fix(media-request-subscriber): prevent mediald nullification from cascade saves (#2356)
This commit is contained in:
@@ -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<MediaRe
|
||||
await mediaRepository.save(media);
|
||||
})
|
||||
.catch(async () => {
|
||||
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<MediaRe
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: entity.media.id },
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
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
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: entity.media.id },
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
@@ -707,10 +719,23 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
||||
await mediaRepository.save(media);
|
||||
})
|
||||
.catch(async () => {
|
||||
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<MediaRe
|
||||
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', {
|
||||
@@ -768,26 +792,29 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const statusKey = entity.is4k ? 'status4k' : 'status';
|
||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
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[statusKey] !== MediaStatus.AVAILABLE &&
|
||||
media[statusKey] !== MediaStatus.PARTIALLY_AVAILABLE &&
|
||||
media[statusKey] !== MediaStatus.PROCESSING
|
||||
) {
|
||||
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING;
|
||||
mediaRepository.save(media);
|
||||
media[statusKey] = MediaStatus.PROCESSING;
|
||||
await mediaRepository.save(media);
|
||||
}
|
||||
|
||||
if (
|
||||
media.mediaType === MediaType.MOVIE &&
|
||||
entity.status === MediaRequestStatus.DECLINED &&
|
||||
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
|
||||
media[statusKey] !== MediaStatus.DELETED
|
||||
) {
|
||||
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
mediaRepository.save(media);
|
||||
media[statusKey] = MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -799,14 +826,71 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
||||
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[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<MediaRe
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (!fullMedia) return;
|
||||
|
||||
if (
|
||||
const needsStatusUpdate =
|
||||
!fullMedia.requests.some((request) => !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<MediaRequest>): void {
|
||||
public async afterUpdate(event: UpdateEvent<MediaRequest>): Promise<void> {
|
||||
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<MediaRequest>): void {
|
||||
public async afterInsert(event: InsertEvent<MediaRequest>): Promise<void> {
|
||||
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<MediaRequest>): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user