mirror of
https://github.com/sct/overseerr.git
synced 2025-12-26 16:27:17 +01:00
* feat: add support for requesting "Specials" for TV Shows This commit is responsible for adding support in Overseerr for requesting "Special" episodes for TV Shows. This request has become especially pertinent when you consider shows like "Doctor Who". These shows have Specials that are critical to understanding the plot of a TV show. fix #779 * chore(yarn.lock): undo inappropriate changes to yarn.lock I was informed by @sct in a comment on the #3724 PR that it was not appropriate to commit the changes that ended up being made to the yarn.lock file. This commit is responsible, then, for undoing the changes to the yarn.lock file that ended up being submitted. * refactor: change loose equality to strict equality I received a comment from OwsleyJr pointing out that we are using loose equality when we could alternatively just be using strict equality to increase the robustness of our code. This commit does exactly that by squashing out previous usages of loose equality in my commits and replacing them with strict equality * refactor: move 'Specials' string to a global message Owsley pointed out that we are redefining the 'Specials' string multiple times throughout this PR. Instead, we can just move it as a global message. This commit does exactly that. It squashes out and previous declarations of the 'Specials' string inside the src files, and moves it directly to the global messages file.
811 lines
24 KiB
TypeScript
811 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();
|
|
|
|
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,
|
|
});
|
|
|
|
await requestRepository.save(request);
|
|
return request;
|
|
} else {
|
|
const tmdbMediaShow = tmdbMedia as Awaited<
|
|
ReturnType<typeof tmdb.getTvShow>
|
|
>;
|
|
let requestedSeasons =
|
|
requestBody.seasons === 'all'
|
|
? tmdbMediaShow.seasons.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 (
|
|
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,
|
|
});
|
|
|
|
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;
|
|
|
|
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;
|