feat(ui): request card progress bar (#3123)

pull/3184/head
Brandon Cohen 2 years ago committed by GitHub
parent 357cab87ac
commit 03853a1b91
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -158,7 +158,12 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => { public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
try { try {
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>( const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
`/queue` `/queue`,
{
params: {
includeEpisode: true,
},
}
); );
return response.data.records; return response.data.records;

@ -13,6 +13,21 @@ interface SonarrSeason {
percentOfEpisodes: number; percentOfEpisodes: number;
}; };
} }
interface EpisodeResult {
seriesId: number;
episodeFileId: number;
seasonNumber: number;
episodeNumber: number;
title: string;
airDate: string;
airDateUtc: string;
overview: string;
hasFile: boolean;
monitored: boolean;
absoluteEpisodeNumber: number;
unverifiedSceneNumbering: boolean;
id: number;
}
export interface SonarrSeries { export interface SonarrSeries {
title: string; title: string;
@ -82,7 +97,11 @@ export interface LanguageProfile {
name: string; name: string;
} }
class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> { class SonarrAPI extends ServarrBase<{
seriesId: number;
episodeId: number;
episode: EpisodeResult;
}> {
constructor({ url, apiKey }: { url: string; apiKey: string }) { constructor({ url, apiKey }: { url: string; apiKey: string }) {
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' }); super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
} }

@ -5,6 +5,12 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { uniqWith } from 'lodash'; import { uniqWith } from 'lodash';
interface EpisodeNumberResult {
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber: number;
id: number;
}
export interface DownloadingItem { export interface DownloadingItem {
mediaType: MediaType; mediaType: MediaType;
externalId: number; externalId: number;
@ -14,6 +20,7 @@ export interface DownloadingItem {
timeLeft: string; timeLeft: string;
estimatedCompletionTime: Date; estimatedCompletionTime: Date;
title: string; title: string;
episode?: EpisodeNumberResult;
} }
class DownloadTracker { class DownloadTracker {
@ -164,6 +171,7 @@ class DownloadTracker {
status: item.status, status: item.status,
timeLeft: item.timeleft, timeLeft: item.timeleft,
title: item.title, title: item.title,
episode: item.episode,
})); }));
if (queueItems.length > 0) { if (queueItems.length > 0) {

@ -16,7 +16,7 @@ import type { Collection } from '@server/models/Collection';
import { uniq } from 'lodash'; import { uniq } from 'lodash';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
@ -51,6 +51,28 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
const { data: genres } = const { data: genres } =
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`); useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
const [downloadStatus, downloadStatus4k] = useMemo(() => {
return [
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
),
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
),
];
}, [data?.parts]);
const [titles, titles4k] = useMemo(() => {
return [
data?.parts
.filter((media) => (media.mediaInfo?.downloadStatus ?? []).length > 0)
.map((title) => title.title),
data?.parts
.filter((media) => (media.mediaInfo?.downloadStatus4k ?? []).length > 0)
.map((title) => title.title),
];
}, [data?.parts]);
if (!data && !error) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
} }
@ -205,6 +227,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
<div className="media-status"> <div className="media-status">
<StatusBadge <StatusBadge
status={collectionStatus} status={collectionStatus}
downloadItem={downloadStatus}
title={titles}
inProgress={data.parts.some( inProgress={data.parts.some(
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0 (part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
)} )}
@ -218,6 +242,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
) && ( ) && (
<StatusBadge <StatusBadge
status={collectionStatus4k} status={collectionStatus4k}
downloadItem={downloadStatus4k}
title={titles4k}
is4k is4k
inProgress={data.parts.some( inProgress={data.parts.some(
(part) => (part) =>

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom';
import type { Config } from 'react-popper-tooltip'; import type { Config } from 'react-popper-tooltip';
import { usePopperTooltip } from 'react-popper-tooltip'; import { usePopperTooltip } from 'react-popper-tooltip';
@ -6,9 +7,15 @@ type TooltipProps = {
content: React.ReactNode; content: React.ReactNode;
children: React.ReactElement; children: React.ReactElement;
tooltipConfig?: Partial<Config>; tooltipConfig?: Partial<Config>;
className?: string;
}; };
const Tooltip = ({ children, content, tooltipConfig }: TooltipProps) => { const Tooltip = ({
children,
content,
tooltipConfig,
className,
}: TooltipProps) => {
const { getTooltipProps, setTooltipRef, setTriggerRef, visible } = const { getTooltipProps, setTooltipRef, setTriggerRef, visible } =
usePopperTooltip({ usePopperTooltip({
followCursor: true, followCursor: true,
@ -17,20 +24,30 @@ const Tooltip = ({ children, content, tooltipConfig }: TooltipProps) => {
...tooltipConfig, ...tooltipConfig,
}); });
const tooltipStyle = [
'z-50 text-sm absolute font-normal bg-gray-800 px-2 py-1 rounded border border-gray-600 shadow text-gray-100',
];
if (className) {
tooltipStyle.push(className);
}
return ( return (
<> <>
{React.cloneElement(children, { ref: setTriggerRef })} {React.cloneElement(children, { ref: setTriggerRef })}
{visible && content && ( {visible &&
<div content &&
ref={setTooltipRef} ReactDOM.createPortal(
{...getTooltipProps({ <div
className: ref={setTooltipRef}
'z-50 text-sm font-normal bg-gray-800 px-2 py-1 rounded border border-gray-600 shadow text-gray-100', {...getTooltipProps({
})} className: tooltipStyle.join(' '),
> })}
{content} >
</div> {content}
)} </div>,
document.body
)}
</> </>
); );
}; };

@ -1,23 +1,39 @@
import Badge from '@app/components/Common/Badge'; import Badge from '@app/components/Common/Badge';
import { Permission, useUser } from '@app/hooks/useUser';
import type { DownloadingItem } from '@server/lib/downloadtracker'; import type { DownloadingItem } from '@server/lib/downloadtracker';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({
estimatedtime: 'Estimated {time}', estimatedtime: 'Estimated {time}',
formattedTitle: '{title}: Season {seasonNumber} Episode {episodeNumber}',
}); });
interface DownloadBlockProps { interface DownloadBlockProps {
downloadItem: DownloadingItem; downloadItem: DownloadingItem;
is4k?: boolean; is4k?: boolean;
title?: string;
} }
const DownloadBlock = ({ downloadItem, is4k = false }: DownloadBlockProps) => { const DownloadBlock = ({
downloadItem,
is4k = false,
title,
}: DownloadBlockProps) => {
const intl = useIntl(); const intl = useIntl();
const { hasPermission } = useUser();
return ( return (
<div className="p-4"> <div className="p-4">
<div className="mb-2 w-56 truncate text-sm sm:w-80 md:w-full"> <div className="mb-2 w-56 truncate text-sm sm:w-80 md:w-full">
{downloadItem.title} {hasPermission(Permission.ADMIN)
? downloadItem.title
: downloadItem.episode
? intl.formatMessage(messages.formattedTitle, {
title,
seasonNumber: downloadItem?.episode?.seasonNumber,
episodeNumber: downloadItem?.episode?.episodeNumber,
})
: title}
</div> </div>
<div className="relative mb-2 h-6 min-w-0 overflow-hidden rounded-full bg-gray-700"> <div className="relative mb-2 h-6 min-w-0 overflow-hidden rounded-full bg-gray-700">
<div <div

@ -305,6 +305,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
<div className="media-status"> <div className="media-status">
<StatusBadge <StatusBadge
status={data.mediaInfo?.status} status={data.mediaInfo?.status}
downloadItem={data.mediaInfo?.downloadStatus}
title={data.title}
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0} inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
tmdbId={data.mediaInfo?.tmdbId} tmdbId={data.mediaInfo?.tmdbId}
mediaType="movie" mediaType="movie"
@ -324,6 +326,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
) && ( ) && (
<StatusBadge <StatusBadge
status={data.mediaInfo?.status4k} status={data.mediaInfo?.status4k}
downloadItem={data.mediaInfo?.downloadStatus4k}
title={data.title}
is4k is4k
inProgress={ inProgress={
(data.mediaInfo?.downloadStatus4k ?? []).length > 0 (data.mediaInfo?.downloadStatus4k ?? []).length > 0

@ -397,6 +397,12 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
status={ status={
requestData.media[requestData.is4k ? 'status4k' : 'status'] requestData.media[requestData.is4k ? 'status4k' : 'status']
} }
downloadItem={
(requestData.media?.downloadStatus4k ?? []).length > 0
? requestData.media?.downloadStatus4k
: requestData.media?.downloadStatus
}
title={isMovie(title) ? title.title : title.name}
inProgress={ inProgress={
( (
requestData.media[ requestData.media[

@ -463,6 +463,12 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
status={ status={
requestData.media[requestData.is4k ? 'status4k' : 'status'] requestData.media[requestData.is4k ? 'status4k' : 'status']
} }
downloadItem={
requestData.media?.downloadStatus4k
? requestData.media?.downloadStatus4k
: requestData.media?.downloadStatus
}
title={isMovie(title) ? title.title : title.name}
inProgress={ inProgress={
( (
requestData.media[ requestData.media[

@ -1,10 +1,12 @@
import Spinner from '@app/assets/spinner.svg'; import Spinner from '@app/assets/spinner.svg';
import Badge from '@app/components/Common/Badge'; import Badge from '@app/components/Common/Badge';
import Tooltip from '@app/components/Common/Tooltip'; import Tooltip from '@app/components/Common/Tooltip';
import DownloadBlock from '@app/components/DownloadBlock';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import { MediaStatus } from '@server/constants/media'; import { MediaStatus } from '@server/constants/media';
import type { DownloadingItem } from '@server/lib/downloadtracker';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({
@ -13,26 +15,31 @@ const messages = defineMessages({
playonplex: 'Play on Plex', playonplex: 'Play on Plex',
openinarr: 'Open in {arr}', openinarr: 'Open in {arr}',
managemedia: 'Manage {mediaType}', managemedia: 'Manage {mediaType}',
seasonepisodenumber: 'S{seasonNumber}E{episodeNumber}',
}); });
interface StatusBadgeProps { interface StatusBadgeProps {
status?: MediaStatus; status?: MediaStatus;
downloadItem?: DownloadingItem[];
is4k?: boolean; is4k?: boolean;
inProgress?: boolean; inProgress?: boolean;
plexUrl?: string; plexUrl?: string;
serviceUrl?: string; serviceUrl?: string;
tmdbId?: number; tmdbId?: number;
mediaType?: 'movie' | 'tv'; mediaType?: 'movie' | 'tv';
title?: string | string[];
} }
const StatusBadge = ({ const StatusBadge = ({
status, status,
downloadItem = [],
is4k = false, is4k = false,
inProgress = false, inProgress = false,
plexUrl, plexUrl,
serviceUrl, serviceUrl,
tmdbId, tmdbId,
mediaType, mediaType,
title,
}: StatusBadgeProps) => { }: StatusBadgeProps) => {
const intl = useIntl(); const intl = useIntl();
const { hasPermission } = useUser(); const { hasPermission } = useUser();
@ -41,6 +48,10 @@ const StatusBadge = ({
let mediaLink: string | undefined; let mediaLink: string | undefined;
let mediaLinkDescription: string | undefined; let mediaLinkDescription: string | undefined;
const calculateDownloadProgress = (media: DownloadingItem) => {
return Math.round(((media?.size - media?.sizeLeft) / media?.size) * 100);
};
if ( if (
mediaType && mediaType &&
plexUrl && plexUrl &&
@ -85,21 +96,87 @@ const StatusBadge = ({
} }
} }
const tooltipContent = downloadItem ? (
<ul>
{downloadItem.map((status, index) => (
<li
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
>
<DownloadBlock
downloadItem={status}
title={Array.isArray(title) ? title[index] : title}
is4k={is4k}
/>
</li>
))}
</ul>
) : (
mediaLinkDescription
);
const badgeDownloadProgress = (
<div
className={`
absolute top-0 left-0 z-10 flex h-full ${
status === MediaStatus.PROCESSING ? 'bg-indigo-500' : 'bg-green-500'
} transition-all duration-200 ease-in-out
`}
style={{
width: `${
downloadItem ? calculateDownloadProgress(downloadItem[0]) : 0
}%`,
}}
/>
);
switch (status) { switch (status) {
case MediaStatus.AVAILABLE: case MediaStatus.AVAILABLE:
return ( return (
<Tooltip content={mediaLinkDescription}> <Tooltip
<Badge badgeType="success" href={mediaLink}> content={inProgress && tooltipContent}
<div className="flex items-center"> className={`${
inProgress && 'hidden max-h-96 w-96 overflow-scroll sm:block'
}`}
tooltipConfig={{ interactive: true, delayHide: 100 }}
>
<Badge
badgeType="success"
href={mediaLink}
className={`${
inProgress &&
'relative !bg-gray-700 !bg-opacity-80 !px-0 hover:!bg-gray-700'
} overflow-hidden`}
>
{inProgress && badgeDownloadProgress}
<div
className={`relative z-20 flex items-center ${
inProgress && 'px-2'
}`}
>
<span> <span>
{intl.formatMessage( {intl.formatMessage(
is4k ? messages.status4k : messages.status, is4k ? messages.status4k : messages.status,
{ {
status: intl.formatMessage(globalMessages.available), status: inProgress
? intl.formatMessage(globalMessages.processing)
: intl.formatMessage(globalMessages.available),
} }
)} )}
</span> </span>
{inProgress && <Spinner className="ml-1 h-3 w-3" />} {inProgress && (
<>
{mediaType === 'tv' && (
<span className="ml-1">
{intl.formatMessage(messages.seasonepisodenumber, {
seasonNumber: downloadItem[0].episode?.seasonNumber,
episodeNumber: downloadItem[0].episode?.episodeNumber,
})}
</span>
)}
<Spinner className="ml-1 h-3 w-3" />
</>
)}
</div> </div>
</Badge> </Badge>
</Tooltip> </Tooltip>
@ -107,20 +184,50 @@ const StatusBadge = ({
case MediaStatus.PARTIALLY_AVAILABLE: case MediaStatus.PARTIALLY_AVAILABLE:
return ( return (
<Tooltip content={mediaLinkDescription}> <Tooltip
<Badge badgeType="success" href={mediaLink}> content={inProgress && tooltipContent}
<div className="flex items-center"> className={`${
inProgress && 'hidden max-h-96 w-96 overflow-scroll sm:block'
}`}
tooltipConfig={{ interactive: true, delayHide: 100 }}
>
<Badge
badgeType="success"
href={mediaLink}
className={`${
inProgress &&
'relative !bg-gray-700 !bg-opacity-80 !px-0 hover:!bg-gray-700'
} overflow-hidden`}
>
{inProgress && badgeDownloadProgress}
<div
className={`relative z-20 flex items-center ${
inProgress && 'px-2'
}`}
>
<span> <span>
{intl.formatMessage( {intl.formatMessage(
is4k ? messages.status4k : messages.status, is4k ? messages.status4k : messages.status,
{ {
status: intl.formatMessage( status: inProgress
globalMessages.partiallyavailable ? intl.formatMessage(globalMessages.processing)
), : intl.formatMessage(globalMessages.partiallyavailable),
} }
)} )}
</span> </span>
{inProgress && <Spinner className="ml-1 h-3 w-3" />} {inProgress && (
<>
{mediaType === 'tv' && (
<span className="ml-1">
{intl.formatMessage(messages.seasonepisodenumber, {
seasonNumber: downloadItem[0].episode?.seasonNumber,
episodeNumber: downloadItem[0].episode?.episodeNumber,
})}
</span>
)}
<Spinner className="ml-1 h-3 w-3" />
</>
)}
</div> </div>
</Badge> </Badge>
</Tooltip> </Tooltip>
@ -128,9 +235,27 @@ const StatusBadge = ({
case MediaStatus.PROCESSING: case MediaStatus.PROCESSING:
return ( return (
<Tooltip content={mediaLinkDescription}> <Tooltip
<Badge badgeType="primary" href={mediaLink}> content={inProgress && tooltipContent}
<div className="flex items-center"> className={`${
inProgress && 'hidden max-h-96 w-96 overflow-scroll sm:block'
}`}
tooltipConfig={{ interactive: true, delayHide: 100 }}
>
<Badge
badgeType="primary"
href={mediaLink}
className={`${
inProgress &&
'relative !bg-gray-700 !bg-opacity-80 !px-0 hover:!bg-gray-700'
} overflow-hidden`}
>
{inProgress && badgeDownloadProgress}
<div
className={`relative z-20 flex items-center ${
inProgress && 'px-2'
}`}
>
<span> <span>
{intl.formatMessage( {intl.formatMessage(
is4k ? messages.status4k : messages.status, is4k ? messages.status4k : messages.status,
@ -141,7 +266,19 @@ const StatusBadge = ({
} }
)} )}
</span> </span>
{inProgress && <Spinner className="ml-1 h-3 w-3" />} {inProgress && (
<>
{mediaType === 'tv' && (
<span className="ml-1">
{intl.formatMessage(messages.seasonepisodenumber, {
seasonNumber: downloadItem[0].episode?.seasonNumber,
episodeNumber: downloadItem[0].episode?.episodeNumber,
})}
</span>
)}
<Spinner className="ml-1 h-3 w-3" />
</>
)}
</div> </div>
</Badge> </Badge>
</Tooltip> </Tooltip>

@ -318,6 +318,8 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
<div className="media-status"> <div className="media-status">
<StatusBadge <StatusBadge
status={data.mediaInfo?.status} status={data.mediaInfo?.status}
downloadItem={data.mediaInfo?.downloadStatus}
title={data.name}
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0} inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
tmdbId={data.mediaInfo?.tmdbId} tmdbId={data.mediaInfo?.tmdbId}
mediaType="tv" mediaType="tv"
@ -337,6 +339,8 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
) && ( ) && (
<StatusBadge <StatusBadge
status={data.mediaInfo?.status4k} status={data.mediaInfo?.status4k}
downloadItem={data.mediaInfo?.downloadStatus4k}
title={data.name}
is4k is4k
inProgress={ inProgress={
(data.mediaInfo?.downloadStatus4k ?? []).length > 0 (data.mediaInfo?.downloadStatus4k ?? []).length > 0

@ -34,6 +34,7 @@
"components.Discover.upcomingmovies": "Upcoming Movies", "components.Discover.upcomingmovies": "Upcoming Movies",
"components.Discover.upcomingtv": "Upcoming Series", "components.Discover.upcomingtv": "Upcoming Series",
"components.DownloadBlock.estimatedtime": "Estimated {time}", "components.DownloadBlock.estimatedtime": "Estimated {time}",
"components.DownloadBlock.formattedTitle": "{title}: Season {seasonNumber} Episode {episodeNumber}",
"components.IssueDetails.IssueComment.areyousuredelete": "Are you sure you want to delete this comment?", "components.IssueDetails.IssueComment.areyousuredelete": "Are you sure you want to delete this comment?",
"components.IssueDetails.IssueComment.delete": "Delete Comment", "components.IssueDetails.IssueComment.delete": "Delete Comment",
"components.IssueDetails.IssueComment.edit": "Edit Comment", "components.IssueDetails.IssueComment.edit": "Edit Comment",
@ -875,6 +876,7 @@
"components.StatusBadge.managemedia": "Manage {mediaType}", "components.StatusBadge.managemedia": "Manage {mediaType}",
"components.StatusBadge.openinarr": "Open in {arr}", "components.StatusBadge.openinarr": "Open in {arr}",
"components.StatusBadge.playonplex": "Play on Plex", "components.StatusBadge.playonplex": "Play on Plex",
"components.StatusBadge.seasonepisodenumber": "S{seasonNumber}E{episodeNumber}",
"components.StatusBadge.status": "{status}", "components.StatusBadge.status": "{status}",
"components.StatusBadge.status4k": "4K {status}", "components.StatusBadge.status4k": "4K {status}",
"components.StatusChecker.appUpdated": "{applicationTitle} Updated", "components.StatusChecker.appUpdated": "{applicationTitle} Updated",

Loading…
Cancel
Save