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.
This commit is contained in:
johnpyp
2020-12-24 19:53:32 -05:00
committed by GitHub
parent ed94a0f335
commit 02969d5426
12 changed files with 296 additions and 47 deletions

View File

@@ -2200,6 +2200,30 @@ paths:
responses: responses:
'204': '204':
description: Succesfully removed request 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}: /request/{requestId}/{status}:
get: get:
summary: Update a requests status summary: Update a requests status

View File

@@ -76,7 +76,7 @@ class RadarrAPI {
} }
}; };
public addMovie = async (options: RadarrMovieOptions): Promise<void> => { public addMovie = async (options: RadarrMovieOptions): Promise<boolean> => {
try { try {
const response = await this.axios.post<RadarrMovie>(`/movie`, { const response = await this.axios.post<RadarrMovie>(`/movie`, {
title: options.title, title: options.title,
@@ -104,7 +104,9 @@ class RadarrAPI {
label: 'Radarr', label: 'Radarr',
options, options,
}); });
return false;
} }
return true;
} catch (e) { } catch (e) {
logger.error( 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.', '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', label: 'Radarr',
errorMessage: e.message, errorMessage: e.message,
options, options,
response: e?.response?.data,
} }
); );
if (e?.response?.data?.[0]?.errorCode === 'MovieExistsValidator') {
return true;
}
return false;
} }
}; };

View File

@@ -116,7 +116,7 @@ class SonarrAPI {
} }
} }
public async addSeries(options: AddSeriesOptions): Promise<SonarrSeries> { public async addSeries(options: AddSeriesOptions): Promise<boolean> {
try { try {
const series = await this.getSeriesByTvdbId(options.tvdbid); const series = await this.getSeriesByTvdbId(options.tvdbid);
@@ -147,9 +147,10 @@ class SonarrAPI {
label: 'Sonarr', label: 'Sonarr',
options, options,
}); });
return false;
} }
return newSeriesResponse.data; return true;
} }
const createdSeriesResponse = await this.axios.post<SonarrSeries>( const createdSeriesResponse = await this.axios.post<SonarrSeries>(
@@ -188,16 +189,18 @@ class SonarrAPI {
label: 'Sonarr', label: 'Sonarr',
options, options,
}); });
return false;
} }
return createdSeriesResponse.data; return true;
} catch (e) { } catch (e) {
logger.error('Something went wrong adding a series to Sonarr', { logger.error('Something went wrong adding a series to Sonarr', {
label: 'Sonarr API', label: 'Sonarr API',
errorMessage: e.message, errorMessage: e.message,
error: e, error: e,
response: e?.response?.data,
}); });
throw new Error('Failed to add series'); return false;
} }
} }

View File

