Skip to content

Commit

Permalink
feat: simple failed request handling (#474)
Browse files Browse the repository at this point in the history
When a movie or series is added with radarr or sonarr, if it fails, this changes the media state to
unknown and sends a notification to admins. Client side this will look like a failed state along
with a retry button that will delete the request and re-queue it.
  • Loading branch information
johnpyp committed Dec 25, 2020
1 parent ed94a0f commit 02969d5
Show file tree
Hide file tree
Showing 12 changed files with 296 additions and 47 deletions.
24 changes: 24 additions & 0 deletions overseerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2200,6 +2200,30 @@ paths:
responses:
'204':
description: Succesfully removed request
/request/{requestId}/retry:
post:
summary: Retry a failed request
description: |
Retries a request by resending requests to Sonarr or Radarr
Requires the `MANAGE_REQUESTS` permission or `ADMIN`
tags:
- request
parameters:
- in: path
name: requestId
description: Request ID
required: true
schema:
type: string
example: 1
responses:
'200':
description: Retry triggered
content:
application/json:
schema:
$ref: '#/components/schemas/MediaRequest'
/request/{requestId}/{status}:
get:
summary: Update a requests status
Expand Down
9 changes: 8 additions & 1 deletion server/api/radarr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class RadarrAPI {
}
};

public addMovie = async (options: RadarrMovieOptions): Promise<void> => {
public addMovie = async (options: RadarrMovieOptions): Promise<boolean> => {
try {
const response = await this.axios.post<RadarrMovie>(`/movie`, {
title: options.title,
Expand Down Expand Up @@ -104,16 +104,23 @@ class RadarrAPI {
label: 'Radarr',
options,
});
return false;
}
return true;
} catch (e) {
logger.error(
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
{
label: 'Radarr',
errorMessage: e.message,
options,
response: e?.response?.data,
}
);
if (e?.response?.data?.[0]?.errorCode === 'MovieExistsValidator') {
return true;
}
return false;
}
};

Expand Down
11 changes: 7 additions & 4 deletions server/api/sonarr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ class SonarrAPI {
}
}

public async addSeries(options: AddSeriesOptions): Promise<SonarrSeries> {
public async addSeries(options: AddSeriesOptions): Promise<boolean> {
try {
const series = await this.getSeriesByTvdbId(options.tvdbid);

Expand Down Expand Up @@ -147,9 +147,10 @@ class SonarrAPI {
label: 'Sonarr',
options,
});
return false;
}

return newSeriesResponse.data;
return true;
}

const createdSeriesResponse = await this.axios.post<SonarrSeries>(
Expand Down Expand Up @@ -188,16 +189,18 @@ class SonarrAPI {
label: 'Sonarr',
options,
});
return false;
}

return createdSeriesResponse.data;
return true;
} catch (e) {
logger.error('Something went wrong adding a series to Sonarr', {
label: 'Sonarr API',
errorMessage: e.message,
error: e,
response: e?.response?.data,
});
throw new Error('Failed to add series');
return false;
}
}

Expand Down
133 changes: 100 additions & 33 deletions server/entity/MediaRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ export class 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) {
Expand Down Expand Up @@ -163,7 +169,7 @@ export class MediaRequest {

@AfterUpdate()
@AfterInsert()
private async _updateParentStatus() {
public async updateParentStatus(): Promise<void> {
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
where: { id: this.media.id },
Expand Down Expand Up @@ -229,14 +235,13 @@ export class MediaRequest {
}
}

@AfterUpdate()
@AfterInsert()
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(
Expand Down Expand Up @@ -268,17 +273,49 @@ export class MediaRequest {
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,
});
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(
Expand All @@ -288,8 +325,6 @@ export class MediaRequest {
}
}

@AfterUpdate()
@AfterInsert()
private async _sendToSonarr() {
if (
this.status === MediaRequestStatus.APPROVED &&
Expand Down Expand Up @@ -352,23 +387,55 @@ export class MediaRequest {
}

// 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,
});
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(
Expand Down
9 changes: 9 additions & 0 deletions server/lib/notifications/agents/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,15 @@ class DiscordAgent
}
);

if (settings.main.applicationUrl) {
fields.push({
name: 'View Media',
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
});
}
break;
case Notification.MEDIA_FAILED:
color = EmbedColors.RED;
if (settings.main.applicationUrl) {
fields.push({
name: 'View Media',
Expand Down
49 changes: 49 additions & 0 deletions server/lib/notifications/agents/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,52 @@ class EmailAgent
}
}

private async sendMediaFailedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
try {
const userRepository = getRepository(User);
const users = await userRepository.find();

// Send to all users with the manage requests permission (or admins)
users
.filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
.forEach((user) => {
const email = this.getNewEmail();

email.send({
template: path.join(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: user.email,
},
locals: {
body:
"A user's new request has failed to add to Sonarr or Radarr",
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
requestType: 'Failed Request',
},
});
});
return true;
} catch (e) {
logger.error('Mail notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
}
}

private async sendMediaApprovedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
Expand Down Expand Up @@ -228,6 +274,9 @@ class EmailAgent
case Notification.MEDIA_AVAILABLE:
this.sendMediaAvailableEmail(payload);
break;
case Notification.MEDIA_FAILED:
this.sendMediaFailedEmail(payload);
break;
case Notification.TEST_NOTIFICATION:
this.sendTestEmail(payload);
break;
Expand Down
3 changes: 2 additions & 1 deletion server/lib/notifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ export enum Notification {
MEDIA_PENDING = 2,
MEDIA_APPROVED = 4,
MEDIA_AVAILABLE = 8,
TEST_NOTIFICATION = 16,
MEDIA_FAILED = 16,
TEST_NOTIFICATION = 32,
}

class NotificationManager {
Expand Down

0 comments on commit 02969d5

Please sign in to comment.