From 02969d5426245062a2f53475d83c4a8639632c9d Mon Sep 17 00:00:00 2001 From: johnpyp Date: Thu, 24 Dec 2020 19:53:32 -0500 Subject: [PATCH] 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. --- overseerr-api.yml | 24 ++++ server/api/radarr.ts | 9 +- server/api/sonarr.ts | 11 +- server/entity/MediaRequest.ts | 133 +++++++++++++----- server/lib/notifications/agents/discord.ts | 9 ++ server/lib/notifications/agents/email.ts | 49 +++++++ server/lib/notifications/index.ts | 3 +- server/routes/request.ts | 26 ++++ .../RequestList/RequestItem/index.tsx | 72 +++++++++- src/components/RequestList/index.tsx | 2 +- src/i18n/globalMessages.ts | 2 + src/i18n/locale/en.json | 3 + 12 files changed, 296 insertions(+), 47 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index 47e175ec9..3e4b2adb3 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -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 diff --git a/server/api/radarr.ts b/server/api/radarr.ts index 968cb21da..f3aaedc4d 100644 --- a/server/api/radarr.ts +++ b/server/api/radarr.ts @@ -76,7 +76,7 @@ class RadarrAPI { } }; - public addMovie = async (options: RadarrMovieOptions): Promise => { + public addMovie = async (options: RadarrMovieOptions): Promise => { try { const response = await this.axios.post(`/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; } }; diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts index a49378765..e3cd23534 100644 --- a/server/api/sonarr.ts +++ b/server/api/sonarr.ts @@ -116,7 +116,7 @@ class SonarrAPI { } } - public async addSeries(options: AddSeriesOptions): Promise { + public async addSeries(options: AddSeriesOptions): Promise { 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( @@ -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; } } diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 09a806804..ebf0b1c94 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -69,6 +69,12 @@ export class MediaRequest { Object.assign(this, init); } + @AfterUpdate() + @AfterInsert() + public async sendMedia(): Promise { + 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 { 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( diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index a0df2c4c3..954469453 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -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', diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 90755e929..18cd3e594 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -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; diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index c826bfeb5..0c711abe4 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -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 { diff --git a/server/routes/request.ts b/server/routes/request.ts index b5602572b..2e5f6ae17 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -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'; diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index d2ea4a73a..f42741de4 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -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 = ({ request, onDelete }) => { +const RequestItem: React.FC = ({ + 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 = ({ request, onDelete }) => { const { data: title, error } = useSWR( inView ? `${url}?language=${locale}` : null ); - const { data: requestData, revalidate } = useSWR( + const { data: requestData, revalidate, mutate } = useSWR( `/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 = ({ 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 = ({ request, onDelete }) => { )} - + {requestData.media.status === MediaStatus.UNKNOWN ? ( + + {intl.formatMessage(globalMessages.failed)} + + ) : ( + + )}
@@ -167,6 +200,31 @@ const RequestItem: React.FC = ({ request, onDelete }) => {
+ {requestData.media.status === MediaStatus.UNKNOWN && + hasPermission(Permission.MANAGE_REQUESTS) && ( + + )} {requestData.status !== MediaRequestStatus.PENDING && hasPermission(Permission.MANAGE_REQUESTS) && (