You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
overseerr/server/entity/MediaRequest.ts

448 lines
14 KiB

import {
Entity,
PrimaryGeneratedColumn,
ManyToOne,
Column,
CreateDateColumn,
UpdateDateColumn,
AfterUpdate,
AfterInsert,
getRepository,
OneToMany,
AfterRemove,
} from 'typeorm';
import { User } from './User';
import Media from './Media';
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
import { getSettings } from '../lib/settings';
import TheMovieDb, { ANIME_KEYWORD_ID } from '../api/themoviedb';
import RadarrAPI from '../api/radarr';
import logger from '../logger';
import SeasonRequest from './SeasonRequest';
import SonarrAPI, { SonarrSeries } from '../api/sonarr';
import notificationManager, { Notification } from '../lib/notifications';
@Entity()
export class MediaRequest {
@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;
@OneToMany(() => SeasonRequest, (season) => season.request, {
eager: true,
cascade: true,
})
public seasons: SeasonRequest[];
constructor(init?: Partial<MediaRequest>) {
Object.assign(this, init);
}
@AfterUpdate()
@AfterInsert()
public async sendMedia(): Promise<void> {
await Promise.all([this._sendToRadarr(), this._sendToSonarr()]);
}
@AfterInsert()
private async _notifyNewRequest() {
if (this.status === MediaRequestStatus.PENDING) {
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
where: { id: this.media.id },
});
if (!media) {
logger.error('No parent media!', { label: 'Media Request' });
return;
}
const tmdb = new TheMovieDb();
if (this.type === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
subject: movie.title,
message: movie.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
notifyUser: this.requestedBy,
media,
});
}
if (this.type === MediaType.TV) {
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
subject: tv.name,
message: tv.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
notifyUser: this.requestedBy,
media,
extra: [
{
name: 'Seasons',
value: this.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
});
}
}
}
/**
* Notification for approval
*
* We only check on AfterUpdate as to not trigger this for
* auto approved content
*/
@AfterUpdate()
private async _notifyApproved() {
if (this.status === MediaRequestStatus.APPROVED) {
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
where: { id: this.media.id },
});
if (!media) {
logger.error('No parent media!', { label: 'Media Request' });
return;
}
const tmdb = new TheMovieDb();
if (this.media.mediaType === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_APPROVED, {
subject: movie.title,
message: movie.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
notifyUser: this.requestedBy,
media,
});
} else if (this.media.mediaType === MediaType.TV) {
const tv = await tmdb.getTvShow({ tvId: this.media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_APPROVED, {
subject: tv.name,
message: tv.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
notifyUser: this.requestedBy,
media,
extra: [
{
name: 'Seasons',
value: this.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
});
}
}
}
@AfterUpdate()
@AfterInsert()
public async updateParentStatus(): Promise<void> {
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
where: { id: this.media.id },
relations: ['requests'],
});
if (!media) {
logger.error('No parent media!', { label: 'Media Request' });
return;
}
const seasonRequestRepository = getRepository(SeasonRequest);
if (this.status === MediaRequestStatus.APPROVED) {
media.status = MediaStatus.PROCESSING;
mediaRepository.save(media);
}
if (
this.media.mediaType === MediaType.MOVIE &&
this.status === MediaRequestStatus.DECLINED
) {
media.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.status === MediaStatus.PENDING
) {
media.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);
});
}
}
@AfterRemove()
private async _handleRemoveParentUpdate() {
const mediaRepository = getRepository(Media);
const fullMedia = await mediaRepository.findOneOrFail({
where: { id: this.media.id },
});
if (!fullMedia.requests || fullMedia.requests.length === 0) {
fullMedia.status = MediaStatus.UNKNOWN;
mediaRepository.save(fullMedia);
}
}
private async _sendToRadarr() {
if (
this.status === MediaRequestStatus.APPROVED &&
this.type === MediaType.MOVIE
) {
try {
const mediaRepository = getRepository(Media);
const settings = getSettings();
if (settings.radarr.length === 0 && !settings.radarr[0]) {
logger.info(
'Skipped radarr request as there is no radarr configured',
{ label: 'Media Request' }
);
return;
}
const radarrSettings = settings.radarr.find(
(radarr) => radarr.isDefault && !radarr.is4k
);
if (!radarrSettings) {
logger.info(
'There is no default radarr configured. Did you set any of your Radarr servers as default?',
{ label: 'Media Request' }
);
return;
}
const tmdb = new TheMovieDb();
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
url: `${radarrSettings.useSsl ? 'https' : 'http'}://${
radarrSettings.hostname
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`,
});
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
// Run this asynchronously so we don't wait for it on the UI side
radarr
.addMovie({
profileId: radarrSettings.activeProfileId,
qualityProfileId: radarrSettings.activeProfileId,
rootFolderPath: radarrSettings.activeDirectory,
minimumAvailability: radarrSettings.minimumAvailability,
title: movie.title,
tmdbId: movie.id,
year: Number(movie.release_date.slice(0, 4)),
monitored: true,
searchNow: true,
})
.then(async (success) => {
if (!success) {
const media = await mediaRepository.findOne({
where: { id: this.media.id },
});
if (!media) {
logger.error('Media not present');
return;
}
media.status = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
'Newly added movie request failed to add to Radarr, marking as unknown',
{
label: 'Media Request',
}
);
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
subject: movie.title,
message: 'Movie failed to add to Radarr',
notifyUser: admin,
media,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
});
}
});
logger.info('Sent request to Radarr', { label: 'Media Request' });
} catch (e) {
throw new Error(
`[MediaRequest] Request failed to send to radarr: ${e.message}`
);
}
}
}
private async _sendToSonarr() {
if (
this.status === MediaRequestStatus.APPROVED &&
this.type === MediaType.TV
) {
try {
const mediaRepository = getRepository(Media);
const settings = getSettings();
if (settings.sonarr.length === 0 && !settings.sonarr[0]) {
logger.info(
'Skipped sonarr request as there is no sonarr configured',
{ label: 'Media Request' }
);
return;
}
const sonarrSettings = settings.sonarr.find(
(sonarr) => sonarr.isDefault && !sonarr.is4k
);
if (!sonarrSettings) {
logger.info(
'There is no default sonarr configured. Did you set any of your Sonarr servers as default?',
{ label: 'Media Request' }
);
return;
}
const media = await mediaRepository.findOne({
where: { id: this.media.id },
relations: ['requests'],
});
if (!media) {
throw new Error('Media data is missing');
}
const tmdb = new TheMovieDb();
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
sonarrSettings.hostname
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
});
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
if (!series.external_ids.tvdb_id) {
throw new Error('Series was missing tvdb id');
}
let seriesType: SonarrSeries['seriesType'] = 'standard';
// Change series type to anime if the anime keyword is present on tmdb
if (
series.keywords.results.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID
)
) {
seriesType = 'anime';
}
// Run this asynchronously so we don't wait for it on the UI side
sonarr
.addSeries({
profileId:
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
? sonarrSettings.activeAnimeProfileId
: sonarrSettings.activeProfileId,
rootFolderPath:
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
? sonarrSettings.activeAnimeDirectory
: sonarrSettings.activeDirectory,
title: series.name,
tvdbid: series.external_ids.tvdb_id,
seasons: this.seasons.map((season) => season.seasonNumber),
seasonFolder: sonarrSettings.enableSeasonFolders,
seriesType,
monitored: true,
searchNow: true,
})
.then(async (success) => {
if (!success) {
media.status = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
'Newly added series request failed to add to Sonarr, marking as unknown',
{
label: 'Media Request',
}
);
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
order: { id: 'ASC' },
});
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
subject: series.name,
message: 'Series failed to add to Sonarr',
notifyUser: admin,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`,
media,
extra: [
{
name: 'Seasons',
value: this.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
});
}
});
logger.info('Sent request to Sonarr', { label: 'Media Request' });
} catch (e) {
throw new Error(
`[MediaRequest] Request failed to send to sonarr: ${e.message}`
);
}
}
}
}