mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
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:
@@ -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,7 +273,8 @@ 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({
|
||||
radarr
|
||||
.addMovie({
|
||||
profileId: radarrSettings.activeProfileId,
|
||||
qualityProfileId: radarrSettings.activeProfileId,
|
||||
rootFolderPath: radarrSettings.activeDirectory,
|
||||
@@ -278,6 +284,37 @@ export class MediaRequest {
|
||||
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) {
|
||||
@@ -288,8 +325,6 @@ export class MediaRequest {
|
||||
}
|
||||
}
|
||||
|
||||
@AfterUpdate()
|
||||
@AfterInsert()
|
||||
private async _sendToSonarr() {
|
||||
if (
|
||||
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
|
||||
sonarr.addSeries({
|
||||
sonarr
|
||||
.addSeries({
|
||||
profileId:
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
|
||||
? sonarrSettings.activeAnimeProfileId
|
||||
@@ -368,6 +404,37 @@ export class MediaRequest {
|
||||
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) {
|
||||
|
@@ -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>
|
||||
{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",
|
||||
|
Reference in New Issue
Block a user