fix(media-request-subscriber): prevent mediald nullification from cascade saves (#2356)

This commit is contained in:
fallenbagel
2026-02-13 15:02:22 +05:00
committed by GitHub
parent 91261f6a61
commit 1ed86c14c0
2 changed files with 165 additions and 56 deletions

View File

@@ -15,6 +15,7 @@ import {
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest'; import { MediaRequest } from '@server/entity/MediaRequest';
import Season from '@server/entity/Season';
import SeasonRequest from '@server/entity/SeasonRequest'; import SeasonRequest from '@server/entity/SeasonRequest';
import notificationManager, { Notification } from '@server/lib/notifications'; import notificationManager, { Notification } from '@server/lib/notifications';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
@@ -27,7 +28,7 @@ import type {
RemoveEvent, RemoveEvent,
UpdateEvent, UpdateEvent,
} from 'typeorm'; } from 'typeorm';
import { EventSubscriber } from 'typeorm'; import { EventSubscriber, Not } from 'typeorm';
const sanitizeDisplayName = (displayName: string): string => { const sanitizeDisplayName = (displayName: string): string => {
return displayName return displayName
@@ -397,10 +398,23 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
await mediaRepository.save(media); await mediaRepository.save(media);
}) })
.catch(async () => { .catch(async () => {
const requestRepository = getRepository(MediaRequest); try {
const requestRepository = getRepository(MediaRequest);
entity.status = MediaRequestStatus.FAILED; if (entity.status !== MediaRequestStatus.FAILED) {
requestRepository.save(entity); 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( logger.warn(
'Something went wrong sending movie request to Radarr, marking status as FAILED', 'Something went wrong sending movie request to Radarr, marking status as FAILED',
@@ -503,7 +517,6 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
const media = await mediaRepository.findOne({ const media = await mediaRepository.findOne({
where: { id: entity.media.id }, where: { id: entity.media.id },
relations: { requests: true },
}); });
if (!media) { if (!media) {
@@ -690,7 +703,6 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
// We grab media again here to make sure we have the latest version of it // We grab media again here to make sure we have the latest version of it
const media = await mediaRepository.findOne({ const media = await mediaRepository.findOne({
where: { id: entity.media.id }, where: { id: entity.media.id },
relations: { requests: true },
}); });
if (!media) { if (!media) {
@@ -707,10 +719,23 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
await mediaRepository.save(media); await mediaRepository.save(media);
}) })
.catch(async () => { .catch(async () => {
const requestRepository = getRepository(MediaRequest); try {
const requestRepository = getRepository(MediaRequest);
entity.status = MediaRequestStatus.FAILED; if (entity.status !== MediaRequestStatus.FAILED) {
requestRepository.save(entity); 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( logger.warn(
'Something went wrong sending series request to Sonarr, marking status as FAILED', 'Something went wrong sending series request to Sonarr, marking status as FAILED',
@@ -758,7 +783,6 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({ const media = await mediaRepository.findOne({
where: { id: entity.media.id }, where: { id: entity.media.id },
relations: { requests: true },
}); });
if (!media) { if (!media) {
logger.error('Media data not found', { logger.error('Media data not found', {
@@ -768,26 +792,29 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
}); });
return; return;
} }
const statusKey = entity.is4k ? 'status4k' : 'status';
const seasonRequestRepository = getRepository(SeasonRequest); const seasonRequestRepository = getRepository(SeasonRequest);
const requestRepository = getRepository(MediaRequest);
if ( if (
entity.status === MediaRequestStatus.APPROVED && entity.status === MediaRequestStatus.APPROVED &&
// Do not update the status if the item is already partially available or available // Do not update the status if the item is already partially available or available
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE && media[statusKey] !== MediaStatus.AVAILABLE &&
media[entity.is4k ? 'status4k' : 'status'] !== media[statusKey] !== MediaStatus.PARTIALLY_AVAILABLE &&
MediaStatus.PARTIALLY_AVAILABLE && media[statusKey] !== MediaStatus.PROCESSING
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.PROCESSING
) { ) {
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING; media[statusKey] = MediaStatus.PROCESSING;
mediaRepository.save(media); await mediaRepository.save(media);
} }
if ( if (
media.mediaType === MediaType.MOVIE && media.mediaType === MediaType.MOVIE &&
entity.status === MediaRequestStatus.DECLINED && entity.status === MediaRequestStatus.DECLINED &&
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED media[statusKey] !== MediaStatus.DELETED
) { ) {
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; media[statusKey] = MediaStatus.UNKNOWN;
mediaRepository.save(media); await mediaRepository.save(media);
} }
/** /**
@@ -799,14 +826,71 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
if ( if (
media.mediaType === MediaType.TV && media.mediaType === MediaType.TV &&
entity.status === MediaRequestStatus.DECLINED && entity.status === MediaRequestStatus.DECLINED &&
media.requests.filter( media[statusKey] === MediaStatus.PENDING
(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; const pendingCount = await requestRepository.count({
mediaRepository.save(media); 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 // Approve child seasons if parent is approved
@@ -830,54 +914,74 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
relations: { requests: true }, relations: { requests: true },
}); });
if (!fullMedia) return; const needsStatusUpdate =
if (
!fullMedia.requests.some((request) => !request.is4k) && !fullMedia.requests.some((request) => !request.is4k) &&
fullMedia.status !== MediaStatus.AVAILABLE fullMedia.status !== MediaStatus.AVAILABLE;
) {
fullMedia.status = MediaStatus.UNKNOWN;
}
if ( const needs4kStatusUpdate =
!fullMedia.requests.some((request) => request.is4k) && !fullMedia.requests.some((request) => request.is4k) &&
fullMedia.status4k !== MediaStatus.AVAILABLE fullMedia.status4k !== MediaStatus.AVAILABLE;
) {
fullMedia.status4k = MediaStatus.UNKNOWN;
}
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<MediaRequest>): void { public async afterUpdate(event: UpdateEvent<MediaRequest>): Promise<void> {
if (!event.entity) { if (!event.entity) {
return; return;
} }
this.sendToRadarr(event.entity as MediaRequest); try {
this.sendToSonarr(event.entity as MediaRequest); 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) {
if (event.entity.status === MediaRequestStatus.COMPLETED) { await this.notifyAvailableMovie(event.entity as MediaRequest, event);
if (event.entity.media.mediaType === MediaType.MOVIE) { }
this.notifyAvailableMovie(event.entity as MediaRequest, event); if (event.entity.media.mediaType === MediaType.TV) {
} await this.notifyAvailableSeries(event.entity as MediaRequest, event);
if (event.entity.media.mediaType === MediaType.TV) { }
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<MediaRequest>): void { public async afterInsert(event: InsertEvent<MediaRequest>): Promise<void> {
if (!event.entity) { if (!event.entity) {
return; return;
} }
this.sendToRadarr(event.entity as MediaRequest); try {
this.sendToSonarr(event.entity as MediaRequest); await this.sendToRadarr(event.entity as MediaRequest);
await this.sendToSonarr(event.entity as MediaRequest);
this.updateParentStatus(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<MediaRequest>): Promise<void> { public async afterRemove(event: RemoveEvent<MediaRequest>): Promise<void> {

View File

@@ -360,7 +360,12 @@ const TvRequestModal = ({
).length > 0 ).length > 0
) { ) {
data.mediaInfo.requests data.mediaInfo.requests
.filter((request) => request.is4k === is4k) .filter(
(request) =>
request.is4k === is4k &&
request.status !== MediaRequestStatus.DECLINED &&
request.status !== MediaRequestStatus.COMPLETED
)
.forEach((request) => { .forEach((request) => {
if (!seasonRequest) { if (!seasonRequest) {
seasonRequest = request.seasons.find( seasonRequest = request.seasons.find(