From 95a96450194764068ed1cfcadc2722e65fceb9fe Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:52:20 +0800 Subject: [PATCH] fix(media-request-subscriber): prevent mediaId nullification from cascade saves The mediaId FK on `media_request` rows was being set to NULL shortly after insert caused by `mediaRepository.save(media)` being called with the requests relation loaded which in turn triggered TypeORM's cascade reconciliation on all loaded request entities, corrupting the FK. This issue must have even gotten worse when the saves were not being awaited in updateParentStatus which could have caused race conditions between concurrent subscriber methods (updateParentStatus, sendToSonarr, sendToRadarr) all firing from afterInsert. fix #2315 --- server/subscriber/MediaRequestSubscriber.ts | 129 ++++++++++++++------ 1 file changed, 95 insertions(+), 34 deletions(-) diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index f9bfeabe..f8fb799b 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -399,8 +399,10 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface { const requestRepository = getRepository(MediaRequest); - entity.status = MediaRequestStatus.FAILED; - requestRepository.save(entity); + if (entity.status !== MediaRequestStatus.FAILED) { + entity.status = MediaRequestStatus.FAILED; + await requestRepository.save(entity); + } logger.warn( 'Something went wrong sending movie request to Radarr, marking status as FAILED', @@ -503,7 +505,6 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface { const requestRepository = getRepository(MediaRequest); - entity.status = MediaRequestStatus.FAILED; - requestRepository.save(entity); + if (entity.status !== MediaRequestStatus.FAILED) { + entity.status = MediaRequestStatus.FAILED; + await requestRepository.save(entity); + } logger.warn( 'Something went wrong sending series request to Sonarr, marking status as FAILED', @@ -758,7 +760,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 requestRepository = getRepository(MediaRequest); + const pendingCount = await requestRepository.count({ + where: { media: { id: media.id }, status: MediaRequestStatus.PENDING }, + }); + + logger.debug('updateParentStatus: TV declined check', { + label: 'Media Request', + requestId: entity.id, + mediaId: media.id, + pendingCount, + }); + + 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); + } + } } // Approve child seasons if parent is approved @@ -815,6 +852,12 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface { + logger.debug('updateParentStatus: approving season request', { + label: 'Media Request', + requestId: entity.id, + seasonRequestId: season.id, + seasonNumber: season.seasonNumber, + }); season.status = MediaRequestStatus.APPROVED; seasonRequestRepository.save(season); }); @@ -832,21 +875,39 @@ 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) { + logger.debug('handleRemoveParentUpdate: setting status to UNKNOWN', { + label: 'Media Request', + requestId: entity.id, + mediaId: cleanMedia.id, + }); + cleanMedia.status = MediaStatus.UNKNOWN; + } + if (needs4kStatusUpdate) { + logger.debug('handleRemoveParentUpdate: setting status4k to UNKNOWN', { + label: 'Media Request', + requestId: entity.id, + mediaId: cleanMedia.id, + }); + cleanMedia.status4k = MediaStatus.UNKNOWN; + } + + await manager.save(cleanMedia); + } } public afterUpdate(event: UpdateEvent): void {