feat: simple failed request handling (#474)

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.
pull/503/head
johnpyp 4 years ago committed by GitHub
parent ed94a0f335
commit 02969d5426
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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

@ -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,
@ -104,7 +104,9 @@ 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.',
@ -112,8 +114,13 @@ class RadarrAPI {
label: 'Radarr',
errorMessage: e.message,
options,
response: e?.response?.data,
}
);
if (e?.response?.data?.[0]?.errorCode === 'MovieExistsValidator') {
return true;
}
return false;
}
};

@ -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);
@ -147,9 +147,10 @@ class SonarrAPI {
label: 'Sonarr',
options,
});
return false;
}
return newSeriesResponse.data;
return true;
}
const createdSeriesResponse = await this.axios.post<SonarrSeries>(
@ -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;
}
}

@ -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) {
@ -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 },
@ -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(
@ -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(
@ -288,8 +325,6 @@ export class MediaRequest {
}
}
@AfterUpdate()
@AfterInsert()
private async _sendToSonarr() {
if (
this.status === MediaRequestStatus.APPROVED &&
@ -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(

@ -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',

@ -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;
@ -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;

@ -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 {

@ -244,6 +244,32 @@ requestRoutes.delete('/:requestId', async (req, res, next) => {
}
});
requestRoutes.post<{
requestId: string;
}>(
'/:requestId/retry',
isAuthenticated(Permission.MANAGE_REQUESTS),
async (req, res, next) => {
const requestRepository = getRepository(MediaRequest);
try {
const request = await requestRepository.findOneOrFail({
where: { id: Number(req.params.requestId) },
relations: ['requestedBy', 'modifiedBy'],
});
await request.updateParentStatus();
await request.sendMedia();
return res.status(200).json(request);
} catch (e) {
logger.error('Error processing request retry', {
label: 'Media Request',
message: e.message,
});
next({ status: 404, message: 'Request not found' });
}
}
);
requestRoutes.get<{
requestId: string;
status: 'pending' | 'approve' | 'decline';

@ -1,4 +1,4 @@
import React, { useContext } from 'react';
import React, { useContext, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import type { MediaRequest } from '../../../../server/entity/MediaRequest';
import {
@ -15,16 +15,21 @@ import useSWR from 'swr';
import Badge from '../../Common/Badge';
import StatusBadge from '../../StatusBadge';
import Table from '../../Common/Table';
import { MediaRequestStatus } from '../../../../server/constants/media';
import {
MediaRequestStatus,
MediaStatus,
} from '../../../../server/constants/media';
import Button from '../../Common/Button';
import axios from 'axios';
import globalMessages from '../../../i18n/globalMessages';
import Link from 'next/link';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages({
requestedby: 'Requested by {username}',
seasons: 'Seasons',
notavailable: 'N/A',
failedretry: 'Something went wrong retrying the request',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
@ -33,13 +38,17 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
interface RequestItemProps {
request: MediaRequest;
onDelete: () => void;
revalidateList: () => void;
}
const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => {
const RequestItem: React.FC<RequestItemProps> = ({
request,
revalidateList,
}) => {
const { ref, inView } = useInView({
triggerOnce: true,
});
const { addToast } = useToasts();
const intl = useIntl();
const { hasPermission } = useUser();
const { locale } = useContext(LanguageContext);
@ -50,13 +59,15 @@ const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => {
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}?language=${locale}` : null
);
const { data: requestData, revalidate } = useSWR<MediaRequest>(
const { data: requestData, revalidate, mutate } = useSWR<MediaRequest>(
`/api/v1/request/${request.id}`,
{
initialData: request,
}
);
const [isRetrying, setRetrying] = useState(false);
const modifyRequest = async (type: 'approve' | 'decline') => {
const response = await axios.get(`/api/v1/request/${request.id}/${type}`);
@ -68,7 +79,23 @@ const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => {
const deleteRequest = async () => {
await axios.delete(`/api/v1/request/${request.id}`);
onDelete();
revalidateList();
};
const retryRequest = async () => {
setRetrying(true);
try {
const result = await axios.post(`/api/v1/request/${request.id}/retry`);
mutate(result.data);
} catch (e) {
addToast(intl.formatMessage(messages.failedretry), {
autoDismiss: true,
appearance: 'error',
});
} finally {
setRetrying(false);
}
};
if (!title && !error) {
@ -138,7 +165,13 @@ const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => {
)}
</Table.TD>
<Table.TD>
<StatusBadge status={requestData.media.status} />
{requestData.media.status === MediaStatus.UNKNOWN ? (
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.failed)}
</Badge>
) : (
<StatusBadge status={requestData.media.status} />
)}
</Table.TD>
<Table.TD>
<div className="flex flex-col">
@ -167,6 +200,31 @@ const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => {
</div>
</Table.TD>
<Table.TD alignText="right">
{requestData.media.status === MediaStatus.UNKNOWN &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
className="mr-2"
buttonType="primary"
buttonSize="sm"
disabled={isRetrying}
onClick={() => retryRequest()}
>
<svg
className="w-4 h-4 mr-0 sm:mr-1"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="18px"
height="18px"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z" />
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.retry)}
</span>
</Button>
)}
{requestData.status !== MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<Button

@ -56,7 +56,7 @@ const RequestList: React.FC = () => {
<RequestItem
request={request}
key={`request-list-${request.id}`}
onDelete={() => revalidate()}
revalidateList={() => revalidate()}
/>
);
})}

@ -6,6 +6,7 @@ const globalMessages = defineMessages({
processing: 'Processing',
unavailable: 'Unavailable',
requested: 'Requested',
failed: 'Failed',
pending: 'Pending',
declined: 'Declined',
approved: 'Approved',
@ -15,6 +16,7 @@ const globalMessages = defineMessages({
approve: 'Approve',
decline: 'Decline',
delete: 'Delete',
retry: 'Retry',
deleting: 'Deleting…',
close: 'Close',
});

@ -71,6 +71,7 @@
"components.RequestCard.all": "All",
"components.RequestCard.requestedby": "Requested by {username}",
"components.RequestCard.seasons": "Seasons",
"components.RequestList.RequestItem.failedretry": "Something went wrong retrying the request",
"components.RequestList.RequestItem.notavailable": "N/A",
"components.RequestList.RequestItem.requestedby": "Requested by {username}",
"components.RequestList.RequestItem.seasons": "Seasons",
@ -376,11 +377,13 @@
"i18n.declined": "Declined",
"i18n.delete": "Delete",
"i18n.deleting": "Deleting…",
"i18n.failed": "Failed",
"i18n.movies": "Movies",
"i18n.partiallyavailable": "Partially Available",
"i18n.pending": "Pending",
"i18n.processing": "Processing…",
"i18n.requested": "Requested",
"i18n.retry": "Retry",
"i18n.tvshows": "Series",
"i18n.unavailable": "Unavailable",
"pages.internalServerError": "{statusCode} - Internal Server Error",

Loading…
Cancel
Save