diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index cc1e429ff..33e40e4d4 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -69,6 +69,30 @@ class ExternalAPI { return response.data; } + protected async post( + endpoint: string, + data: Record, + config?: AxiosRequestConfig, + ttl?: number + ): Promise { + const cacheKey = this.serializeCacheKey(endpoint, { + config: config?.params, + data, + }); + const cachedItem = this.cache?.get(cacheKey); + if (cachedItem) { + return cachedItem; + } + + const response = await this.axios.post(endpoint, data, config); + + if (this.cache) { + this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); + } + + return response.data; + } + protected async getRolling( endpoint: string, config?: AxiosRequestConfig, diff --git a/server/api/rottentomatoes.ts b/server/api/rottentomatoes.ts index e190b7b97..7695e3987 100644 --- a/server/api/rottentomatoes.ts +++ b/server/api/rottentomatoes.ts @@ -1,28 +1,40 @@ import cacheManager from '@server/lib/cache'; +import { getSettings } from '@server/lib/settings'; import ExternalAPI from './externalapi'; -interface RTSearchResult { - meterClass: 'certified_fresh' | 'fresh' | 'rotten'; - meterScore: number; - url: string; +interface RTAlgoliaSearchResponse { + results: { + hits: RTAlgoliaHit[]; + index: 'content_rt' | 'people_rt'; + }[]; } -interface RTTvSearchResult extends RTSearchResult { +interface RTAlgoliaHit { + emsId: string; + emsVersionId: string; + tmsId: string; + type: string; title: string; - startYear: number; - endYear: number; -} -interface RTMovieSearchResult extends RTSearchResult { - name: string; - url: string; - year: number; -} - -interface RTMultiSearchResponse { - tvCount: number; - tvSeries: RTTvSearchResult[]; - movieCount: number; - movies: RTMovieSearchResult[]; + titles: string[]; + description: string; + releaseYear: string; + rating: string; + genres: string[]; + updateDate: string; + isEmsSearchable: boolean; + rtId: number; + vanity: string; + aka: string[]; + posterImageUrl: string; + rottenTomatoes: { + audienceScore: number; + criticsIconUrl: string; + wantToSeeCount: number; + audienceIconUrl: string; + scoreSentiment: string; + certifiedFresh: boolean; + criticsScore: number; + }; } export interface RTRating { @@ -47,13 +59,20 @@ export interface RTRating { */ class RottenTomatoes extends ExternalAPI { constructor() { + const settings = getSettings(); super( - 'https://www.rottentomatoes.com/api/private', - {}, + 'https://79frdp12pn-dsn.algolia.net/1/indexes/*', + { + 'x-algolia-agent': + 'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)', + 'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561', + 'x-algolia-application-id': '79FRDP12PN', + }, { headers: { 'Content-Type': 'application/json', Accept: 'application/json', + 'x-algolia-usertoken': settings.clientId, }, nodeCache: cacheManager.getCache('rt').data, } @@ -61,14 +80,11 @@ class RottenTomatoes extends ExternalAPI { } /** - * Search the 1.0 api for the movie title + * Search the RT algolia api for the movie title * * We compare the release date to make sure its the correct * match. But it's not guaranteed to have results. * - * We use the 1.0 API here because the 2.0 search api does - * not return audience ratings. - * * @param name Movie name * @param year Release Year */ @@ -77,30 +93,45 @@ class RottenTomatoes extends ExternalAPI { year: number ): Promise { try { - const data = await this.get('/v2.0/search/', { - params: { q: name, limit: 10 }, + const data = await this.post('/queries', { + requests: [ + { + indexName: 'content_rt', + query: name, + params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20', + }, + ], }); + const contentResults = data.results.find((r) => r.index === 'content_rt'); + + if (!contentResults) { + return null; + } + // First, attempt to match exact name and year - let movie = data.movies.find( - (movie) => movie.year === year && movie.name === name + let movie = contentResults.hits.find( + (movie) => movie.releaseYear === year.toString() && movie.title === name ); // If we don't find a movie, try to match partial name and year if (!movie) { - movie = data.movies.find( - (movie) => movie.year === year && movie.name.includes(name) + movie = contentResults.hits.find( + (movie) => + movie.releaseYear === year.toString() && movie.title.includes(name) ); } // If we still dont find a movie, try to match just on year if (!movie) { - movie = data.movies.find((movie) => movie.year === year); + movie = contentResults.hits.find( + (movie) => movie.releaseYear === year.toString() + ); } // One last try, try exact name match only if (!movie) { - movie = data.movies.find((movie) => movie.name === name); + movie = contentResults.hits.find((movie) => movie.title === name); } if (!movie) { @@ -108,16 +139,15 @@ class RottenTomatoes extends ExternalAPI { } return { - title: movie.name, - url: `https://www.rottentomatoes.com${movie.url}`, - criticsRating: - movie.meterClass === 'certified_fresh' - ? 'Certified Fresh' - : movie.meterClass === 'fresh' - ? 'Fresh' - : 'Rotten', - criticsScore: movie.meterScore, - year: movie.year, + title: movie.title, + url: `https://www.rottentomatoes.com/m/${movie.vanity}`, + criticsRating: movie.rottenTomatoes.certifiedFresh + ? 'Certified Fresh' + : movie.rottenTomatoes.criticsScore >= 60 + ? 'Fresh' + : 'Rotten', + criticsScore: movie.rottenTomatoes.criticsScore, + year: Number(movie.releaseYear), }; } catch (e) { throw new Error( @@ -131,14 +161,28 @@ class RottenTomatoes extends ExternalAPI { year?: number ): Promise { try { - const data = await this.get('/v2.0/search/', { - params: { q: name, limit: 10 }, + const data = await this.post('/queries', { + requests: [ + { + indexName: 'content_rt', + query: name, + params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20', + }, + ], }); - let tvshow: RTTvSearchResult | undefined = data.tvSeries[0]; + const contentResults = data.results.find((r) => r.index === 'content_rt'); + + if (!contentResults) { + return null; + } + + let tvshow: RTAlgoliaHit | undefined = contentResults.hits[0]; if (year) { - tvshow = data.tvSeries.find((series) => series.startYear === year); + tvshow = contentResults.hits.find( + (series) => series.releaseYear === year.toString() + ); } if (!tvshow) { @@ -147,10 +191,11 @@ class RottenTomatoes extends ExternalAPI { return { title: tvshow.title, - url: `https://www.rottentomatoes.com${tvshow.url}`, - criticsRating: tvshow.meterClass === 'fresh' ? 'Fresh' : 'Rotten', - criticsScore: tvshow.meterScore, - year: tvshow.startYear, + url: `https://www.rottentomatoes.com/tv/${tvshow.vanity}`, + criticsRating: + tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten', + criticsScore: tvshow.rottenTomatoes.criticsScore, + year: Number(tvshow.releaseYear), }; } catch (e) { throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`); 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" @@ -354,6 +356,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { ) && ( 0 diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 27bb33834..0f018aa30 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -38,6 +38,7 @@ const messages = defineMessages({ editrequest: 'Edit Request', cancelrequest: 'Cancel Request', deleterequest: 'Delete Request', + unknowntitle: 'Unknown Title', }); const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { @@ -136,6 +137,14 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => { requestData.is4k ? 'status4k' : 'status' ] } + downloadItem={ + requestData.media[ + requestData.is4k + ? 'downloadStatus4k' + : 'downloadStatus' + ] + } + title={intl.formatMessage(messages.unknowntitle)} inProgress={ ( requestData.media[ @@ -146,6 +155,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => { ).length > 0 } is4k={requestData.is4k} + mediaType={requestData.type} plexUrl={requestData.is4k ? plexUrl4k : plexUrl} serviceUrl={ requestData.is4k @@ -397,6 +407,12 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { status={ requestData.media[requestData.is4k ? 'status4k' : 'status'] } + downloadItem={ + requestData.media[ + requestData.is4k ? 'downloadStatus4k' : '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 e5a00de75..bab252aa9 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -39,6 +39,7 @@ const messages = defineMessages({ cancelRequest: 'Cancel Request', tmdbid: 'TMDB ID', tvdbid: 'TheTVDB ID', + unknowntitle: 'Unknown Title', }); const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { @@ -128,6 +129,12 @@ const RequestItemError = ({ requestData.is4k ? 'status4k' : 'status' ] } + downloadItem={ + requestData.media[ + requestData.is4k ? 'downloadStatus4k' : 'downloadStatus' + ] + } + title={intl.formatMessage(messages.unknowntitle)} inProgress={ ( requestData.media[ @@ -138,6 +145,7 @@ const RequestItemError = ({ ).length > 0 } is4k={requestData.is4k} + mediaType={requestData.type} plexUrl={requestData.is4k ? plexUrl4k : plexUrl} serviceUrl={ requestData.is4k @@ -463,6 +471,12 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { status={ requestData.media[requestData.is4k ? 'status4k' : 'status'] } + downloadItem={ + requestData.media[ + requestData.is4k ? 'downloadStatus4k' : '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 cd5e0ad1a..4ad39eb13 100644 --- a/src/components/StatusBadge/index.tsx +++ b/src/components/StatusBadge/index.tsx @@ -1,11 +1,13 @@ 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 { MediaServerType } from '@server/constants/server'; +import type { DownloadingItem } from '@server/lib/downloadtracker'; import getConfig from 'next/config'; import { defineMessages, useIntl } from 'react-intl'; @@ -15,26 +17,31 @@ const messages = defineMessages({ playonplex: 'Play on {mediaServerName}', 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(); @@ -44,6 +51,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 && @@ -95,21 +106,87 @@ const StatusBadge = ({ } } + const tooltipContent = ( +
    + {downloadItem.map((status, index) => ( +
  • + +
  • + ))} +
+ ); + + 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, + })} + + )} + + + )}
@@ -117,20 +194,52 @@ 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, + })} + + )} + + + )}
@@ -138,9 +247,29 @@ const StatusBadge = ({ case MediaStatus.PROCESSING: return ( - - -
+ + + {inProgress && badgeDownloadProgress} +
{intl.formatMessage( is4k ? messages.status4k : messages.status, @@ -151,7 +280,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 1792027e9..81f47d016 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -350,6 +350,8 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
0} tmdbId={data.mediaInfo?.tmdbId} mediaType="tv" @@ -369,6 +371,8 @@ const TvDetails = ({ tv }: TvDetailsProps) => { ) && ( 0 diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 27f21134c..fc1c04765 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -35,6 +35,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", @@ -332,6 +333,7 @@ "components.RequestCard.seasons": "{seasonCount, plural, one {Season} other {Seasons}}", "components.RequestCard.tmdbid": "TMDB ID", "components.RequestCard.tvdbid": "TheTVDB ID", + "components.RequestCard.unknowntitle": "Unknown Title", "components.RequestList.RequestItem.cancelRequest": "Cancel Request", "components.RequestList.RequestItem.deleterequest": "Delete Request", "components.RequestList.RequestItem.editrequest": "Edit Request", @@ -344,6 +346,7 @@ "components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}", "components.RequestList.RequestItem.tmdbid": "TMDB ID", "components.RequestList.RequestItem.tvdbid": "TheTVDB ID", + "components.RequestList.RequestItem.unknowntitle": "Unknown Title", "components.RequestList.requests": "Requests", "components.RequestList.showallrequests": "Show All Requests", "components.RequestList.sortAdded": "Most Recent", @@ -880,6 +883,7 @@ "components.StatusBadge.managemedia": "Manage {mediaType}", "components.StatusBadge.openinarr": "Open in {arr}", "components.StatusBadge.playonplex": "Play on {mediaServerName}", + "components.StatusBadge.seasonepisodenumber": "S{seasonNumber}E{episodeNumber}", "components.StatusBadge.status": "{status}", "components.StatusBadge.status4k": "4K {status}", "components.StatusChacker.newversionDescription": "Jellyseerr has been updated! Please click the button below to reload the page.",