From df4315aaba7946d5462e46b5b1ddb5c23a7c4af8 Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+fallenbagel@users.noreply.github.com> Date: Mon, 5 May 2025 16:04:11 +0800 Subject: [PATCH] refactor(MediaRequest): consolidate lifecycle hooks into subscriber & restrict cascade to prevent recursion (#4124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mediarequest entity): narrow cascade to insert & remove to prevent hook recursion Restrict cascade options on the MediaRequest→Media relation to only `insert` and `remove`to avoid nested subscriber/AfterUpdate recursion when saving entities. * fix(mediarequest): move methods modifying MediaRequest to its Subscriber * fix(mediasubscriber): use event.manager for parent media updates on remove Replace `getRepository(Media)` calls with `event.manager` in the `afterRemove` hook so that parent-media status resets run within the same transaction/QueryRunner (important for postgresql. Doesnt affect sqlite). * refactor(mediasubscriber): make afterInsert and afterUpdate async and await internal operations This should prevent unhandled promise rejections and ensure sequential execution of lifecycle actions --------- Co-authored-by: Gauthier --- server/entity/Media.ts | 4 +- server/entity/MediaRequest.ts | 682 +------------------- server/subscriber/MediaRequestSubscriber.ts | 680 ++++++++++++++++++- 3 files changed, 713 insertions(+), 653 deletions(-) diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 2d1691724..840f2d9a4 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -90,7 +90,9 @@ class Media { @Column({ type: 'int', default: MediaStatus.UNKNOWN }) public status4k: MediaStatus; - @OneToMany(() => MediaRequest, (request) => request.media, { cascade: true }) + @OneToMany(() => MediaRequest, (request) => request.media, { + cascade: ['insert', 'update'], + }) public requests: MediaRequest[]; @OneToMany(() => Season, (season) => season.media, { diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index afc8010e9..fe31fbfd4 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1,12 +1,4 @@ -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, @@ -16,12 +8,10 @@ import { getRepository } from '@server/datasource'; 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 { isEqual, truncate } from 'lodash'; +import { truncate } from 'lodash'; import { AfterInsert, - AfterRemove, AfterUpdate, Column, CreateDateColumn, @@ -468,12 +458,6 @@ export class MediaRequest { Object.assign(this, init); } - @AfterUpdate() - @AfterInsert() - public async sendMedia(): Promise { - await Promise.all([this.sendToRadarr(), this.sendToSonarr()]); - } - @AfterInsert() public async notifyNewRequest(): Promise { if (this.status === MediaRequestStatus.PENDING) { @@ -490,10 +474,14 @@ export class MediaRequest { return; } - this.sendNotification(media, Notification.MEDIA_PENDING); + MediaRequest.sendNotification(this, media, Notification.MEDIA_PENDING); if (this.isAutoRequest) { - this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED); + MediaRequest.sendNotification( + this, + media, + Notification.MEDIA_AUTO_REQUESTED + ); } } } @@ -531,7 +519,8 @@ export class MediaRequest { return; } - this.sendNotification( + MediaRequest.sendNotification( + this, media, this.status === MediaRequestStatus.APPROVED ? autoApproved @@ -545,7 +534,11 @@ export class MediaRequest { autoApproved && this.isAutoRequest ) { - this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED); + MediaRequest.sendNotification( + this, + media, + Notification.MEDIA_AUTO_REQUESTED + ); } } } @@ -557,661 +550,56 @@ 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); - }); - } - } - - @AfterRemove() - public async handleRemoveParentUpdate(): Promise { - const mediaRepository = getRepository(Media); - const fullMedia = await mediaRepository.findOneOrFail({ - where: { id: this.media.id }, - relations: { requests: true }, - }); - - 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; - } - - mediaRepository.save(fullMedia); - } - - public async sendToRadarr(): Promise { - if ( - this.status === MediaRequestStatus.APPROVED && - this.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: this.id, - mediaId: this.media.id, - } - ); - return; - } - - let radarrSettings = settings.radarr.find( - (radarr) => radarr.isDefault && radarr.is4k === this.is4k - ); - - if ( - this.serverId !== null && - this.serverId >= 0 && - radarrSettings?.id !== this.serverId - ) { - radarrSettings = settings.radarr.find( - (radarr) => radarr.id === this.serverId - ); - logger.info( - `Request has an override server: ${radarrSettings?.name}`, - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - } - ); - } - - if (!radarrSettings) { - logger.warn( - `There is no default ${ - this.is4k ? '4K ' : '' - }Radarr server configured. Did you set any of your ${ - this.is4k ? '4K ' : '' - }Radarr servers as default?`, - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - } - ); - return; - } - - let rootFolder = radarrSettings.activeDirectory; - let qualityProfile = radarrSettings.activeProfileId; - let tags = radarrSettings.tags ? [...radarrSettings.tags] : []; - - if ( - this.rootFolder && - this.rootFolder !== '' && - this.rootFolder !== radarrSettings.activeDirectory - ) { - rootFolder = this.rootFolder; - logger.info(`Request has an override root folder: ${rootFolder}`, { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); - } - - if ( - this.profileId && - this.profileId !== radarrSettings.activeProfileId - ) { - qualityProfile = this.profileId; - logger.info( - `Request has an override quality profile ID: ${qualityProfile}`, - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - } - ); - } - - if (this.tags && !isEqual(this.tags, radarrSettings.tags)) { - tags = this.tags; - logger.info(`Request has override tags`, { - label: 'Media Request', - requestId: this.id, - mediaId: this.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: this.media.tmdbId }); - - const media = await mediaRepository.findOne({ - where: { id: this.media.id }, - }); - - if (!media) { - logger.error('Media data not found', { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); - return; - } - - if (radarrSettings.tagRequests) { - let userTag = (await radarr.getTags()).find((v) => - v.label.startsWith(this.requestedBy.id + ' - ') - ); - if (!userTag) { - logger.info(`Requester has no active tag. Creating new`, { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - userId: this.requestedBy.id, - newTag: - this.requestedBy.id + ' - ' + this.requestedBy.displayName, - }); - userTag = await radarr.createTag({ - label: this.requestedBy.id + ' - ' + this.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: this.id, - mediaId: this.media.id, - userId: this.requestedBy.id, - radarrServer: radarrSettings.hostname + ':' + radarrSettings.port, - }); - } - } - - if ( - media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE - ) { - logger.warn('Media already exists, marking request as APPROVED', { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); - - const requestRepository = getRepository(MediaRequest); - this.status = MediaRequestStatus.APPROVED; - await requestRepository.save(this); - 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 this 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: this.media.id }, - }); - - if (!media) { - throw new Error('Media data not found'); - } - - media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] = - radarrMovie.id; - media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = - radarrMovie.titleSlug; - media[this.is4k ? 'serviceId4k' : 'serviceId'] = radarrSettings?.id; - await mediaRepository.save(media); - }) - .catch(async () => { - const requestRepository = getRepository(MediaRequest); - - this.status = MediaRequestStatus.FAILED; - requestRepository.save(this); - - logger.warn( - 'Something went wrong sending movie request to Radarr, marking status as FAILED', - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - radarrMovieOptions, - } - ); - - this.sendNotification(media, Notification.MEDIA_FAILED); - }); - logger.info('Sent request to Radarr', { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); - } catch (e) { - logger.error('Something went wrong sending request to Radarr', { - label: 'Media Request', - errorMessage: e.message, - requestId: this.id, - mediaId: this.media.id, - }); - throw new Error(e.message); - } - } - } - - public async sendToSonarr(): Promise { - if ( - this.status === MediaRequestStatus.APPROVED && - this.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: this.id, - mediaId: this.media.id, - } - ); - return; - } - - let sonarrSettings = settings.sonarr.find( - (sonarr) => sonarr.isDefault && sonarr.is4k === this.is4k - ); - - if ( - this.serverId !== null && - this.serverId >= 0 && - sonarrSettings?.id !== this.serverId - ) { - sonarrSettings = settings.sonarr.find( - (sonarr) => sonarr.id === this.serverId - ); - logger.info( - `Request has an override server: ${sonarrSettings?.name}`, - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - } - ); - } - - if (!sonarrSettings) { - logger.warn( - `There is no default ${ - this.is4k ? '4K ' : '' - }Sonarr server configured. Did you set any of your ${ - this.is4k ? '4K ' : '' - }Sonarr servers as default?`, - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - } - ); - return; - } - - const media = await mediaRepository.findOne({ - where: { id: this.media.id }, - relations: { requests: true }, - }); - - if (!media) { - throw new Error('Media data not found'); - } - - if ( - media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE - ) { - logger.warn('Media already exists, marking request as APPROVED', { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); - - const requestRepository = getRepository(MediaRequest); - this.status = MediaRequestStatus.APPROVED; - await requestRepository.save(this); - 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(this); - 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 ( - this.rootFolder && - this.rootFolder !== '' && - this.rootFolder !== rootFolder - ) { - rootFolder = this.rootFolder; - logger.info(`Request has an override root folder: ${rootFolder}`, { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); - } - - if (this.profileId && this.profileId !== qualityProfile) { - qualityProfile = this.profileId; - logger.info( - `Request has an override quality profile ID: ${qualityProfile}`, - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - } - ); - } - - if ( - this.languageProfileId && - this.languageProfileId !== languageProfile - ) { - languageProfile = this.languageProfileId; - logger.info( - `Request has an override language profile ID: ${languageProfile}`, - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - } - ); - } - - if (this.tags && !isEqual(this.tags, tags)) { - tags = this.tags; - logger.info(`Request has override tags`, { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - tagIds: tags, - }); - } - - if (sonarrSettings.tagRequests) { - let userTag = (await sonarr.getTags()).find((v) => - v.label.startsWith(this.requestedBy.id + ' - ') - ); - if (!userTag) { - logger.info(`Requester has no active tag. Creating new`, { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - userId: this.requestedBy.id, - newTag: - this.requestedBy.id + ' - ' + this.requestedBy.displayName, - }); - userTag = await sonarr.createTag({ - label: this.requestedBy.id + ' - ' + this.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: this.id, - mediaId: this.media.id, - userId: this.requestedBy.id, - sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port, - }); - } - } - - const sonarrSeriesOptions: AddSeriesOptions = { - profileId: qualityProfile, - languageProfileId: languageProfile, - rootFolderPath: rootFolder, - title: series.name, - tvdbid: tvdbId, - seasons: this.seasons.map((season) => season.seasonNumber), - seasonFolder: sonarrSettings.enableSeasonFolders, - seriesType, - tags, - monitored: true, - searchNow: !sonarrSettings.preventSearch, - }; - - // Run this 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: this.media.id }, - relations: { requests: true }, - }); - - if (!media) { - throw new Error('Media data not found'); - } - - media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] = - sonarrSeries.id; - media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = - sonarrSeries.titleSlug; - media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id; - await mediaRepository.save(media); - }) - .catch(async () => { - const requestRepository = getRepository(MediaRequest); - - this.status = MediaRequestStatus.FAILED; - requestRepository.save(this); - - logger.warn( - 'Something went wrong sending series request to Sonarr, marking status as FAILED', - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - sonarrSeriesOptions, - } - ); - - this.sendNotification(media, Notification.MEDIA_FAILED); - }); - logger.info('Sent request to Sonarr', { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - }); - } catch (e) { - logger.error('Something went wrong sending request to Sonarr', { - label: 'Media Request', - errorMessage: e.message, - requestId: this.id, - mediaId: this.media.id, - }); - throw new Error(e.message); - } - } - } - - private async sendNotification(media: Media, type: Notification) { + static async sendNotification( + entity: MediaRequest, + media: Media, + type: Notification + ) { const tmdb = new TheMovieDb(); try { - const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series'; + const mediaType = entity.type === MediaType.MOVIE ? 'Movie' : 'Series'; let event: string | undefined; let notifyAdmin = true; let notifySystem = true; switch (type) { case Notification.MEDIA_APPROVED: - event = `${this.is4k ? '4K ' : ''}${mediaType} Request Approved`; + event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Approved`; notifyAdmin = false; break; case Notification.MEDIA_DECLINED: - event = `${this.is4k ? '4K ' : ''}${mediaType} Request Declined`; + event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Declined`; notifyAdmin = false; break; case Notification.MEDIA_PENDING: - event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`; + event = `New ${entity.is4k ? '4K ' : ''}${mediaType} Request`; break; case Notification.MEDIA_AUTO_REQUESTED: event = `${ - this.is4k ? '4K ' : '' + entity.is4k ? '4K ' : '' }${mediaType} Request Automatically Submitted`; notifyAdmin = false; notifySystem = false; break; case Notification.MEDIA_AUTO_APPROVED: event = `${ - this.is4k ? '4K ' : '' + entity.is4k ? '4K ' : '' }${mediaType} Request Automatically Approved`; break; case Notification.MEDIA_FAILED: - event = `${this.is4k ? '4K ' : ''}${mediaType} Request Failed`; + event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Failed`; break; } - if (this.type === MediaType.MOVIE) { + if (entity.type === MediaType.MOVIE) { const movie = await tmdb.getMovie({ movieId: media.tmdbId }); notificationManager.sendNotification(type, { media, - request: this, + request: entity, notifyAdmin, notifySystem, - notifyUser: notifyAdmin ? undefined : this.requestedBy, + notifyUser: notifyAdmin ? undefined : entity.requestedBy, event, subject: `${movie.title}${ movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' @@ -1223,14 +611,14 @@ export class MediaRequest { }), image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, }); - } else if (this.type === MediaType.TV) { + } else if (entity.type === MediaType.TV) { const tv = await tmdb.getTvShow({ tvId: media.tmdbId }); notificationManager.sendNotification(type, { media, - request: this, + request: entity, notifyAdmin, notifySystem, - notifyUser: notifyAdmin ? undefined : this.requestedBy, + notifyUser: notifyAdmin ? undefined : entity.requestedBy, event, subject: `${tv.name}${ tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : '' @@ -1244,7 +632,7 @@ export class MediaRequest { extra: [ { name: 'Requested Seasons', - value: this.seasons + value: entity.seasons .map((season) => season.seasonNumber) .join(', '), }, @@ -1255,8 +643,8 @@ export class MediaRequest { logger.error('Something went wrong sending media notification(s)', { label: 'Notifications', errorMessage: e.message, - requestId: this.id, - mediaId: this.media.id, + requestId: entity.id, + mediaId: entity.media.id, }); } } 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; }