Compare commits

...

13 Commits

Author SHA1 Message Date
fallenbagel
d73f0391ab fix(media-request-subscriber): prevent updating season status if other active requests exist 2026-02-09 19:33:13 +08:00
fallenbagel
0293632b68 fix(mediarequestsubscriber): reset season statuses when a TV request is declined 2026-02-09 19:33:13 +08:00
fallenbagel
a37ee6fb30 fix: exclude current request from pending count during status updates
refactor: more loggssss

fix: pOTENTIAL FIX
2026-02-09 19:33:13 +08:00
fallenbagel
35050f7513 fix(media-request-subscriber): improve error handling and logging in request status updates 2026-02-09 19:33:13 +08:00
fallenbagel
c7d6073b72 fix(media-request-subscriber): better async handling for afterInsert & afterUpdate 2026-02-09 19:33:13 +08:00
fallenbagel
febf82e961 refactor(media-request): remove excessive debug logging 2026-02-09 19:33:13 +08:00
fallenbagel
95a9645019 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
2026-02-09 19:33:13 +08:00
fallenbagel
064b6eab74 refactor(media-request): remove all fixes and enable verbose debug logging 2026-02-09 19:33:12 +08:00
fallenbagel
c1977bb1a9 fix(media-request): update mediaId handling to use RelationId for TypeORM mapping 2026-02-09 19:33:12 +08:00
fallenbagel
c79f2a5e1b fix(media-request): set mediaId explicitly when creating MediaRequest instances 2026-02-09 19:33:12 +08:00
fallenbagel
15b017c52c fix(media-request): resolve mediaId null value using read-only column mapping 2026-02-09 19:33:12 +08:00
fallenbagel
58934d0455 fix(media-request): set mediaId explicitly to resolve TypeORM relation mapping issue 2026-02-09 19:33:12 +08:00
fallenbagel
a0c8c231fd fix: add explicit JoinClumn to MediaRequest media relation
Fixes intermittent NULL mediaId foreign key on media_request records byadding explicit @JoinColumn
decorator to the media relation. Without this,TypeORM's implicit FK mapping was unreliable, causing
orphaned requeststhat would crash the frontend when accessing user profiles. Also removes the
redundant @Column decorator for mediaId which conflicted withthe relation, and removes explicit
mediaId assignments in the constructorwhich are now handled correctly by TypeORM through the
relation.
2026-02-09 19:33:12 +08:00
2 changed files with 166 additions and 56 deletions

View File

@@ -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,72 @@ 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 requestRepository = getRepository(MediaRequest);
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 +915,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> {

View File

@@ -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(