refactor(MediaRequest): consolidate lifecycle hooks into subscriber & restrict cascade to prevent recursion (#4124)

* fix(mediarequest entity): narrow cascade to insert & remove to prevent hook recursion

Restrict cascade options on the MediaRequest→Media relation to only `insert` and `remove`to avoid
nested subscriber/AfterUpdate recursion when saving entities.

* fix(mediarequest): move methods modifying MediaRequest to its Subscriber

* fix(mediasubscriber): use event.manager for parent media updates on remove

Replace `getRepository(Media)` calls with `event.manager` in the `afterRemove` hook so that
parent-media status resets run within the same transaction/QueryRunner (important for postgresql.
Doesnt affect sqlite).

* refactor(mediasubscriber): make afterInsert and afterUpdate async and await internal operations

This should prevent unhandled promise rejections and ensure sequential execution of lifecycle
actions

---------

Co-authored-by: Gauthier <mail@gauthierth.fr>
This commit is contained in:
fallenbagel
2025-05-05 16:04:11 +08:00
committed by OwsleyJr
parent 9967983a95
commit b383bbcdc1
3 changed files with 676 additions and 79 deletions

View File

@@ -103,7 +103,7 @@ class Media {
public status4k: MediaStatus;
@OneToMany(() => MediaRequest, (request) => request.media, {
cascade: ['insert', 'remove'],
cascade: ['insert', 'update'],
})
public requests: MediaRequest[];

View File

@@ -1,6 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import {
MediaRequestStatus,
MediaStatus,
@@ -11,13 +9,10 @@ import OverrideRule from '@server/entity/OverrideRule';
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { truncate } from 'lodash';
import {
AfterInsert,
AfterLoad,
AfterUpdate,
Column,
Entity,
@@ -702,74 +697,6 @@ export class MediaRequest {
}
}
@AfterUpdate()
@AfterInsert()
public async updateParentStatus(): Promise<void> {
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
where: { id: this.media.id },
relations: { requests: true },
});
if (!media) {
logger.error('Media data not found', {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
return;
}
const seasonRequestRepository = getRepository(SeasonRequest);
if (
this.status === MediaRequestStatus.APPROVED &&
// Do not update the status if the item is already partially available or available
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE &&
media[this.is4k ? 'status4k' : 'status'] !==
MediaStatus.PARTIALLY_AVAILABLE
) {
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING;
mediaRepository.save(media);
}
if (
media.mediaType === MediaType.MOVIE &&
this.status === MediaRequestStatus.DECLINED &&
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
) {
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
mediaRepository.save(media);
}
/**
* If the media type is TV, and we are declining a request,
* we must check if its the only pending request and that
* there the current media status is just pending (meaning no
* other requests have yet to be approved)
*/
if (
media.mediaType === MediaType.TV &&
this.status === MediaRequestStatus.DECLINED &&
media.requests.filter(
(request) => request.status === MediaRequestStatus.PENDING
).length === 0 &&
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING &&
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
) {
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
mediaRepository.save(media);
}
// Approve child seasons if parent is approved
if (
media.mediaType === MediaType.TV &&
this.status === MediaRequestStatus.APPROVED
) {
this.seasons.forEach((season) => {
season.status = MediaRequestStatus.APPROVED;
seasonRequestRepository.save(season);
});
}
}
static async sendNotification(
entity: MediaRequest,
media: Media,