fix(frontend): better request/media cards for items without valid TMDb IDs (#2181)

pull/2953/head
TheCatLady 2 years ago committed by GitHub
parent a12697b061
commit 9bc1f89777
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -19,26 +19,156 @@ const clickFirstTitleCardInSlider = (sliderTitle: string): void => {
describe('Discover', () => {
beforeEach(() => {
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
cy.visit('/');
});
it('loads a trending item', () => {
cy.intercept('/api/v1/discover/trending*').as('getTrending');
cy.visit('/');
cy.wait('@getTrending');
clickFirstTitleCardInSlider('Trending');
});
it('loads popular movies', () => {
cy.intercept('/api/v1/discover/movies*').as('getPopularMovies');
cy.visit('/');
cy.wait('@getPopularMovies');
clickFirstTitleCardInSlider('Popular Movies');
});
it('loads upcoming movies', () => {
cy.intercept('/api/v1/discover/movies/upcoming*').as('getUpcomingMovies');
cy.visit('/');
cy.wait('@getUpcomingMovies');
clickFirstTitleCardInSlider('Upcoming Movies');
});
it('loads popular series', () => {
cy.intercept('/api/v1/discover/tv*').as('getPopularTv');
cy.visit('/');
cy.wait('@getPopularTv');
clickFirstTitleCardInSlider('Popular Series');
});
it('loads upcoming series', () => {
cy.intercept('/api/v1/discover/tv/upcoming*').as('getUpcomingSeries');
cy.visit('/');
cy.wait('@getUpcomingSeries');
clickFirstTitleCardInSlider('Upcoming Series');
});
it('displays error for media with invalid TMDb ID', () => {
cy.intercept('GET', '/api/v1/media?*', {
pageInfo: { pages: 1, pageSize: 20, results: 1, page: 1 },
results: [
{
downloadStatus: [],
downloadStatus4k: [],
id: 1922,
mediaType: 'movie',
tmdbId: 998814,
tvdbId: null,
imdbId: null,
status: 5,
status4k: 1,
createdAt: '2022-08-18T18:11:13.000Z',
updatedAt: '2022-08-18T19:56:41.000Z',
lastSeasonChange: '2022-08-18T19:56:41.000Z',
mediaAddedAt: '2022-08-18T19:56:41.000Z',
serviceId: null,
serviceId4k: null,
externalServiceId: null,
externalServiceId4k: null,
externalServiceSlug: null,
externalServiceSlug4k: null,
ratingKey: null,
ratingKey4k: null,
seasons: [],
},
],
}).as('getMedia');
cy.visit('/');
cy.wait('@getMedia');
cy.contains('.slider-header', 'Recently Added')
.next('[data-testid=media-slider]')
.find('[data-testid=title-card]')
.first()
.find('[data-testid=title-card-title]')
.contains('Movie Not Found');
});
it('displays error for request with invalid TMDb ID', () => {
cy.intercept('GET', '/api/v1/request?*', {
pageInfo: { pages: 1, pageSize: 10, results: 1, page: 1 },
results: [
{
id: 582,
status: 1,
createdAt: '2022-08-18T18:11:13.000Z',
updatedAt: '2022-08-18T18:11:13.000Z',
type: 'movie',
is4k: false,
serverId: null,
profileId: null,
rootFolder: null,
languageProfileId: null,
tags: null,
media: {
downloadStatus: [],
downloadStatus4k: [],
id: 1922,
mediaType: 'movie',
tmdbId: 998814,
tvdbId: null,
imdbId: null,
status: 2,
status4k: 1,
createdAt: '2022-08-18T18:11:13.000Z',
updatedAt: '2022-08-18T18:11:13.000Z',
lastSeasonChange: '2022-08-18T18:11:13.000Z',
mediaAddedAt: null,
serviceId: null,
serviceId4k: null,
externalServiceId: null,
externalServiceId4k: null,
externalServiceSlug: null,
externalServiceSlug4k: null,
ratingKey: null,
ratingKey4k: null,
},
seasons: [],
modifiedBy: null,
requestedBy: {
permissions: 4194336,
id: 18,
email: 'friend@seerr.dev',
plexUsername: null,
username: '',
recoveryLinkExpirationDate: null,
userType: 2,
avatar:
'https://gravatar.com/avatar/c77fdc27cab83732b8623d2ea873d330?default=mm&size=200',
movieQuotaLimit: null,
movieQuotaDays: null,
tvQuotaLimit: null,
tvQuotaDays: null,
createdAt: '2022-08-17T04:55:28.000Z',
updatedAt: '2022-08-17T04:55:28.000Z',
requestCount: 1,
displayName: 'friend@seerr.dev',
},
seasonCount: 0,
},
],
}).as('getRequests');
cy.visit('/');
cy.wait('@getRequests');
cy.contains('.slider-header', 'Recent Requests')
.next('[data-testid=media-slider]')
.find('[data-testid=request-card]')
.first()
.find('[data-testid=request-card-title]')
.contains('Movie Not Found');
});
});

@ -39,7 +39,9 @@ const Discover = () => {
const { data: requests, error: requestError } =
useSWR<RequestResultsResponse>(
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
{ revalidateOnMount: true }
{
revalidateOnMount: true,
}
);
return (
@ -61,7 +63,9 @@ const Discover = () => {
items={media?.results?.map((item) => (
<TmdbTitleCard
key={`media-slider-item-${item.id}`}
id={item.id}
tmdbId={item.tmdbId}
tvdbId={item.tvdbId}
type={item.mediaType}
/>
))}

@ -28,7 +28,9 @@ import StatusBadge from '../StatusBadge';
const messages = defineMessages({
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
failedretry: 'Something went wrong while retrying the request.',
mediaerror: 'The associated title for this request is no longer available.',
mediaerror: '{mediaType} Not Found',
tmdbid: 'TMDb ID',
tvdbid: 'TVDB ID',
deleterequest: 'Delete Request',
});
@ -47,41 +49,127 @@ const RequestCardPlaceholder = () => {
};
interface RequestCardErrorProps {
mediaId?: number;
requestData?: MediaRequest;
}
const RequestCardError = ({ mediaId }: RequestCardErrorProps) => {
const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
const { hasPermission } = useUser();
const intl = useIntl();
const deleteRequest = async () => {
await axios.delete(`/api/v1/media/${mediaId}`);
await axios.delete(`/api/v1/media/${requestData?.media.id}`);
mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded');
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
};
return (
<div className="relative w-72 rounded-xl bg-gray-800 p-4 ring-1 ring-red-500 sm:w-96">
<div
className="relative flex w-72 overflow-hidden rounded-xl bg-gray-800 p-4 text-gray-400 shadow ring-1 ring-red-500 sm:w-96"
data-testid="request-card"
>
<div className="w-20 sm:w-28">
<div className="w-full" style={{ paddingBottom: '150%' }}>
<div className="absolute inset-0 flex h-full w-full flex-col items-center justify-center px-10">
<div className="w-full whitespace-normal text-center text-xs text-gray-300 sm:text-sm">
{intl.formatMessage(messages.mediaerror)}
<div className="absolute inset-0 z-10 flex min-w-0 flex-1 flex-col p-4">
<div
className="whitespace-normal text-base font-bold text-white sm:text-lg"
data-testid="request-card-title"
>
{intl.formatMessage(messages.mediaerror, {
mediaType: intl.formatMessage(
requestData?.type
? requestData?.type === 'movie'
? globalMessages.movie
: globalMessages.tvshow
: globalMessages.request
),
})}
</div>
{requestData && (
<>
{hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
) && (
<div className="card-field !hidden sm:!block">
<Link href={`/users/${requestData.requestedBy.id}`}>
<a className="group flex items-center">
<img
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm"
/>
<span className="truncate group-hover:underline">
{requestData.requestedBy.displayName}
</span>
</a>
</Link>
</div>
)}
<div className="mt-2 flex items-center text-sm sm:mt-1">
<span className="mr-2 hidden font-bold sm:block">
{intl.formatMessage(globalMessages.status)}
</span>
{requestData.status === MediaRequestStatus.DECLINED ||
requestData.status === MediaRequestStatus.FAILED ? (
<Badge badgeType="danger">
{requestData.status === MediaRequestStatus.DECLINED
? intl.formatMessage(globalMessages.declined)
: intl.formatMessage(globalMessages.failed)}
</Badge>
) : (
<StatusBadge
status={
requestData.media[
requestData.is4k ? 'status4k' : 'status'
]
}
inProgress={
(
requestData.media[
requestData.is4k
? 'downloadStatus4k'
: 'downloadStatus'
] ?? []
).length > 0
}
is4k={requestData.is4k}
plexUrl={
requestData.is4k
? requestData.media.plexUrl4k
: requestData.media.plexUrl
}
serviceUrl={
hasPermission(Permission.ADMIN)
? requestData.is4k
? requestData.media.serviceUrl4k
: requestData.media.serviceUrl
: undefined
}
/>
)}
</div>
{hasPermission(Permission.MANAGE_REQUESTS) && mediaId && (
</>
)}
<div className="flex flex-1 items-end space-x-2">
{hasPermission(Permission.MANAGE_REQUESTS) &&
requestData?.media.id && (
<Button
buttonType="danger"
buttonSize="sm"
className="mt-4"
onClick={() => deleteRequest()}
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
<TrashIcon style={{ marginRight: '0' }} />
<span className="ml-1.5 hidden sm:block">
{intl.formatMessage(messages.deleterequest)}
</span>
</Button>
)}
</div>
</div>
</div>
</div>
</div>
);
};
@ -165,7 +253,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
}
if (!title || !requestData) {
return <RequestCardError mediaId={requestData?.media.id} />;
return <RequestCardError requestData={requestData} />;
}
return (
@ -182,7 +270,10 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
setShowEditModal(false);
}}
/>
<div className="relative flex w-72 overflow-hidden rounded-xl bg-gray-800 bg-cover bg-center p-4 text-gray-400 shadow ring-1 ring-gray-700 sm:w-96">
<div
className="relative flex w-72 overflow-hidden rounded-xl bg-gray-800 bg-cover bg-center p-4 text-gray-400 shadow ring-1 ring-gray-700 sm:w-96"
data-testid="request-card"
>
{title.backdropPath && (
<div className="absolute inset-0 z-0">
<CachedImage
@ -200,7 +291,10 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
/>
</div>
)}
<div className="relative z-10 flex min-w-0 flex-1 flex-col pr-4">
<div
className="relative z-10 flex min-w-0 flex-1 flex-col pr-4"
data-testid="request-card-title"
>
<div className="hidden text-xs font-medium text-white sm:flex">
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice(
0,

@ -32,51 +32,230 @@ const messages = defineMessages({
requesteddate: 'Requested',
modified: 'Modified',
modifieduserdate: '{date} by {user}',
mediaerror: 'The associated title for this request is no longer available.',
mediaerror: '{mediaType} Not Found',
editrequest: 'Edit Request',
deleterequest: 'Delete Request',
cancelRequest: 'Cancel Request',
tmdbid: 'TMDb ID',
tvdbid: 'TVDB ID',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
interface RequestItemErroProps {
mediaId?: number;
interface RequestItemErrorProps {
requestData?: MediaRequest;
revalidateList: () => void;
}
const RequestItemError = ({
mediaId,
requestData,
revalidateList,
}: RequestItemErroProps) => {
}: RequestItemErrorProps) => {
const intl = useIntl();
const { hasPermission } = useUser();
const deleteRequest = async () => {
await axios.delete(`/api/v1/media/${mediaId}`);
await axios.delete(`/api/v1/media/${requestData?.media.id}`);
revalidateList();
};
return (
<div className="flex h-64 w-full flex-col items-center justify-center rounded-xl bg-gray-800 px-10 ring-1 ring-red-500 lg:flex-row xl:h-28">
<span className="text-center text-sm text-gray-300 lg:text-left">
{intl.formatMessage(messages.mediaerror)}
<div className="flex h-64 w-full flex-col justify-center rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-red-500 xl:h-28 xl:flex-row">
<div className="flex w-full flex-col justify-between overflow-hidden sm:flex-row">
<div className="flex w-full flex-col justify-center overflow-hidden pl-4 pr-4 sm:pr-0 xl:w-7/12 2xl:w-2/3">
<div className="flex text-lg font-bold text-white xl:text-xl">
{intl.formatMessage(messages.mediaerror, {
mediaType: intl.formatMessage(
requestData?.type
? requestData?.type === 'movie'
? globalMessages.movie
: globalMessages.tvshow
: globalMessages.request
),
})}
</div>
{requestData && hasPermission(Permission.MANAGE_REQUESTS) && (
<>
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.tmdbid)}
</span>
<span className="flex truncate text-sm text-gray-300">
{requestData.media.tmdbId}
</span>
</div>
{requestData.media.tvdbId && (
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.tvdbid)}
</span>
<span className="flex truncate text-sm text-gray-300">
{requestData?.media.tvdbId}
</span>
</div>
)}
</>
)}
</div>
<div className="mt-4 ml-4 flex w-full flex-col justify-center overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
{requestData && (
<>
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(globalMessages.status)}
</span>
{requestData.status === MediaRequestStatus.DECLINED ||
requestData.status === MediaRequestStatus.FAILED ? (
<Badge badgeType="danger">
{requestData.status === MediaRequestStatus.DECLINED
? intl.formatMessage(globalMessages.declined)
: intl.formatMessage(globalMessages.failed)}
</Badge>
) : (
<StatusBadge
status={
requestData.media[
requestData.is4k ? 'status4k' : 'status'
]
}
inProgress={
(
requestData.media[
requestData.is4k
? 'downloadStatus4k'
: 'downloadStatus'
] ?? []
).length > 0
}
is4k={requestData.is4k}
plexUrl={
requestData.is4k
? requestData.media.plexUrl4k
: requestData.media.plexUrl
}
serviceUrl={
hasPermission(Permission.ADMIN)
? requestData.is4k
? requestData.media.serviceUrl4k
: requestData.media.serviceUrl
: undefined
}
/>
)}
</div>
<div className="card-field">
{hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
) ? (
<>
<span className="card-field-name">
{intl.formatMessage(messages.requested)}
</span>
<span className="flex truncate text-sm text-gray-300">
{intl.formatMessage(messages.modifieduserdate, {
date: (
<FormattedRelativeTime
value={Math.floor(
(new Date(requestData.createdAt).getTime() -
Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
user: (
<Link href={`/users/${requestData.requestedBy.id}`}>
<a className="group flex items-center truncate">
<img
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm ml-1.5"
/>
<span className="truncate text-sm group-hover:underline">
{requestData.requestedBy.displayName}
</span>
</a>
</Link>
),
})}
</span>
</>
) : (
<>
<span className="card-field-name">
{intl.formatMessage(messages.requesteddate)}
</span>
<span className="flex truncate text-sm text-gray-300">
<FormattedRelativeTime
value={Math.floor(
(new Date(requestData.createdAt).getTime() -
Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
</span>
{hasPermission(Permission.MANAGE_REQUESTS) && mediaId && (
<div className="mt-4 lg:ml-4 lg:mt-0">
</>
)}
</div>
{requestData.modifiedBy && (
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.modified)}
</span>
<span className="flex truncate text-sm text-gray-300">
{intl.formatMessage(messages.modifieduserdate, {
date: (
<FormattedRelativeTime
value={Math.floor(
(new Date(requestData.updatedAt).getTime() -
Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
user: (
<Link href={`/users/${requestData.modifiedBy.id}`}>
<a className="group flex items-center truncate">
<img
src={requestData.modifiedBy.avatar}
alt=""
className="avatar-sm ml-1.5"
/>
<span className="truncate text-sm group-hover:underline">
{requestData.modifiedBy.displayName}
</span>
</a>
</Link>
),
})}
</span>
</div>
)}
</>
)}
</div>
</div>
<div className="z-10 mt-4 flex w-full flex-col justify-center pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
{hasPermission(Permission.MANAGE_REQUESTS) && requestData?.media.id && (
<Button
className="w-full"
buttonType="danger"
buttonSize="sm"
onClick={() => deleteRequest()}
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
</Button>
</div>
)}
</div>
</div>
);
};
@ -151,7 +330,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
if (!title || !requestData) {
return (
<RequestItemError
mediaId={requestData?.media.id}
requestData={requestData}
revalidateList={revalidateList}
/>
);

@ -0,0 +1,131 @@
import { defineMessages, useIntl } from 'react-intl';
import globalMessages from '../../i18n/globalMessages';
import Button from '../Common/Button';
import { CheckIcon, TrashIcon } from '@heroicons/react/solid';
import axios from 'axios';
import { mutate } from 'swr';
interface ErrorCardProps {
id: number;
tmdbId: number;
tvdbId?: number;
type: 'movie' | 'tv';
canExpand?: boolean;
}
const messages = defineMessages({
mediaerror: '{mediaType} Not Found',
tmdbid: 'TMDb ID',
tvdbid: 'TVDB ID',
cleardata: 'Clear Data',
});
const Error = ({ id, tmdbId, tvdbId, type, canExpand }: ErrorCardProps) => {
const intl = useIntl();
const deleteMedia = async () => {
await axios.delete(`/api/v1/media/${id}`);
mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded');
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
};
return (
<div
className={canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'}
data-testid="title-card"
>
<div
className="relative transform-gpu cursor-default overflow-hidden rounded-xl bg-gray-800 bg-cover shadow outline-none ring-1 ring-gray-700 transition duration-300"
style={{
paddingBottom: '150%',
}}
>
<div className="absolute inset-0 h-full w-full overflow-hidden">
<div className="absolute left-0 right-0 flex items-center justify-between p-2">
<div
className={`pointer-events-none z-40 rounded-full shadow ${
type === 'movie' ? 'bg-blue-500' : 'bg-purple-600'
}`}
>
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
{type === 'movie'
? intl.formatMessage(globalMessages.movie)
: intl.formatMessage(globalMessages.tvshow)}
</div>
</div>
<div className="pointer-events-none z-40">
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-green-400 text-white shadow sm:h-5 sm:w-5">
<CheckIcon className="h-3 w-3 sm:h-4 sm:w-4" />
</div>
</div>
</div>
<div className="flex h-full w-full items-end">
<div className="px-2 pb-11 text-white">
<h1
className="whitespace-normal text-xl font-bold leading-tight"
style={{
WebkitLineClamp: 3,
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
}}
data-testid="title-card-title"
>
{intl.formatMessage(messages.mediaerror, {
mediaType: intl.formatMessage(
type === 'movie'
? globalMessages.movie
: globalMessages.tvshow
),
})}
</h1>
<div
className="whitespace-normal text-xs"
style={{
WebkitLineClamp: 3,
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
}}
>
<div className="flex items-center">
<span className="mr-2 font-bold text-gray-400">
{intl.formatMessage(messages.tmdbid)}
</span>
{tmdbId}
</div>
{!!tvdbId && (
<div className="mt-2 flex items-center sm:mt-1">
<span className="mr-2 font-bold text-gray-400">
{intl.formatMessage(messages.tvdbid)}
</span>
{tvdbId}
</div>
)}
</div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 py-2">
<Button
buttonType="danger"
buttonSize="sm"
onClick={(e) => {
e.preventDefault();
deleteMedia();
}}
className="h-7 w-full"
>
<TrashIcon />
<span>{intl.formatMessage(messages.cleardata)}</span>
</Button>
</div>
</div>
</div>
</div>
);
};
export default Error;

@ -3,9 +3,12 @@ import useSWR from 'swr';
import TitleCard from '.';
import type { MovieDetails } from '../../../server/models/Movie';
import type { TvDetails } from '../../../server/models/Tv';
import { Permission, useUser } from '../../hooks/useUser';
interface TmdbTitleCardProps {
export interface TmdbTitleCardProps {
id: number;
tmdbId: number;
tvdbId?: number;
type: 'movie' | 'tv';
}
@ -13,7 +16,9 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
const TmdbTitleCard = ({ tmdbId, type }: TmdbTitleCardProps) => {
const TmdbTitleCard = ({ id, tmdbId, tvdbId, type }: TmdbTitleCardProps) => {
const { hasPermission } = useUser();
const { ref, inView } = useInView({
triggerOnce: true,
});
@ -32,7 +37,14 @@ const TmdbTitleCard = ({ tmdbId, type }: TmdbTitleCardProps) => {
}
if (!title) {
return <TitleCard.Placeholder />;
return hasPermission(Permission.ADMIN) ? (
<TitleCard.ErrorCard
id={id}
tmdbId={tmdbId}
tvdbId={tvdbId}
type={type}
/>
) : null;
}
return isMovie(title) ? (

@ -15,6 +15,7 @@ import CachedImage from '../Common/CachedImage';
import RequestModal from '../RequestModal';
import Transition from '../Transition';
import Placeholder from './Placeholder';
import ErrorCard from './ErrorCard';
interface TitleCardProps {
id: number;
@ -266,4 +267,4 @@ const TitleCard = ({
);
};
export default withProperties(TitleCard, { Placeholder });
export default withProperties(TitleCard, { Placeholder, ErrorCard });

@ -293,7 +293,9 @@ const UserProfile = () => {
items={watchData.recentlyWatched.map((item) => (
<TmdbTitleCard
key={`media-slider-item-${item.id}`}
id={item.id}
tmdbId={item.tmdbId}
tvdbId={item.tvdbId}
type={item.mediaType}
/>
))}

@ -292,18 +292,22 @@
"components.RequestButton.viewrequest4k": "View 4K Request",
"components.RequestCard.deleterequest": "Delete Request",
"components.RequestCard.failedretry": "Something went wrong while retrying the request.",
"components.RequestCard.mediaerror": "The associated title for this request is no longer available.",
"components.RequestCard.mediaerror": "{mediaType} Not Found",
"components.RequestCard.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
"components.RequestCard.tmdbid": "TMDb ID",
"components.RequestCard.tvdbid": "TVDB ID",
"components.RequestList.RequestItem.cancelRequest": "Cancel Request",
"components.RequestList.RequestItem.deleterequest": "Delete Request",
"components.RequestList.RequestItem.editrequest": "Edit Request",
"components.RequestList.RequestItem.failedretry": "Something went wrong while retrying the request.",
"components.RequestList.RequestItem.mediaerror": "The associated title for this request is no longer available.",
"components.RequestList.RequestItem.mediaerror": "{mediaType} Not Found",
"components.RequestList.RequestItem.modified": "Modified",
"components.RequestList.RequestItem.modifieduserdate": "{date} by {user}",
"components.RequestList.RequestItem.requested": "Requested",
"components.RequestList.RequestItem.requesteddate": "Requested",
"components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
"components.RequestList.RequestItem.tmdbid": "TMDb ID",
"components.RequestList.RequestItem.tvdbid": "TVDB ID",
"components.RequestList.requests": "Requests",
"components.RequestList.showallrequests": "Show All Requests",
"components.RequestList.sortAdded": "Most Recent",
@ -827,6 +831,10 @@
"components.StatusChecker.reloadApp": "Reload {applicationTitle}",
"components.StatusChecker.restartRequired": "Server Restart Required",
"components.StatusChecker.restartRequiredDescription": "Please restart the server to apply the updated settings.",
"components.TitleCard.cleardata": "Clear Data",
"components.TitleCard.mediaerror": "{mediaType} Not Found",
"components.TitleCard.tmdbid": "TMDb ID",
"components.TitleCard.tvdbid": "TVDB ID",
"components.TvDetails.TvCast.fullseriescast": "Full Series Cast",
"components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew",
"components.TvDetails.anime": "Anime",

Loading…
Cancel
Save