Skip to content

Commit 25590a7

Browse files
committed
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.
1 parent f89740d commit 25590a7

6 files changed

Lines changed: 227 additions & 51 deletions

File tree

‎src/components/RequestBlock/index.tsx‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import useRequestOverride from '@app/hooks/useRequestOverride';
77
import { useUser } from '@app/hooks/useUser';
88
import globalMessages from '@app/i18n/globalMessages';
99
import defineMessages from '@app/utils/defineMessages';
10+
import { formatFailureReason } from '@app/utils/requestFailureHelpers';
1011
import {
1112
CalendarIcon,
1213
CheckIcon,
@@ -225,9 +226,17 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
225226
</Badge>
226227
)}
227228
{request.status === MediaRequestStatus.FAILED && (
228-
<Badge badgeType="danger">
229-
{intl.formatMessage(globalMessages.failed)}
230-
</Badge>
229+
<Tooltip
230+
content={formatFailureReason(
231+
intl,
232+
request.type,
233+
request.failureReason
234+
)}
235+
>
236+
<Badge badgeType="danger">
237+
{intl.formatMessage(globalMessages.failed)}
238+
</Badge>
239+
</Tooltip>
231240
)}
232241
{request.status === MediaRequestStatus.COMPLETED && (
233242
<Badge badgeType="success">

‎src/components/RequestCard/index.tsx‎

Lines changed: 81 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@ import {
1414
getRequestDownloadStatus,
1515
refreshIntervalHelper,
1616
} from '@app/utils/refreshIntervalHelper';
17+
import {
18+
canRetryRequest,
19+
canSearchAnotherListing,
20+
formatFailureReason,
21+
} from '@app/utils/requestFailureHelpers';
1722
import { withProperties } from '@app/utils/typeHelpers';
1823
import {
1924
ArrowPathIcon,
2025
CheckIcon,
26+
MagnifyingGlassIcon,
2127
PencilIcon,
2228
TrashIcon,
2329
XMarkIcon,
@@ -149,11 +155,19 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
149155
</span>
150156
{requestData.status === MediaRequestStatus.DECLINED ||
151157
requestData.status === MediaRequestStatus.FAILED ? (
152-
<Badge badgeType="danger">
153-
{requestData.status === MediaRequestStatus.DECLINED
154-
? intl.formatMessage(globalMessages.declined)
155-
: intl.formatMessage(globalMessages.failed)}
156-
</Badge>
158+
<Tooltip
159+
content={formatFailureReason(
160+
intl,
161+
requestData.type,
162+
requestData.failureReason
163+
)}
164+
>
165+
<Badge badgeType="danger">
166+
{requestData.status === MediaRequestStatus.DECLINED
167+
? intl.formatMessage(globalMessages.declined)
168+
: intl.formatMessage(globalMessages.failed)}
169+
</Badge>
170+
</Tooltip>
157171
) : (
158172
<StatusBadge
159173
status={
@@ -442,12 +456,20 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
442456
{intl.formatMessage(globalMessages.declined)}
443457
</Badge>
444458
) : requestData.status === MediaRequestStatus.FAILED ? (
445-
<Badge
446-
badgeType="danger"
447-
href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
459+
<Tooltip
460+
content={formatFailureReason(
461+
intl,
462+
requestData.type,
463+
requestData.failureReason
464+
)}
448465
>
449-
{intl.formatMessage(globalMessages.failed)}
450-
</Badge>
466+
<Badge
467+
badgeType="danger"
468+
href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
469+
>
470+
{intl.formatMessage(globalMessages.failed)}
471+
</Badge>
472+
</Tooltip>
451473
) : requestData.status === MediaRequestStatus.PENDING &&
452474
requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
453475
MediaStatus.DELETED ? (
@@ -480,20 +502,56 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
480502
<div className="flex flex-1 items-end space-x-2">
481503
{requestData.status === MediaRequestStatus.FAILED &&
482504
hasPermission(Permission.MANAGE_REQUESTS) && (
483-
<Button
484-
buttonType="primary"
485-
buttonSize="sm"
486-
disabled={isRetrying}
487-
onClick={() => retryRequest()}
505+
<Tooltip
506+
content={
507+
canRetryRequest(requestData.failureReason)
508+
? undefined
509+
: intl.formatMessage(globalMessages.retryunavailable)
510+
}
488511
>
489-
<ArrowPathIcon
490-
className={isRetrying ? 'animate-spin' : ''}
491-
style={{ marginRight: '0', animationDirection: 'reverse' }}
492-
/>
493-
<span className="ml-1.5 hidden sm:block">
494-
{intl.formatMessage(globalMessages.retry)}
495-
</span>
496-
</Button>
512+
<Button
513+
buttonType="primary"
514+
buttonSize="sm"
515+
disabled={
516+
isRetrying || !canRetryRequest(requestData.failureReason)
517+
}
518+
onClick={() => retryRequest()}
519+
>
520+
<ArrowPathIcon
521+
className={isRetrying ? 'animate-spin' : ''}
522+
style={{
523+
marginRight: '0',
524+
animationDirection: 'reverse',
525+
}}
526+
/>
527+
<span className="ml-1.5 hidden sm:block">
528+
{intl.formatMessage(globalMessages.retry)}
529+
</span>
530+
</Button>
531+
</Tooltip>
532+
)}
533+
{requestData.status === MediaRequestStatus.FAILED &&
534+
canSearchAnotherListing(requestData.failureReason) &&
535+
title && (
536+
<Link
537+
href={`/search?query=${encodeURIComponent(
538+
isMovie(title) ? title.title : title.name
539+
)}`}
540+
passHref
541+
legacyBehavior
542+
>
543+
<Button
544+
as="a"
545+
buttonType="default"
546+
buttonSize="sm"
547+
title={intl.formatMessage(globalMessages.findlisting)}
548+
>
549+
<MagnifyingGlassIcon style={{ marginRight: '0' }} />
550+
<span className="ml-1.5 hidden sm:block">
551+
{intl.formatMessage(globalMessages.findlisting)}
552+
</span>
553+
</Button>
554+
</Link>
497555
)}
498556
{requestData.status === MediaRequestStatus.PENDING &&
499557
hasPermission(Permission.MANAGE_REQUESTS) && (

‎src/components/RequestList/RequestItem/index.tsx‎

Lines changed: 76 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Badge from '@app/components/Common/Badge';
33
import Button from '@app/components/Common/Button';
44
import CachedImage from '@app/components/Common/CachedImage';
55
import ConfirmButton from '@app/components/Common/ConfirmButton';
6+
import Tooltip from '@app/components/Common/Tooltip';
67
import RequestModal from '@app/components/RequestModal';
78
import StatusBadge from '@app/components/StatusBadge';
89
import useDeepLinks from '@app/hooks/useDeepLinks';
@@ -14,9 +15,15 @@ import {
1415
getRequestDownloadStatus,
1516
refreshIntervalHelper,
1617
} from '@app/utils/refreshIntervalHelper';
18+
import {
19+
canRetryRequest,
20+
canSearchAnotherListing,
21+
formatFailureReason,
22+
} from '@app/utils/requestFailureHelpers';
1723
import {
1824
ArrowPathIcon,
1925
CheckIcon,
26+
MagnifyingGlassIcon,
2027
PencilIcon,
2128
TrashIcon,
2229
XMarkIcon,
@@ -139,11 +146,19 @@ const RequestItemError = ({
139146
</span>
140147
{requestData.status === MediaRequestStatus.DECLINED ||
141148
requestData.status === MediaRequestStatus.FAILED ? (
142-
<Badge badgeType="danger">
143-
{requestData.status === MediaRequestStatus.DECLINED
144-
? intl.formatMessage(globalMessages.declined)
145-
: intl.formatMessage(globalMessages.failed)}
146-
</Badge>
149+
<Tooltip
150+
content={formatFailureReason(
151+
intl,
152+
requestData.type,
153+
requestData.failureReason
154+
)}
155+
>
156+
<Badge badgeType="danger">
157+
{requestData.status === MediaRequestStatus.DECLINED
158+
? intl.formatMessage(globalMessages.declined)
159+
: intl.formatMessage(globalMessages.failed)}
160+
</Badge>
161+
</Tooltip>
147162
) : (
148163
<StatusBadge
149164
status={
@@ -527,12 +542,20 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
527542
{intl.formatMessage(globalMessages.declined)}
528543
</Badge>
529544
) : requestData.status === MediaRequestStatus.FAILED ? (
530-
<Badge
531-
badgeType="danger"
532-
href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
545+
<Tooltip
546+
content={formatFailureReason(
547+
intl,
548+
requestData.type,
549+
requestData.failureReason
550+
)}
533551
>
534-
{intl.formatMessage(globalMessages.failed)}
535-
</Badge>
552+
<Badge
553+
badgeType="danger"
554+
href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
555+
>
556+
{intl.formatMessage(globalMessages.failed)}
557+
</Badge>
558+
</Tooltip>
536559
) : requestData.status === MediaRequestStatus.PENDING &&
537560
requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
538561
MediaStatus.DELETED ? (
@@ -683,22 +706,50 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
683706
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
684707
{requestData.status === MediaRequestStatus.FAILED &&
685708
hasPermission(Permission.MANAGE_REQUESTS) && (
686-
<Button
687-
className="w-full"
688-
buttonType="primary"
689-
disabled={isRetrying}
690-
onClick={() => retryRequest()}
709+
<Tooltip
710+
content={
711+
canRetryRequest(requestData.failureReason)
712+
? undefined
713+
: intl.formatMessage(globalMessages.retryunavailable)
714+
}
691715
>
692-
<ArrowPathIcon
693-
className={isRetrying ? 'animate-spin' : ''}
694-
style={{ animationDirection: 'reverse' }}
695-
/>
696-
<span>
697-
{intl.formatMessage(
698-
isRetrying ? globalMessages.retrying : globalMessages.retry
699-
)}
700-
</span>
701-
</Button>
716+
<Button
717+
className="w-full"
718+
buttonType="primary"
719+
disabled={
720+
isRetrying || !canRetryRequest(requestData.failureReason)
721+
}
722+
onClick={() => retryRequest()}
723+
>
724+
<ArrowPathIcon
725+
className={isRetrying ? 'animate-spin' : ''}
726+
style={{ animationDirection: 'reverse' }}
727+
/>
728+
<span>
729+
{intl.formatMessage(
730+
isRetrying
731+
? globalMessages.retrying
732+
: globalMessages.retry
733+
)}
734+
</span>
735+
</Button>
736+
</Tooltip>
737+
)}
738+
{requestData.status === MediaRequestStatus.FAILED &&
739+
canSearchAnotherListing(requestData.failureReason) &&
740+
title && (
741+
<Link
742+
href={`/search?query=${encodeURIComponent(
743+
isMovie(title) ? title.title : title.name
744+
)}`}
745+
passHref
746+
legacyBehavior
747+
>
748+
<Button as="a" className="w-full" buttonType="default">
749+
<MagnifyingGlassIcon />
750+
<span>{intl.formatMessage(globalMessages.findlisting)}</span>
751+
</Button>
752+
</Link>
702753
)}
703754
{requestData.status !== MediaRequestStatus.PENDING &&
704755
hasPermission(Permission.MANAGE_REQUESTS) && (

‎src/i18n/globalMessages.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ const globalMessages = defineMessages('i18n', {
7373
removefromBlocklist: 'Remove from Blocklist',
7474
specials: 'Specials',
7575
notificationLocale: 'Notification Language',
76+
findlisting: 'Find Listing',
77+
retryunavailable: 'Retrying cannot resolve this failure.',
78+
failurereasonunreachable: 'Could not connect to {serviceName}.',
79+
failurereasondispatch: '{serviceName} rejected the request.',
80+
failurereasontvdbid: 'This series could not be identified on TheTVDB.',
81+
failurereasonseasonunverified:
82+
'The season numbering could not be checked against TheTVDB.',
83+
failurereasonseasonmismatch:
84+
'This listing numbers its seasons differently than TheTVDB.',
7685
});
7786

7887
export default globalMessages;

‎src/i18n/locale/en.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1664,6 +1664,12 @@
16641664
"i18n.error": "Something went wrong. Please try again.",
16651665
"i18n.experimental": "Experimental",
16661666
"i18n.failed": "Failed",
1667+
"i18n.failurereasondispatch": "{serviceName} rejected the request.",
1668+
"i18n.failurereasonseasonmismatch": "This listing numbers its seasons differently than TheTVDB.",
1669+
"i18n.failurereasonseasonunverified": "The season numbering could not be checked against TheTVDB.",
1670+
"i18n.failurereasontvdbid": "This series could not be identified on TheTVDB.",
1671+
"i18n.failurereasonunreachable": "Could not connect to {serviceName}.",
1672+
"i18n.findlisting": "Find Listing",
16671673
"i18n.import": "Import",
16681674
"i18n.importing": "Importing…",
16691675
"i18n.loading": "Loading…",
@@ -1690,6 +1696,7 @@
16901696
"i18n.resultsperpage": "Display {pageSize} results per page",
16911697
"i18n.retry": "Retry",
16921698
"i18n.retrying": "Retrying…",
1699+
"i18n.retryunavailable": "Retrying cannot resolve this failure.",
16931700
"i18n.save": "Save Changes",
16941701
"i18n.saving": "Saving…",
16951702
"i18n.settings": "Settings",

‎src/utils/requestFailureHelpers.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import globalMessages from '@app/i18n/globalMessages';
2+
import { MediaRequestFailureReason, MediaType } from '@server/constants/media';
3+
import type { IntlShape, MessageDescriptor } from 'react-intl';
4+
5+
const failureReasonMessages: Record<
6+
MediaRequestFailureReason,
7+
MessageDescriptor
8+
> = {
9+
[MediaRequestFailureReason.SERVICE_UNREACHABLE]:
10+
globalMessages.failurereasonunreachable,
11+
[MediaRequestFailureReason.DISPATCH_FAILED]:
12+
globalMessages.failurereasondispatch,
13+
[MediaRequestFailureReason.TVDB_ID_UNRESOLVED]:
14+
globalMessages.failurereasontvdbid,
15+
[MediaRequestFailureReason.SEASON_NUMBERING_UNVERIFIED]:
16+
globalMessages.failurereasonseasonunverified,
17+
[MediaRequestFailureReason.SEASON_NUMBERING_MISMATCH]:
18+
globalMessages.failurereasonseasonmismatch,
19+
};
20+
21+
export const formatFailureReason = (
22+
intl: IntlShape,
23+
type: MediaType,
24+
reason?: MediaRequestFailureReason | null
25+
): string | undefined => {
26+
const message = reason ? failureReasonMessages[reason] : undefined;
27+
28+
return message
29+
? intl.formatMessage(message, {
30+
serviceName: type === MediaType.MOVIE ? 'Radarr' : 'Sonarr',
31+
})
32+
: undefined;
33+
};
34+
35+
export const canSearchAnotherListing = (
36+
reason?: MediaRequestFailureReason | null
37+
): boolean => reason === MediaRequestFailureReason.SEASON_NUMBERING_MISMATCH;
38+
39+
// a numbering mismatch re-runs the same comparison on every attempt
40+
export const canRetryRequest = (
41+
reason?: MediaRequestFailureReason | null
42+
): boolean => reason !== MediaRequestFailureReason.SEASON_NUMBERING_MISMATCH;

0 commit comments

Comments
 (0)