From 03853a1b9155c8a2153c8885022a74619af1bc15 Mon Sep 17 00:00:00 2001 From: Brandon Cohen Date: Mon, 26 Dec 2022 21:13:57 -0500 Subject: [PATCH] feat(ui): request card progress bar (#3123) --- server/api/servarr/base.ts | 7 +- server/api/servarr/sonarr.ts | 21 ++- server/lib/downloadtracker.ts | 8 + src/components/CollectionDetails/index.tsx | 28 ++- src/components/Common/Tooltip/index.tsx | 41 +++-- src/components/DownloadBlock/index.tsx | 20 ++- src/components/MovieDetails/index.tsx | 4 + src/components/RequestCard/index.tsx | 6 + .../RequestList/RequestItem/index.tsx | 6 + src/components/StatusBadge/index.tsx | 169 ++++++++++++++++-- src/components/TvDetails/index.tsx | 4 + src/i18n/locale/en.json | 2 + 12 files changed, 283 insertions(+), 33 deletions(-) diff --git a/server/api/servarr/base.ts b/server/api/servarr/base.ts index 2b8ec4cb8..c004b4746 100644 --- a/server/api/servarr/base.ts +++ b/server/api/servarr/base.ts @@ -158,7 +158,12 @@ class ServarrBase extends ExternalAPI { public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => { try { const response = await this.axios.get>( - `/queue` + `/queue`, + { + params: { + includeEpisode: true, + }, + } ); return response.data.records; diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index a5b9c1e8d..eca0208c7 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -13,6 +13,21 @@ interface SonarrSeason { 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 { title: string; @@ -82,7 +97,11 @@ export interface LanguageProfile { 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 }) { super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' }); } diff --git a/server/lib/downloadtracker.ts b/server/lib/downloadtracker.ts index 4aef968f1..cf29313e9 100644 --- a/server/lib/downloadtracker.ts +++ b/server/lib/downloadtracker.ts @@ -5,6 +5,12 @@ import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { uniqWith } from 'lodash'; +interface EpisodeNumberResult { + seasonNumber: number; + episodeNumber: number; + absoluteEpisodeNumber: number; + id: number; +} export interface DownloadingItem { mediaType: MediaType; externalId: number; @@ -14,6 +20,7 @@ export interface DownloadingItem { timeLeft: string; estimatedCompletionTime: Date; title: string; + episode?: EpisodeNumberResult; } class DownloadTracker { @@ -164,6 +171,7 @@ class DownloadTracker { status: item.status, timeLeft: item.timeleft, title: item.title, + episode: item.episode, })); if (queueItems.length > 0) { diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 52bd8a269..60ce94053 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -16,7 +16,7 @@ import type { Collection } from '@server/models/Collection'; import { uniq } from 'lodash'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; @@ -51,6 +51,28 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { const { data: genres } = 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) { return ; } @@ -205,6 +227,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
(part.mediaInfo?.downloadStatus ?? []).length > 0 )} @@ -218,6 +242,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { ) && ( diff --git a/src/components/Common/Tooltip/index.tsx b/src/components/Common/Tooltip/index.tsx index 82bc7a7a9..e8574699e 100644 --- a/src/components/Common/Tooltip/index.tsx +++ b/src/components/Common/Tooltip/index.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import ReactDOM from 'react-dom'; import type { Config } from 'react-popper-tooltip'; import { usePopperTooltip } from 'react-popper-tooltip'; @@ -6,9 +7,15 @@ type TooltipProps = { content: React.ReactNode; children: React.ReactElement; tooltipConfig?: Partial; + className?: string; }; -const Tooltip = ({ children, content, tooltipConfig }: TooltipProps) => { +const Tooltip = ({ + children, + content, + tooltipConfig, + className, +}: TooltipProps) => { const { getTooltipProps, setTooltipRef, setTriggerRef, visible } = usePopperTooltip({ followCursor: true, @@ -17,20 +24,30 @@ const Tooltip = ({ children, content, tooltipConfig }: TooltipProps) => { ...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 ( <> {React.cloneElement(children, { ref: setTriggerRef })} - {visible && content && ( -
- {content} -
- )} + {visible && + content && + ReactDOM.createPortal( +
+ {content} +
, + document.body + )} ); }; diff --git a/src/components/DownloadBlock/index.tsx b/src/components/DownloadBlock/index.tsx index 0597e3a6a..6bb04b54f 100644 --- a/src/components/DownloadBlock/index.tsx +++ b/src/components/DownloadBlock/index.tsx @@ -1,23 +1,39 @@ import Badge from '@app/components/Common/Badge'; +import { Permission, useUser } from '@app/hooks/useUser'; import type { DownloadingItem } from '@server/lib/downloadtracker'; import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; const messages = defineMessages({ estimatedtime: 'Estimated {time}', + formattedTitle: '{title}: Season {seasonNumber} Episode {episodeNumber}', }); interface DownloadBlockProps { downloadItem: DownloadingItem; is4k?: boolean; + title?: string; } -const DownloadBlock = ({ downloadItem, is4k = false }: DownloadBlockProps) => { +const DownloadBlock = ({ + downloadItem, + is4k = false, + title, +}: DownloadBlockProps) => { const intl = useIntl(); + const { hasPermission } = useUser(); return (
- {downloadItem.title} + {hasPermission(Permission.ADMIN) + ? downloadItem.title + : downloadItem.episode + ? intl.formatMessage(messages.formattedTitle, { + title, + seasonNumber: downloadItem?.episode?.seasonNumber, + episodeNumber: downloadItem?.episode?.episodeNumber, + }) + : title}
{
0} tmdbId={data.mediaInfo?.tmdbId} mediaType="movie" @@ -324,6 +326,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { ) && ( 0 diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index b98ecc7fb..f48d8fc0a 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -397,6 +397,12 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { 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={ ( requestData.media[ diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 88bd29c8b..31c9009ae 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -463,6 +463,12 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { 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={ ( requestData.media[ diff --git a/src/components/StatusBadge/index.tsx b/src/components/StatusBadge/index.tsx index 22fa2bbe8..278c8819b 100644 --- a/src/components/StatusBadge/index.tsx +++ b/src/components/StatusBadge/index.tsx @@ -1,10 +1,12 @@ import Spinner from '@app/assets/spinner.svg'; import Badge from '@app/components/Common/Badge'; import Tooltip from '@app/components/Common/Tooltip'; +import DownloadBlock from '@app/components/DownloadBlock'; import useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import { MediaStatus } from '@server/constants/media'; +import type { DownloadingItem } from '@server/lib/downloadtracker'; import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ @@ -13,26 +15,31 @@ const messages = defineMessages({ playonplex: 'Play on Plex', openinarr: 'Open in {arr}', managemedia: 'Manage {mediaType}', + seasonepisodenumber: 'S{seasonNumber}E{episodeNumber}', }); interface StatusBadgeProps { status?: MediaStatus; + downloadItem?: DownloadingItem[]; is4k?: boolean; inProgress?: boolean; plexUrl?: string; serviceUrl?: string; tmdbId?: number; mediaType?: 'movie' | 'tv'; + title?: string | string[]; } const StatusBadge = ({ status, + downloadItem = [], is4k = false, inProgress = false, plexUrl, serviceUrl, tmdbId, mediaType, + title, }: StatusBadgeProps) => { const intl = useIntl(); const { hasPermission } = useUser(); @@ -41,6 +48,10 @@ const StatusBadge = ({ let mediaLink: string | undefined; let mediaLinkDescription: string | undefined; + const calculateDownloadProgress = (media: DownloadingItem) => { + return Math.round(((media?.size - media?.sizeLeft) / media?.size) * 100); + }; + if ( mediaType && plexUrl && @@ -85,21 +96,87 @@ const StatusBadge = ({ } } + const tooltipContent = downloadItem ? ( +
    + {downloadItem.map((status, index) => ( +
  • + +
  • + ))} +
+ ) : ( + mediaLinkDescription + ); + + const badgeDownloadProgress = ( +
+ ); + switch (status) { case MediaStatus.AVAILABLE: return ( - - -
+ + + {inProgress && badgeDownloadProgress} +
{intl.formatMessage( is4k ? messages.status4k : messages.status, { - status: intl.formatMessage(globalMessages.available), + status: inProgress + ? intl.formatMessage(globalMessages.processing) + : intl.formatMessage(globalMessages.available), } )} - {inProgress && } + {inProgress && ( + <> + {mediaType === 'tv' && ( + + {intl.formatMessage(messages.seasonepisodenumber, { + seasonNumber: downloadItem[0].episode?.seasonNumber, + episodeNumber: downloadItem[0].episode?.episodeNumber, + })} + + )} + + + )}
@@ -107,20 +184,50 @@ const StatusBadge = ({ case MediaStatus.PARTIALLY_AVAILABLE: return ( - - -
+ + + {inProgress && badgeDownloadProgress} +
{intl.formatMessage( is4k ? messages.status4k : messages.status, { - status: intl.formatMessage( - globalMessages.partiallyavailable - ), + status: inProgress + ? intl.formatMessage(globalMessages.processing) + : intl.formatMessage(globalMessages.partiallyavailable), } )} - {inProgress && } + {inProgress && ( + <> + {mediaType === 'tv' && ( + + {intl.formatMessage(messages.seasonepisodenumber, { + seasonNumber: downloadItem[0].episode?.seasonNumber, + episodeNumber: downloadItem[0].episode?.episodeNumber, + })} + + )} + + + )}
@@ -128,9 +235,27 @@ const StatusBadge = ({ case MediaStatus.PROCESSING: return ( - - -
+ + + {inProgress && badgeDownloadProgress} +
{intl.formatMessage( is4k ? messages.status4k : messages.status, @@ -141,7 +266,19 @@ const StatusBadge = ({ } )} - {inProgress && } + {inProgress && ( + <> + {mediaType === 'tv' && ( + + {intl.formatMessage(messages.seasonepisodenumber, { + seasonNumber: downloadItem[0].episode?.seasonNumber, + episodeNumber: downloadItem[0].episode?.episodeNumber, + })} + + )} + + + )}
diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 628175f4f..876423905 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -318,6 +318,8 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
0} tmdbId={data.mediaInfo?.tmdbId} mediaType="tv" @@ -337,6 +339,8 @@ const TvDetails = ({ tv }: TvDetailsProps) => { ) && ( 0 diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index d05928cd5..18322e25c 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -34,6 +34,7 @@ "components.Discover.upcomingmovies": "Upcoming Movies", "components.Discover.upcomingtv": "Upcoming Series", "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.delete": "Delete Comment", "components.IssueDetails.IssueComment.edit": "Edit Comment", @@ -875,6 +876,7 @@ "components.StatusBadge.managemedia": "Manage {mediaType}", "components.StatusBadge.openinarr": "Open in {arr}", "components.StatusBadge.playonplex": "Play on Plex", + "components.StatusBadge.seasonepisodenumber": "S{seasonNumber}E{episodeNumber}", "components.StatusBadge.status": "{status}", "components.StatusBadge.status4k": "4K {status}", "components.StatusChecker.appUpdated": "{applicationTitle} Updated",