From 1ed86c14c00b12971beb58055fa3ec6130701127 Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+fallenbagel@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:02:22 +0500 Subject: [PATCH] fix(media-request-subscriber): prevent mediald nullification from cascade saves (#2356) --- server/subscriber/MediaRequestSubscriber.ts | 214 +++++++++++++----- .../RequestModal/TvRequestModal.tsx | 7 +- 2 files changed, 165 insertions(+), 56 deletions(-) diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index f9bfeabe..c54ee339 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -15,6 +15,7 @@ import { 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 notificationManager, { Notification } from '@server/lib/notifications'; import { getSettings } from '@server/lib/settings'; @@ -27,7 +28,7 @@ import type { RemoveEvent, UpdateEvent, } from 'typeorm'; -import { EventSubscriber } from 'typeorm'; +import { EventSubscriber, Not } from 'typeorm'; const sanitizeDisplayName = (displayName: string): string => { return displayName @@ -397,10 +398,23 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface { - const requestRepository = getRepository(MediaRequest); + try { + const requestRepository = getRepository(MediaRequest); - entity.status = MediaRequestStatus.FAILED; - requestRepository.save(entity); + if (entity.status !== MediaRequestStatus.FAILED) { + entity.status = MediaRequestStatus.FAILED; + await requestRepository.save(entity); + } + } catch (saveError) { + logger.error('Failed to mark request as FAILED', { + label: 'Media Request', + requestId: entity.id, + errorMessage: + saveError instanceof Error + ? saveError.message + : String(saveError), + }); + } logger.warn( 'Something went wrong sending movie request to Radarr, marking status as FAILED', @@ -503,7 +517,6 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface { - const requestRepository = getRepository(MediaRequest); + try { + const requestRepository = getRepository(MediaRequest); - entity.status = MediaRequestStatus.FAILED; - requestRepository.save(entity); + if (entity.status !== MediaRequestStatus.FAILED) { + entity.status = MediaRequestStatus.FAILED; + await requestRepository.save(entity); + } + } catch (saveError) { + logger.error('Failed to mark request as FAILED', { + label: 'Media Request', + requestId: entity.id, + errorMessage: + saveError instanceof Error + ? saveError.message + : String(saveError), + }); + } logger.warn( 'Something went wrong sending series request to Sonarr, marking status as FAILED', @@ -758,7 +783,6 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface request.status === MediaRequestStatus.PENDING - ).length === 0 && - media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING && - media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED + media[statusKey] === MediaStatus.PENDING ) { - media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; - mediaRepository.save(media); + const pendingCount = await requestRepository.count({ + where: { + media: { id: media.id }, + status: MediaRequestStatus.PENDING, + is4k: entity.is4k, + id: Not(entity.id), + }, + }); + + if (pendingCount === 0) { + // Re-fetch media without requests to avoid cascade issues + const freshMedia = await mediaRepository.findOne({ + where: { id: media.id }, + }); + if (freshMedia) { + freshMedia[statusKey] = MediaStatus.UNKNOWN; + await mediaRepository.save(freshMedia); + } + } + } + + // Reset season statuses when a TV request is declined + if ( + media.mediaType === MediaType.TV && + entity.status === MediaRequestStatus.DECLINED + ) { + const seasonRepository = getRepository(Season); + const actualSeasons = await seasonRepository.find({ + where: { media: { id: media.id } }, + }); + + for (const seasonRequest of entity.seasons) { + seasonRequest.status = MediaRequestStatus.DECLINED; + await seasonRequestRepository.save(seasonRequest); + + const season = actualSeasons.find( + (s) => s.seasonNumber === seasonRequest.seasonNumber + ); + + if (season && season[statusKey] === MediaStatus.PENDING) { + const otherActiveRequests = await requestRepository + .createQueryBuilder('request') + .leftJoinAndSelect('request.seasons', 'season') + .where('request.mediaId = :mediaId', { mediaId: media.id }) + .andWhere('request.id != :requestId', { requestId: entity.id }) + .andWhere('request.is4k = :is4k', { is4k: entity.is4k }) + .andWhere('request.status NOT IN (:...statuses)', { + statuses: [ + MediaRequestStatus.DECLINED, + MediaRequestStatus.COMPLETED, + ], + }) + .andWhere('season.seasonNumber = :seasonNumber', { + seasonNumber: season.seasonNumber, + }) + .getCount(); + + if (otherActiveRequests === 0) { + season[statusKey] = MediaStatus.UNKNOWN; + await seasonRepository.save(season); + } + } + } } // Approve child seasons if parent is approved @@ -830,54 +914,74 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface !request.is4k) && - fullMedia.status !== MediaStatus.AVAILABLE - ) { - fullMedia.status = MediaStatus.UNKNOWN; - } + fullMedia.status !== MediaStatus.AVAILABLE; - if ( + const needs4kStatusUpdate = !fullMedia.requests.some((request) => request.is4k) && - fullMedia.status4k !== MediaStatus.AVAILABLE - ) { - fullMedia.status4k = MediaStatus.UNKNOWN; - } + fullMedia.status4k !== MediaStatus.AVAILABLE; - await manager.save(fullMedia); + if (needsStatusUpdate || needs4kStatusUpdate) { + // Re-fetch WITHOUT requests to avoid cascade issues on save + const cleanMedia = await manager.findOneOrFail(Media, { + where: { id: entity.media.id }, + }); + + if (needsStatusUpdate) { + cleanMedia.status = MediaStatus.UNKNOWN; + } + if (needs4kStatusUpdate) { + cleanMedia.status4k = MediaStatus.UNKNOWN; + } + + await manager.save(cleanMedia); + } } - public afterUpdate(event: UpdateEvent): void { + public async afterUpdate(event: UpdateEvent): Promise { if (!event.entity) { return; } - this.sendToRadarr(event.entity as MediaRequest); - this.sendToSonarr(event.entity as MediaRequest); + try { + await this.sendToRadarr(event.entity as MediaRequest); + await this.sendToSonarr(event.entity as MediaRequest); + await this.updateParentStatus(event.entity as MediaRequest); - 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, event); - } - if (event.entity.media.mediaType === MediaType.TV) { - this.notifyAvailableSeries(event.entity as MediaRequest, event); + if (event.entity.status === MediaRequestStatus.COMPLETED) { + if (event.entity.media.mediaType === MediaType.MOVIE) { + await this.notifyAvailableMovie(event.entity as MediaRequest, event); + } + if (event.entity.media.mediaType === MediaType.TV) { + await this.notifyAvailableSeries(event.entity as MediaRequest, event); + } } + } catch (e) { + logger.error('Error in afterUpdate subscriber', { + label: 'Media Request', + requestId: (event.entity as MediaRequest).id, + errorMessage: e instanceof Error ? e.message : String(e), + }); } } - public afterInsert(event: InsertEvent): void { + public async afterInsert(event: InsertEvent): Promise { if (!event.entity) { return; } - this.sendToRadarr(event.entity as MediaRequest); - this.sendToSonarr(event.entity as MediaRequest); - - this.updateParentStatus(event.entity as MediaRequest); + try { + await this.sendToRadarr(event.entity as MediaRequest); + await this.sendToSonarr(event.entity as MediaRequest); + await this.updateParentStatus(event.entity as MediaRequest); + } catch (e) { + logger.error('Error in afterInsert subscriber', { + label: 'Media Request', + requestId: (event.entity as MediaRequest).id, + errorMessage: e instanceof Error ? e.message : String(e), + }); + } } public async afterRemove(event: RemoveEvent): Promise { diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index ba718302..0a649585 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -360,7 +360,12 @@ const TvRequestModal = ({ ).length > 0 ) { data.mediaInfo.requests - .filter((request) => request.is4k === is4k) + .filter( + (request) => + request.is4k === is4k && + request.status !== MediaRequestStatus.DECLINED && + request.status !== MediaRequestStatus.COMPLETED + ) .forEach((request) => { if (!seasonRequest) { seasonRequest = request.seasons.find(