From 75c3a755341589f2b4ca684624f08c72e90579c7 Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:19:05 +0800 Subject: [PATCH] feat(requests): show why a request failed A failed request looked the same whatever went wrong, so the only offered action was a retry that could not help in some situations. Surface the recorded reason on the request's status badge, offer a search for a different listing when a show's seasons are numbered differently than TheTVDB, and disable the retry for that case since it cannot succeed on replay. --- src/components/RequestBlock/index.tsx | 15 ++- src/components/RequestCard/index.tsx | 104 ++++++++++++++---- .../RequestList/RequestItem/index.tsx | 101 ++++++++++++----- src/i18n/globalMessages.ts | 9 ++ src/i18n/locale/en.json | 7 ++ src/utils/requestFailureHelpers.ts | 42 +++++++ 6 files changed, 227 insertions(+), 51 deletions(-) create mode 100644 src/utils/requestFailureHelpers.ts diff --git a/src/components/RequestBlock/index.tsx b/src/components/RequestBlock/index.tsx index 5ea29c3d56..2355dcce50 100644 --- a/src/components/RequestBlock/index.tsx +++ b/src/components/RequestBlock/index.tsx @@ -7,6 +7,7 @@ import useRequestOverride from '@app/hooks/useRequestOverride'; import { useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; +import { formatFailureReason } from '@app/utils/requestFailureHelpers'; import { CalendarIcon, CheckIcon, @@ -225,9 +226,17 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => { )} {request.status === MediaRequestStatus.FAILED && ( - - {intl.formatMessage(globalMessages.failed)} - + + + {intl.formatMessage(globalMessages.failed)} + + )} {request.status === MediaRequestStatus.COMPLETED && ( diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 0f2341f5ee..51b1b77902 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -14,10 +14,16 @@ import { getRequestDownloadStatus, refreshIntervalHelper, } from '@app/utils/refreshIntervalHelper'; +import { + canRetryRequest, + canSearchAnotherListing, + formatFailureReason, +} from '@app/utils/requestFailureHelpers'; import { withProperties } from '@app/utils/typeHelpers'; import { ArrowPathIcon, CheckIcon, + MagnifyingGlassIcon, PencilIcon, TrashIcon, XMarkIcon, @@ -149,11 +155,19 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => { {requestData.status === MediaRequestStatus.DECLINED || requestData.status === MediaRequestStatus.FAILED ? ( - - {requestData.status === MediaRequestStatus.DECLINED - ? intl.formatMessage(globalMessages.declined) - : intl.formatMessage(globalMessages.failed)} - + + + {requestData.status === MediaRequestStatus.DECLINED + ? intl.formatMessage(globalMessages.declined) + : intl.formatMessage(globalMessages.failed)} + + ) : ( { {intl.formatMessage(globalMessages.declined)} ) : requestData.status === MediaRequestStatus.FAILED ? ( - - {intl.formatMessage(globalMessages.failed)} - + + {intl.formatMessage(globalMessages.failed)} + + ) : requestData.status === MediaRequestStatus.PENDING && requestData.media[requestData.is4k ? 'status4k' : 'status'] === MediaStatus.DELETED ? ( @@ -480,20 +502,56 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
{requestData.status === MediaRequestStatus.FAILED && hasPermission(Permission.MANAGE_REQUESTS) && ( - + + + )} + {requestData.status === MediaRequestStatus.FAILED && + canSearchAnotherListing(requestData.failureReason) && + title && ( + + + )} {requestData.status === MediaRequestStatus.PENDING && hasPermission(Permission.MANAGE_REQUESTS) && ( diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 29a11ac514..cbcafed963 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -3,6 +3,7 @@ import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; import ConfirmButton from '@app/components/Common/ConfirmButton'; +import Tooltip from '@app/components/Common/Tooltip'; import RequestModal from '@app/components/RequestModal'; import StatusBadge from '@app/components/StatusBadge'; import useDeepLinks from '@app/hooks/useDeepLinks'; @@ -14,9 +15,15 @@ import { getRequestDownloadStatus, refreshIntervalHelper, } from '@app/utils/refreshIntervalHelper'; +import { + canRetryRequest, + canSearchAnotherListing, + formatFailureReason, +} from '@app/utils/requestFailureHelpers'; import { ArrowPathIcon, CheckIcon, + MagnifyingGlassIcon, PencilIcon, TrashIcon, XMarkIcon, @@ -139,11 +146,19 @@ const RequestItemError = ({ {requestData.status === MediaRequestStatus.DECLINED || requestData.status === MediaRequestStatus.FAILED ? ( - - {requestData.status === MediaRequestStatus.DECLINED - ? intl.formatMessage(globalMessages.declined) - : intl.formatMessage(globalMessages.failed)} - + + + {requestData.status === MediaRequestStatus.DECLINED + ? intl.formatMessage(globalMessages.declined) + : intl.formatMessage(globalMessages.failed)} + + ) : ( { {intl.formatMessage(globalMessages.declined)} ) : requestData.status === MediaRequestStatus.FAILED ? ( - - {intl.formatMessage(globalMessages.failed)} - + + {intl.formatMessage(globalMessages.failed)} + + ) : requestData.status === MediaRequestStatus.PENDING && requestData.media[requestData.is4k ? 'status4k' : 'status'] === MediaStatus.DELETED ? ( @@ -683,22 +706,50 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
{requestData.status === MediaRequestStatus.FAILED && hasPermission(Permission.MANAGE_REQUESTS) && ( - + + + )} + {requestData.status === MediaRequestStatus.FAILED && + canSearchAnotherListing(requestData.failureReason) && + title && ( + + + )} {requestData.status !== MediaRequestStatus.PENDING && hasPermission(Permission.MANAGE_REQUESTS) && ( diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts index 51eac54508..b09414d582 100644 --- a/src/i18n/globalMessages.ts +++ b/src/i18n/globalMessages.ts @@ -73,6 +73,15 @@ const globalMessages = defineMessages('i18n', { removefromBlocklist: 'Remove from Blocklist', specials: 'Specials', notificationLocale: 'Notification Language', + findlisting: 'Find Listing', + retryunavailable: 'Retrying cannot resolve this failure.', + failurereasonunreachable: 'Could not connect to {serviceName}.', + failurereasondispatch: '{serviceName} rejected the request.', + failurereasontvdbid: 'This series could not be identified on TheTVDB.', + failurereasonseasonunverified: + 'The season numbering could not be checked against TheTVDB.', + failurereasonseasonmismatch: + 'This listing numbers its seasons differently than TheTVDB.', }); export default globalMessages; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 9a2e9c78d1..8e076780f7 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1665,6 +1665,12 @@ "i18n.error": "Something went wrong. Please try again.", "i18n.experimental": "Experimental", "i18n.failed": "Failed", + "i18n.failurereasondispatch": "{serviceName} rejected the request.", + "i18n.failurereasonseasonmismatch": "This listing numbers its seasons differently than TheTVDB.", + "i18n.failurereasonseasonunverified": "The season numbering could not be checked against TheTVDB.", + "i18n.failurereasontvdbid": "This series could not be identified on TheTVDB.", + "i18n.failurereasonunreachable": "Could not connect to {serviceName}.", + "i18n.findlisting": "Find Listing", "i18n.import": "Import", "i18n.importing": "Importing…", "i18n.loading": "Loading…", @@ -1691,6 +1697,7 @@ "i18n.resultsperpage": "Display {pageSize} results per page", "i18n.retry": "Retry", "i18n.retrying": "Retrying…", + "i18n.retryunavailable": "Retrying cannot resolve this failure.", "i18n.save": "Save Changes", "i18n.saving": "Saving…", "i18n.settings": "Settings", diff --git a/src/utils/requestFailureHelpers.ts b/src/utils/requestFailureHelpers.ts new file mode 100644 index 0000000000..d341cb6d6f --- /dev/null +++ b/src/utils/requestFailureHelpers.ts @@ -0,0 +1,42 @@ +import globalMessages from '@app/i18n/globalMessages'; +import { MediaRequestFailureReason, MediaType } from '@server/constants/media'; +import type { IntlShape, MessageDescriptor } from 'react-intl'; + +const failureReasonMessages: Record< + MediaRequestFailureReason, + MessageDescriptor +> = { + [MediaRequestFailureReason.SERVICE_UNREACHABLE]: + globalMessages.failurereasonunreachable, + [MediaRequestFailureReason.DISPATCH_FAILED]: + globalMessages.failurereasondispatch, + [MediaRequestFailureReason.TVDB_ID_UNRESOLVED]: + globalMessages.failurereasontvdbid, + [MediaRequestFailureReason.SEASON_NUMBERING_UNVERIFIED]: + globalMessages.failurereasonseasonunverified, + [MediaRequestFailureReason.SEASON_NUMBERING_MISMATCH]: + globalMessages.failurereasonseasonmismatch, +}; + +export const formatFailureReason = ( + intl: IntlShape, + type: MediaType, + reason?: MediaRequestFailureReason | null +): string | undefined => { + const message = reason ? failureReasonMessages[reason] : undefined; + + return message + ? intl.formatMessage(message, { + serviceName: type === MediaType.MOVIE ? 'Radarr' : 'Sonarr', + }) + : undefined; +}; + +export const canSearchAnotherListing = ( + reason?: MediaRequestFailureReason | null +): boolean => reason === MediaRequestFailureReason.SEASON_NUMBERING_MISMATCH; + +// a numbering mismatch re-runs the same comparison on every attempt +export const canRetryRequest = ( + reason?: MediaRequestFailureReason | null +): boolean => reason !== MediaRequestFailureReason.SEASON_NUMBERING_MISMATCH;