@@ -69,6 +69,12 @@ export class MediaRequest {
Object.assign(this, init); Object.assign(this, init);
} }
@AfterUpdate()
@AfterInsert()
public async sendMedia(): Promise<void> {
await Promise.all([this._sendToRadarr(), this._sendToSonarr()]);
}
@AfterInsert() @AfterInsert()
private async _notifyNewRequest() { private async _notifyNewRequest() {
if (this.status === MediaRequestStatus.PENDING) { if (this.status === MediaRequestStatus.PENDING) {
@@ -163,7 +169,7 @@ export class MediaRequest {
@AfterUpdate() @AfterUpdate()
@AfterInsert() @AfterInsert()
private async _updateParentStatus() { public async updateParentStatus(): Promise<void> {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({ const media = await mediaRepository.findOne({
where: { id: this.media.id }, where: { id: this.media.id },
@@ -229,14 +235,13 @@ export class MediaRequest {
} }
} }
@AfterUpdate()
@AfterInsert()
private async _sendToRadarr() { private async _sendToRadarr() {
if ( if (
this.status === MediaRequestStatus.APPROVED && this.status === MediaRequestStatus.APPROVED &&
this.type === MediaType.MOVIE this.type === MediaType.MOVIE
) { ) {
try { try {
const mediaRepository = getRepository(Media);
const settings = getSettings(); const settings = getSettings();
if (settings.radarr.length === 0 && !settings.radarr[0]) { if (settings.radarr.length === 0 && !settings.radarr[0]) {
logger.info( logger.info(
@@ -268,7 +273,8 @@ export class MediaRequest {
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
// Run this asynchronously so we don't wait for it on the UI side // Run this asynchronously so we don't wait for it on the UI side
radarr.addMovie({ radarr
.addMovie({
profileId: radarrSettings.activeProfileId, profileId: radarrSettings.activeProfileId,
qualityProfileId: radarrSettings.activeProfileId, qualityProfileId: radarrSettings.activeProfileId,
rootFolderPath: radarrSettings.activeDirectory, rootFolderPath: radarrSettings.activeDirectory,
@@ -278,6 +284,37 @@ export class MediaRequest {
year: Number(movie.release_date.slice(0, 4)), year: Number(movie.release_date.slice(0, 4)),
monitored: true, monitored: true,
searchNow: 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' }); logger.info('Sent request to Radarr', { label: 'Media Request' });
} catch (e) { } catch (e) {
@@ -288,8 +325,6 @@ export class MediaRequest {
} }
} }
@AfterUpdate()
@AfterInsert()
private async _sendToSonarr() { private async _sendToSonarr() {
if ( if (
this.status === MediaRequestStatus.APPROVED && this.status === MediaRequestStatus.APPROVED &&
@@ -352,7 +387,8 @@ export class MediaRequest {
} }
// Run this asynchronously so we don't wait for it on the UI side // Run this asynchronously so we don't wait for it on the UI side
sonarr.addSeries({ sonarr
.addSeries({
profileId: profileId:
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
? sonarrSettings.activeAnimeProfileId ? sonarrSettings.activeAnimeProfileId
@@ -368,6 +404,37 @@ export class MediaRequest {
seriesType, seriesType,
monitored: true, monitored: true,
searchNow: 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' }); logger.info('Sent request to Sonarr', { label: 'Media Request' });
} catch (e) { } catch (e) {

View File

@@ -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) { if (settings.main.applicationUrl) {
fields.push({ fields.push({
name: 'View Media', name: 'View Media',

View File

@@ -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) { private async sendMediaApprovedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app // This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl; const applicationUrl = getSettings().main.applicationUrl;
@@ -228,6 +274,9 @@ class EmailAgent
case Notification.MEDIA_AVAILABLE: case Notification.MEDIA_AVAILABLE:
this.sendMediaAvailableEmail(payload); this.sendMediaAvailableEmail(payload);
break; break;
case Notification.MEDIA_FAILED:
this.sendMediaFailedEmail(payload);
break;
case Notification.TEST_NOTIFICATION: case Notification.TEST_NOTIFICATION:
this.sendTestEmail(payload); this.sendTestEmail(payload);
break; break;

View File

@@ -5,7 +5,8 @@ export enum Notification {
MEDIA_PENDING = 2, MEDIA_PENDING = 2,
MEDIA_APPROVED = 4, MEDIA_APPROVED = 4,
MEDIA_AVAILABLE = 8, MEDIA_AVAILABLE = 8,
TEST_NOTIFICATION = 16, MEDIA_FAILED = 16,
TEST_NOTIFICATION = 32,
} }
class NotificationManager { class NotificationManager {

View File

@@ -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<{ requestRoutes.get<{
requestId: string; requestId: string;
status: 'pending' | 'approve' | 'decline'; status: 'pending' | 'approve' | 'decline';

View File

@@ -1,4 +1,4 @@
import React, { useContext } from 'react'; import React, { useContext, useState } from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import type { MediaRequest } from '../../../../server/entity/MediaRequest'; import type { MediaRequest } from '../../../../server/entity/MediaRequest';
import { import {
@@ -15,16 +15,21 @@ import useSWR from 'swr';
import Badge from '../../Common/Badge'; import Badge from '../../Common/Badge';
import StatusBadge from '../../StatusBadge'; import StatusBadge from '../../StatusBadge';
import Table from '../../Common/Table'; import Table from '../../Common/Table';
import { MediaRequestStatus } from '../../../../server/constants/media'; import {
MediaRequestStatus,
MediaStatus,
} from '../../../../server/constants/media';
import Button from '../../Common/Button'; import Button from '../../Common/Button';
import axios from 'axios'; import axios from 'axios';
import globalMessages from '../../../i18n/globalMessages'; import globalMessages from '../../../i18n/globalMessages';
import Link from 'next/link'; import Link from 'next/link';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages({ const messages = defineMessages({
requestedby: 'Requested by {username}', requestedby: 'Requested by {username}',
seasons: 'Seasons', seasons: 'Seasons',
notavailable: 'N/A', notavailable: 'N/A',
failedretry: 'Something went wrong retrying the request',
}); });
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
@@ -33,13 +38,17 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
interface RequestItemProps { interface RequestItemProps {
request: MediaRequest; request: MediaRequest;
onDelete: () => void; revalidateList: () => void;
} }
const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => { const RequestItem: React.FC<RequestItemProps> = ({
request,
revalidateList,
}) => {
const { ref, inView } = useInView({ const { ref, inView } = useInView({
triggerOnce: true, triggerOnce: true,
}); });
const { addToast } = useToasts();
const intl = useIntl(); const intl = useIntl();
const { hasPermission } = useUser(); const { hasPermission } = useUser();
const { locale } = useContext(LanguageContext); const { locale } = useContext(LanguageContext);
@@ -50,13 +59,15 @@ const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => {
const { data: title, error } = useSWR<MovieDetails | TvDetails>( const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}?language=${locale}` : null inView ? `${url}?language=${locale}` : null
); );
const { data: requestData, revalidate } = useSWR<MediaRequest>( const { data: requestData, revalidate, mutate } = useSWR<MediaRequest>(
`/api/v1/request/${request.id}`, `/api/v1/request/${request.id}`,
{ {
initialData: request, initialData: request,
} }
); );
const [isRetrying, setRetrying] = useState(false);
const modifyRequest = async (type: 'approve' | 'decline') => { const modifyRequest = async (type: 'approve' | 'decline') => {
const response = await axios.get(`/api/v1/request/${request.id}/${type}`); 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 () => { const deleteRequest = async () => {
await axios.delete(`/api/v1/request/${request.id}`); 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) { if (!title && !error) {
@@ -138,7 +165,13 @@ const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => {
)} )}
</Table.TD> </Table.TD>
<Table.TD> <Table.TD>
{requestData.media.status === MediaStatus.UNKNOWN ? (
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.failed)}
</Badge>
) : (
<StatusBadge status={requestData.media.status} /> <StatusBadge status={requestData.media.status} />
)}
</Table.TD> </Table.TD>
<Table.TD> <Table.TD>
<div className="flex flex-col"> <div className="flex flex-col">
@@ -167,6 +200,31 @@ const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => {
</div> </div>
</Table.TD> </Table.TD>
<Table.TD alignText="right"> <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 && {requestData.status !== MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && ( hasPermission(Permission.MANAGE_REQUESTS) && (
<Button <Button

View File

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

View File

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

View File

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