Files
jellyseerr/server/entity/MediaRequest.ts
2025-10-09 19:16:53 +02:00

829 lines
24 KiB
TypeScript

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,
MediaType,
} from '@server/constants/media';
import { getRepository } from '@server/datasource';
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,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
RelationCount,
} 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 {}
export class BlacklistedMediaError 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);
const settings = getSettings();
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();
const canBypassQuota = user.hasPermission([Permission.MANAGE_REQUESTS]);
if (!canBypassQuota) {
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.BLACKLISTED) {
logger.warn('Request for media blocked due to being blacklisted', {
tmdbId: tmdbMedia.id,
mediaType: requestBody.mediaType,
label: 'Media Request',
});
throw new BlacklistedMediaError('This media is blacklisted.');
}
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.'
);
}
}
// Apply overrides if the user is not an admin or has the "advanced request" permission
const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], {
type: 'or',
});
let rootFolder = requestBody.rootFolder;
let profileId = requestBody.profileId;
let tags = requestBody.tags;
if (useOverrides) {
const defaultRadarrId = requestBody.is4k
? settings.radarr.findIndex((r) => r.is4k && r.isDefault)
: settings.radarr.findIndex((r) => !r.is4k && r.isDefault);
const defaultSonarrId = requestBody.is4k
? settings.sonarr.findIndex((s) => s.is4k && s.isDefault)
: settings.sonarr.findIndex((s) => !s.is4k && s.isDefault);
const overrideRuleRepository = getRepository(OverrideRule);
const overrideRules = await overrideRuleRepository.find({
where:
requestBody.mediaType === MediaType.MOVIE
? { radarrServiceId: defaultRadarrId }
: { sonarrServiceId: defaultSonarrId },
});
const appliedOverrideRules = overrideRules.filter((rule) => {
const hasAnimeKeyword =
'results' in tmdbMedia.keywords &&
tmdbMedia.keywords.results.some(
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
);
// Skip override rules if the media is an anime TV show as anime TV
// is handled by default and override rules do not explicitly include
// the anime keyword
if (
requestBody.mediaType === MediaType.TV &&
hasAnimeKeyword &&
(!rule.keywords ||
!rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID))
) {
return false;
}
if (
rule.users &&
!rule.users
.split(',')
.some((userId) => Number(userId) === requestUser.id)
) {
return false;
}
if (
rule.genre &&
!rule.genre
.split(',')
.some((genreId) =>
tmdbMedia.genres
.map((genre) => genre.id)
.includes(Number(genreId))
)
) {
return false;
}
if (
rule.language &&
!rule.language
.split('|')
.some((languageId) => languageId === tmdbMedia.original_language)
) {
return false;
}
if (
rule.keywords &&
!rule.keywords.split(',').some((keywordId) => {
let keywordList: TmdbKeyword[] = [];
if ('keywords' in tmdbMedia.keywords) {
keywordList = tmdbMedia.keywords.keywords;
} else if ('results' in tmdbMedia.keywords) {
keywordList = tmdbMedia.keywords.results;
}
return keywordList
.map((keyword: TmdbKeyword) => keyword.id)
.includes(Number(keywordId));
})
) {
return false;
}
return true;
});
// hacky way to prioritize rules
// TODO: make this better
const prioritizedRule = appliedOverrideRules.sort((a, b) => {
const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords'];
const aSpecificity = keys.filter((key) => a[key] !== null).length;
const bSpecificity = keys.filter((key) => b[key] !== null).length;
// Take the rule with the most specific condition first
return bSpecificity - aSpecificity;
})[0];
if (prioritizedRule) {
if (prioritizedRule.rootFolder) {
rootFolder = prioritizedRule.rootFolder;
}
if (prioritizedRule.profileId) {
profileId = prioritizedRule.profileId;
}
if (prioritizedRule.tags) {
tags = [
...new Set([
...(tags || []),
...prioritizedRule.tags.split(',').map((tag) => Number(tag)),
]),
];
}
logger.debug('Override rule applied.', {
label: 'Media Request',
overrides: prioritizedRule,
});
}
}
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: profileId,
rootFolder: rootFolder,
tags: tags,
isAutoRequest: options.isAutoRequest ?? false,
ignoreQuota: requestBody.ignoreQuota ?? false,
});
await requestRepository.save(request);
return request;
} else {
const tmdbMediaShow = tmdbMedia as Awaited<
ReturnType<typeof tmdb.getTvShow>
>;
let requestedSeasons =
requestBody.seasons === 'all'
? tmdbMediaShow.seasons
.filter((season) => season.season_number !== 0)
.map((season) => season.season_number)
: (requestBody.seasons as number[]);
if (!settings.main.enableSpecialEpisodes) {
requestedSeasons = requestedSeasons.filter((sn) => sn > 0);
}
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 (
!canBypassQuota &&
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: profileId,
rootFolder: rootFolder,
languageProfileId: requestBody.languageProfileId,
tags: 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,
ignoreQuota: requestBody.ignoreQuota ?? 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;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
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;
@Column({ default: false })
public ignoreQuota: 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);
}
}
@AfterLoad()
private sortSeasons() {
if (Array.isArray(this.seasons)) {
this.seasons.sort((a, b) => a.id - b.id);
}
}
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;