mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00

* 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>
654 lines
19 KiB
TypeScript
654 lines
19 KiB
TypeScript
import TheMovieDb from '@server/api/themoviedb';
|
|
import {
|
|
MediaRequestStatus,
|
|
MediaStatus,
|
|
MediaType,
|
|
} from '@server/constants/media';
|
|
import { getRepository } from '@server/datasource';
|
|
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
|
|
import notificationManager, { Notification } from '@server/lib/notifications';
|
|
import { Permission } from '@server/lib/permissions';
|
|
import logger from '@server/logger';
|
|
import { truncate } from 'lodash';
|
|
import {
|
|
AfterInsert,
|
|
AfterUpdate,
|
|
Column,
|
|
CreateDateColumn,
|
|
Entity,
|
|
ManyToOne,
|
|
OneToMany,
|
|
PrimaryGeneratedColumn,
|
|
RelationCount,
|
|
UpdateDateColumn,
|
|
} from 'typeorm';
|
|
import Media from './Media';
|
|
import SeasonRequest from './SeasonRequest';
|
|
import { User } from './User';
|
|
|
|
export class RequestPermissionError extends Error {}
|
|
export class QuotaRestrictedError extends Error {}
|
|
export class DuplicateMediaRequestError extends Error {}
|
|
export class NoSeasonsAvailableError extends Error {}
|
|
|
|
type MediaRequestOptions = {
|
|
isAutoRequest?: boolean;
|
|
};
|
|
|
|
@Entity()
|
|
export class MediaRequest {
|
|
public static async request(
|
|
requestBody: MediaRequestBody,
|
|
user: User,
|
|
options: MediaRequestOptions = {}
|
|
): Promise<MediaRequest> {
|
|
const tmdb = new TheMovieDb();
|
|
const mediaRepository = getRepository(Media);
|
|
const requestRepository = getRepository(MediaRequest);
|
|
const userRepository = getRepository(User);
|
|
|
|
let requestUser = user;
|
|
|
|
if (
|
|
requestBody.userId &&
|
|
!requestUser.hasPermission([
|
|
Permission.MANAGE_USERS,
|
|
Permission.MANAGE_REQUESTS,
|
|
])
|
|
) {
|
|
throw new RequestPermissionError(
|
|
'You do not have permission to modify the request user.'
|
|
);
|
|
} else if (requestBody.userId) {
|
|
requestUser = await userRepository.findOneOrFail({
|
|
where: { id: requestBody.userId },
|
|
});
|
|
}
|
|
|
|
if (!requestUser) {
|
|
throw new Error('User missing from request context.');
|
|
}
|
|
|
|
if (
|
|
requestBody.mediaType === MediaType.MOVIE &&
|
|
!requestUser.hasPermission(
|
|
requestBody.is4k
|
|
? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE]
|
|
: [Permission.REQUEST, Permission.REQUEST_MOVIE],
|
|
{
|
|
type: 'or',
|
|
}
|
|
)
|
|
) {
|
|
throw new RequestPermissionError(
|
|
`You do not have permission to make ${
|
|
requestBody.is4k ? '4K ' : ''
|
|
}movie requests.`
|
|
);
|
|
} else if (
|
|
requestBody.mediaType === MediaType.TV &&
|
|
!requestUser.hasPermission(
|
|
requestBody.is4k
|
|
? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV]
|
|
: [Permission.REQUEST, Permission.REQUEST_TV],
|
|
{
|
|
type: 'or',
|
|
}
|
|
)
|
|
) {
|
|
throw new RequestPermissionError(
|
|
`You do not have permission to make ${
|
|
requestBody.is4k ? '4K ' : ''
|
|
}series requests.`
|
|
);
|
|
}
|
|
|
|
const quotas = await requestUser.getQuota();
|
|
|
|
if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
|
|
throw new QuotaRestrictedError('Movie Quota exceeded.');
|
|
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
|
|
throw new QuotaRestrictedError('Series Quota exceeded.');
|
|
}
|
|
|
|
const tmdbMedia =
|
|
requestBody.mediaType === MediaType.MOVIE
|
|
? await tmdb.getMovie({ movieId: requestBody.mediaId })
|
|
: await tmdb.getTvShow({ tvId: requestBody.mediaId });
|
|
|
|
let media = await mediaRepository.findOne({
|
|
where: {
|
|
tmdbId: requestBody.mediaId,
|
|
mediaType: requestBody.mediaType,
|
|
},
|
|
relations: ['requests'],
|
|
});
|
|
|
|
if (!media) {
|
|
media = new Media({
|
|
tmdbId: tmdbMedia.id,
|
|
tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
|
|
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
|
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
|
mediaType: requestBody.mediaType,
|
|
});
|
|
} else {
|
|
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
|
|
media.status = MediaStatus.PENDING;
|
|
}
|
|
|
|
if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) {
|
|
media.status4k = MediaStatus.PENDING;
|
|
}
|
|
}
|
|
|
|
const existing = await requestRepository
|
|
.createQueryBuilder('request')
|
|
.leftJoin('request.media', 'media')
|
|
.leftJoinAndSelect('request.requestedBy', 'user')
|
|
.where('request.is4k = :is4k', { is4k: requestBody.is4k })
|
|
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
|
|
.andWhere('media.mediaType = :mediaType', {
|
|
mediaType: requestBody.mediaType,
|
|
})
|
|
.getMany();
|
|
|
|
if (existing && existing.length > 0) {
|
|
// If there is an existing movie request that isn't declined, don't allow a new one.
|
|
if (
|
|
requestBody.mediaType === MediaType.MOVIE &&
|
|
existing[0].status !== MediaRequestStatus.DECLINED &&
|
|
existing[0].status !== MediaRequestStatus.COMPLETED
|
|
) {
|
|
logger.warn('Duplicate request for media blocked', {
|
|
tmdbId: tmdbMedia.id,
|
|
mediaType: requestBody.mediaType,
|
|
is4k: requestBody.is4k,
|
|
label: 'Media Request',
|
|
});
|
|
|
|
throw new DuplicateMediaRequestError(
|
|
'Request for this media already exists.'
|
|
);
|
|
}
|
|
|
|
// If an existing auto-request for this media exists from the same user,
|
|
// don't allow a new one.
|
|
if (
|
|
existing.find(
|
|
(r) => r.requestedBy.id === requestUser.id && r.isAutoRequest
|
|
)
|
|
) {
|
|
throw new DuplicateMediaRequestError(
|
|
'Auto-request for this media and user already exists.'
|
|
);
|
|
}
|
|
}
|
|
|
|
if (requestBody.mediaType === MediaType.MOVIE) {
|
|
await mediaRepository.save(media);
|
|
|
|
const request = new MediaRequest({
|
|
type: MediaType.MOVIE,
|
|
media,
|
|
requestedBy: requestUser,
|
|
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
|
status: user.hasPermission(
|
|
[
|
|
requestBody.is4k
|
|
? Permission.AUTO_APPROVE_4K
|
|
: Permission.AUTO_APPROVE,
|
|
requestBody.is4k
|
|
? Permission.AUTO_APPROVE_4K_MOVIE
|
|
: Permission.AUTO_APPROVE_MOVIE,
|
|
Permission.MANAGE_REQUESTS,
|
|
],
|
|
{ type: 'or' }
|
|
)
|
|
? MediaRequestStatus.APPROVED
|
|
: MediaRequestStatus.PENDING,
|
|
modifiedBy: user.hasPermission(
|
|
[
|
|
requestBody.is4k
|
|
? Permission.AUTO_APPROVE_4K
|
|
: Permission.AUTO_APPROVE,
|
|
requestBody.is4k
|
|
? Permission.AUTO_APPROVE_4K_MOVIE
|
|
: Permission.AUTO_APPROVE_MOVIE,
|
|
Permission.MANAGE_REQUESTS,
|
|
],
|
|
{ type: 'or' }
|
|
)
|
|
? user
|
|
: undefined,
|
|
is4k: requestBody.is4k,
|
|
serverId: requestBody.serverId,
|
|
profileId: requestBody.profileId,
|
|
rootFolder: requestBody.rootFolder,
|
|
tags: requestBody.tags,
|
|
isAutoRequest: options.isAutoRequest ?? false,
|
|
});
|
|
|
|
await requestRepository.save(request);
|
|
return request;
|
|
} else {
|
|
const tmdbMediaShow = tmdbMedia as Awaited<
|
|
ReturnType<typeof tmdb.getTvShow>
|
|
>;
|
|
const requestedSeasons =
|
|
requestBody.seasons === 'all'
|
|
? tmdbMediaShow.seasons
|
|
.filter((season) => season.season_number !== 0)
|
|
.map((season) => season.season_number)
|
|
: (requestBody.seasons as number[]);
|
|
let existingSeasons: number[] = [];
|
|
|
|
// We need to check existing requests on this title to make sure we don't double up on seasons that were
|
|
// already requested. In the case they were, we just throw out any duplicates but still approve the request.
|
|
// (Unless there are no seasons, in which case we abort)
|
|
if (media.requests) {
|
|
existingSeasons = media.requests
|
|
.filter(
|
|
(request) =>
|
|
request.is4k === requestBody.is4k &&
|
|
request.status !== MediaRequestStatus.DECLINED &&
|
|
request.status !== MediaRequestStatus.COMPLETED
|
|
)
|
|
.reduce((seasons, request) => {
|
|
const combinedSeasons = request.seasons.map(
|
|
(season) => season.seasonNumber
|
|
);
|
|
|
|
return [...seasons, ...combinedSeasons];
|
|
}, [] as number[]);
|
|
}
|
|
|
|
// We should also check seasons that are available/partially available but don't have existing requests
|
|
if (media.seasons) {
|
|
existingSeasons = [
|
|
...existingSeasons,
|
|
...media.seasons
|
|
.filter(
|
|
(season) =>
|
|
season[requestBody.is4k ? 'status4k' : 'status'] !==
|
|
MediaStatus.UNKNOWN &&
|
|
season[requestBody.is4k ? 'status4k' : 'status'] !==
|
|
MediaStatus.DELETED
|
|
)
|
|
.map((season) => season.seasonNumber),
|
|
];
|
|
}
|
|
|
|
const finalSeasons = requestedSeasons.filter(
|
|
(rs) => !existingSeasons.includes(rs)
|
|
);
|
|
|
|
if (finalSeasons.length === 0) {
|
|
throw new NoSeasonsAvailableError('No seasons available to request');
|
|
} else if (
|
|
quotas.tv.limit &&
|
|
finalSeasons.length > (quotas.tv.remaining ?? 0)
|
|
) {
|
|
throw new QuotaRestrictedError('Series Quota exceeded.');
|
|
}
|
|
|
|
await mediaRepository.save(media);
|
|
|
|
const request = new MediaRequest({
|
|
type: MediaType.TV,
|
|
media,
|
|
requestedBy: requestUser,
|
|
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
|
status: user.hasPermission(
|
|
[
|
|
requestBody.is4k
|
|
? Permission.AUTO_APPROVE_4K
|
|
: Permission.AUTO_APPROVE,
|
|
requestBody.is4k
|
|
? Permission.AUTO_APPROVE_4K_TV
|
|
: Permission.AUTO_APPROVE_TV,
|
|
Permission.MANAGE_REQUESTS,
|
|
],
|
|
{ type: 'or' }
|
|
)
|
|
? MediaRequestStatus.APPROVED
|
|
: MediaRequestStatus.PENDING,
|
|
modifiedBy: user.hasPermission(
|
|
[
|
|
requestBody.is4k
|
|
? Permission.AUTO_APPROVE_4K
|
|
: Permission.AUTO_APPROVE,
|
|
requestBody.is4k
|
|
? Permission.AUTO_APPROVE_4K_TV
|
|
: Permission.AUTO_APPROVE_TV,
|
|
Permission.MANAGE_REQUESTS,
|
|
],
|
|
{ type: 'or' }
|
|
)
|
|
? user
|
|
: undefined,
|
|
is4k: requestBody.is4k,
|
|
serverId: requestBody.serverId,
|
|
profileId: requestBody.profileId,
|
|
rootFolder: requestBody.rootFolder,
|
|
languageProfileId: requestBody.languageProfileId,
|
|
tags: requestBody.tags,
|
|
seasons: finalSeasons.map(
|
|
(sn) =>
|
|
new SeasonRequest({
|
|
seasonNumber: sn,
|
|
status: user.hasPermission(
|
|
[
|
|
requestBody.is4k
|
|
? Permission.AUTO_APPROVE_4K
|
|
: Permission.AUTO_APPROVE,
|
|
requestBody.is4k
|
|
? Permission.AUTO_APPROVE_4K_TV
|
|
: Permission.AUTO_APPROVE_TV,
|
|
Permission.MANAGE_REQUESTS,
|
|
],
|
|
{ type: 'or' }
|
|
)
|
|
? MediaRequestStatus.APPROVED
|
|
: MediaRequestStatus.PENDING,
|
|
})
|
|
),
|
|
isAutoRequest: options.isAutoRequest ?? false,
|
|
});
|
|
|
|
await requestRepository.save(request);
|
|
return request;
|
|
}
|
|
}
|
|
|
|
@PrimaryGeneratedColumn()
|
|
public id: number;
|
|
|
|
@Column({ type: 'integer' })
|
|
public status: MediaRequestStatus;
|
|
|
|
@ManyToOne(() => Media, (media) => media.requests, {
|
|
eager: true,
|
|
onDelete: 'CASCADE',
|
|
})
|
|
public media: Media;
|
|
|
|
@ManyToOne(() => User, (user) => user.requests, {
|
|
eager: true,
|
|
onDelete: 'CASCADE',
|
|
})
|
|
public requestedBy: User;
|
|
|
|
@ManyToOne(() => User, {
|
|
nullable: true,
|
|
cascade: true,
|
|
eager: true,
|
|
onDelete: 'SET NULL',
|
|
})
|
|
public modifiedBy?: User;
|
|
|
|
@CreateDateColumn()
|
|
public createdAt: Date;
|
|
|
|
@UpdateDateColumn()
|
|
public updatedAt: Date;
|
|
|
|
@Column({ type: 'varchar' })
|
|
public type: MediaType;
|
|
|
|
@RelationCount((request: MediaRequest) => request.seasons)
|
|
public seasonCount: number;
|
|
|
|
@OneToMany(() => SeasonRequest, (season) => season.request, {
|
|
eager: true,
|
|
cascade: true,
|
|
})
|
|
public seasons: SeasonRequest[];
|
|
|
|
@Column({ default: false })
|
|
public is4k: boolean;
|
|
|
|
@Column({ nullable: true })
|
|
public serverId: number;
|
|
|
|
@Column({ nullable: true })
|
|
public profileId: number;
|
|
|
|
@Column({ nullable: true })
|
|
public rootFolder: string;
|
|
|
|
@Column({ nullable: true })
|
|
public languageProfileId: number;
|
|
|
|
@Column({
|
|
type: 'text',
|
|
nullable: true,
|
|
transformer: {
|
|
from: (value: string | null): number[] | null => {
|
|
if (value) {
|
|
if (value === 'none') {
|
|
return [];
|
|
}
|
|
return value.split(',').map((v) => Number(v));
|
|
}
|
|
return null;
|
|
},
|
|
to: (value: number[] | null): string | null => {
|
|
if (value) {
|
|
const finalValue = value.join(',');
|
|
|
|
// We want to keep the actual state of an "empty array" so we use
|
|
// the keyword "none" to track this.
|
|
if (!finalValue) {
|
|
return 'none';
|
|
}
|
|
|
|
return finalValue;
|
|
}
|
|
return null;
|
|
},
|
|
},
|
|
})
|
|
public tags?: number[];
|
|
|
|
@Column({ default: false })
|
|
public isAutoRequest: boolean;
|
|
|
|
constructor(init?: Partial<MediaRequest>) {
|
|
Object.assign(this, init);
|
|
}
|
|
|
|
@AfterInsert()
|
|
public async notifyNewRequest(): Promise<void> {
|
|
if (this.status === MediaRequestStatus.PENDING) {
|
|
const mediaRepository = getRepository(Media);
|
|
const media = await mediaRepository.findOne({
|
|
where: { id: this.media.id },
|
|
});
|
|
if (!media) {
|
|
logger.error('Media data not found', {
|
|
label: 'Media Request',
|
|
requestId: this.id,
|
|
mediaId: this.media.id,
|
|
});
|
|
return;
|
|
}
|
|
|
|
MediaRequest.sendNotification(this, media, Notification.MEDIA_PENDING);
|
|
|
|
if (this.isAutoRequest) {
|
|
MediaRequest.sendNotification(
|
|
this,
|
|
media,
|
|
Notification.MEDIA_AUTO_REQUESTED
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Notification for approval
|
|
*
|
|
* We only check on AfterUpdate as to not trigger this for
|
|
* auto approved content
|
|
*/
|
|
@AfterUpdate()
|
|
public async notifyApprovedOrDeclined(autoApproved = false): Promise<void> {
|
|
if (
|
|
this.status === MediaRequestStatus.APPROVED ||
|
|
this.status === MediaRequestStatus.DECLINED
|
|
) {
|
|
const mediaRepository = getRepository(Media);
|
|
const media = await mediaRepository.findOne({
|
|
where: { id: this.media.id },
|
|
});
|
|
if (!media) {
|
|
logger.error('Media data not found', {
|
|
label: 'Media Request',
|
|
requestId: this.id,
|
|
mediaId: this.media.id,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE) {
|
|
logger.warn(
|
|
'Media became available before request was approved. Skipping approval notification',
|
|
{ label: 'Media Request', requestId: this.id, mediaId: this.media.id }
|
|
);
|
|
return;
|
|
}
|
|
|
|
MediaRequest.sendNotification(
|
|
this,
|
|
media,
|
|
this.status === MediaRequestStatus.APPROVED
|
|
? autoApproved
|
|
? Notification.MEDIA_AUTO_APPROVED
|
|
: Notification.MEDIA_APPROVED
|
|
: Notification.MEDIA_DECLINED
|
|
);
|
|
|
|
if (
|
|
this.status === MediaRequestStatus.APPROVED &&
|
|
autoApproved &&
|
|
this.isAutoRequest
|
|
) {
|
|
MediaRequest.sendNotification(
|
|
this,
|
|
media,
|
|
Notification.MEDIA_AUTO_REQUESTED
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@AfterInsert()
|
|
public async autoapprovalNotification(): Promise<void> {
|
|
if (this.status === MediaRequestStatus.APPROVED) {
|
|
this.notifyApprovedOrDeclined(true);
|
|
}
|
|
}
|
|
|
|
static async sendNotification(
|
|
entity: MediaRequest,
|
|
media: Media,
|
|
type: Notification
|
|
) {
|
|
const tmdb = new TheMovieDb();
|
|
|
|
try {
|
|
const mediaType = entity.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
|
let event: string | undefined;
|
|
let notifyAdmin = true;
|
|
let notifySystem = true;
|
|
|
|
switch (type) {
|
|
case Notification.MEDIA_APPROVED:
|
|
event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Approved`;
|
|
notifyAdmin = false;
|
|
break;
|
|
case Notification.MEDIA_DECLINED:
|
|
event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Declined`;
|
|
notifyAdmin = false;
|
|
break;
|
|
case Notification.MEDIA_PENDING:
|
|
event = `New ${entity.is4k ? '4K ' : ''}${mediaType} Request`;
|
|
break;
|
|
case Notification.MEDIA_AUTO_REQUESTED:
|
|
event = `${
|
|
entity.is4k ? '4K ' : ''
|
|
}${mediaType} Request Automatically Submitted`;
|
|
notifyAdmin = false;
|
|
notifySystem = false;
|
|
break;
|
|
case Notification.MEDIA_AUTO_APPROVED:
|
|
event = `${
|
|
entity.is4k ? '4K ' : ''
|
|
}${mediaType} Request Automatically Approved`;
|
|
break;
|
|
case Notification.MEDIA_FAILED:
|
|
event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Failed`;
|
|
break;
|
|
}
|
|
|
|
if (entity.type === MediaType.MOVIE) {
|
|
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
|
|
notificationManager.sendNotification(type, {
|
|
media,
|
|
request: entity,
|
|
notifyAdmin,
|
|
notifySystem,
|
|
notifyUser: notifyAdmin ? undefined : entity.requestedBy,
|
|
event,
|
|
subject: `${movie.title}${
|
|
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
|
}`,
|
|
message: truncate(movie.overview, {
|
|
length: 500,
|
|
separator: /\s/,
|
|
omission: '…',
|
|
}),
|
|
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
|
});
|
|
} else if (entity.type === MediaType.TV) {
|
|
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
|
|
notificationManager.sendNotification(type, {
|
|
media,
|
|
request: entity,
|
|
notifyAdmin,
|
|
notifySystem,
|
|
notifyUser: notifyAdmin ? undefined : entity.requestedBy,
|
|
event,
|
|
subject: `${tv.name}${
|
|
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
|
}`,
|
|
message: truncate(tv.overview, {
|
|
length: 500,
|
|
separator: /\s/,
|
|
omission: '…',
|
|
}),
|
|
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
|
extra: [
|
|
{
|
|
name: 'Requested Seasons',
|
|
value: entity.seasons
|
|
.map((season) => season.seasonNumber)
|
|
.join(', '),
|
|
},
|
|
],
|
|
});
|
|
}
|
|
} catch (e) {
|
|
logger.error('Something went wrong sending media notification(s)', {
|
|
label: 'Notifications',
|
|
errorMessage: e.message,
|
|
requestId: entity.id,
|
|
mediaId: entity.media.id,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
export default MediaRequest;
|