mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-01 04:08:45 -05:00
Merge remote-tracking branch 'overseerr/develop' into develop
This commit is contained in:
@@ -69,6 +69,30 @@ class ExternalAPI {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async post<T>(
|
||||||
|
endpoint: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
config?: AxiosRequestConfig,
|
||||||
|
ttl?: number
|
||||||
|
): Promise<T> {
|
||||||
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
|
config: config?.params,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||||
|
if (cachedItem) {
|
||||||
|
return cachedItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.axios.post<T>(endpoint, data, config);
|
||||||
|
|
||||||
|
if (this.cache) {
|
||||||
|
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
protected async getRolling<T>(
|
protected async getRolling<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
config?: AxiosRequestConfig,
|
config?: AxiosRequestConfig,
|
||||||
|
|||||||
@@ -1,28 +1,40 @@
|
|||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
import ExternalAPI from './externalapi';
|
import ExternalAPI from './externalapi';
|
||||||
|
|
||||||
interface RTSearchResult {
|
interface RTAlgoliaSearchResponse {
|
||||||
meterClass: 'certified_fresh' | 'fresh' | 'rotten';
|
results: {
|
||||||
meterScore: number;
|
hits: RTAlgoliaHit[];
|
||||||
url: string;
|
index: 'content_rt' | 'people_rt';
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RTTvSearchResult extends RTSearchResult {
|
interface RTAlgoliaHit {
|
||||||
|
emsId: string;
|
||||||
|
emsVersionId: string;
|
||||||
|
tmsId: string;
|
||||||
|
type: string;
|
||||||
title: string;
|
title: string;
|
||||||
startYear: number;
|
titles: string[];
|
||||||
endYear: number;
|
description: string;
|
||||||
}
|
releaseYear: string;
|
||||||
interface RTMovieSearchResult extends RTSearchResult {
|
rating: string;
|
||||||
name: string;
|
genres: string[];
|
||||||
url: string;
|
updateDate: string;
|
||||||
year: number;
|
isEmsSearchable: boolean;
|
||||||
}
|
rtId: number;
|
||||||
|
vanity: string;
|
||||||
interface RTMultiSearchResponse {
|
aka: string[];
|
||||||
tvCount: number;
|
posterImageUrl: string;
|
||||||
tvSeries: RTTvSearchResult[];
|
rottenTomatoes: {
|
||||||
movieCount: number;
|
audienceScore: number;
|
||||||
movies: RTMovieSearchResult[];
|
criticsIconUrl: string;
|
||||||
|
wantToSeeCount: number;
|
||||||
|
audienceIconUrl: string;
|
||||||
|
scoreSentiment: string;
|
||||||
|
certifiedFresh: boolean;
|
||||||
|
criticsScore: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RTRating {
|
export interface RTRating {
|
||||||
@@ -47,13 +59,20 @@ export interface RTRating {
|
|||||||
*/
|
*/
|
||||||
class RottenTomatoes extends ExternalAPI {
|
class RottenTomatoes extends ExternalAPI {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
const settings = getSettings();
|
||||||
super(
|
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: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
|
'x-algolia-usertoken': settings.clientId,
|
||||||
},
|
},
|
||||||
nodeCache: cacheManager.getCache('rt').data,
|
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
|
* We compare the release date to make sure its the correct
|
||||||
* match. But it's not guaranteed to have results.
|
* 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 name Movie name
|
||||||
* @param year Release Year
|
* @param year Release Year
|
||||||
*/
|
*/
|
||||||
@@ -77,30 +93,45 @@ class RottenTomatoes extends ExternalAPI {
|
|||||||
year: number
|
year: number
|
||||||
): Promise<RTRating | null> {
|
): Promise<RTRating | null> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
|
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
|
||||||
params: { q: name, limit: 10 },
|
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
|
// First, attempt to match exact name and year
|
||||||
let movie = data.movies.find(
|
let movie = contentResults.hits.find(
|
||||||
(movie) => movie.year === year && movie.name === name
|
(movie) => movie.releaseYear === year.toString() && movie.title === name
|
||||||
);
|
);
|
||||||
|
|
||||||
// If we don't find a movie, try to match partial name and year
|
// If we don't find a movie, try to match partial name and year
|
||||||
if (!movie) {
|
if (!movie) {
|
||||||
movie = data.movies.find(
|
movie = contentResults.hits.find(
|
||||||
(movie) => movie.year === year && movie.name.includes(name)
|
(movie) =>
|
||||||
|
movie.releaseYear === year.toString() && movie.title.includes(name)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we still dont find a movie, try to match just on year
|
// If we still dont find a movie, try to match just on year
|
||||||
if (!movie) {
|
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
|
// One last try, try exact name match only
|
||||||
if (!movie) {
|
if (!movie) {
|
||||||
movie = data.movies.find((movie) => movie.name === name);
|
movie = contentResults.hits.find((movie) => movie.title === name);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!movie) {
|
if (!movie) {
|
||||||
@@ -108,16 +139,15 @@ class RottenTomatoes extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: movie.name,
|
title: movie.title,
|
||||||
url: `https://www.rottentomatoes.com${movie.url}`,
|
url: `https://www.rottentomatoes.com/m/${movie.vanity}`,
|
||||||
criticsRating:
|
criticsRating: movie.rottenTomatoes.certifiedFresh
|
||||||
movie.meterClass === 'certified_fresh'
|
? 'Certified Fresh'
|
||||||
? 'Certified Fresh'
|
: movie.rottenTomatoes.criticsScore >= 60
|
||||||
: movie.meterClass === 'fresh'
|
? 'Fresh'
|
||||||
? 'Fresh'
|
: 'Rotten',
|
||||||
: 'Rotten',
|
criticsScore: movie.rottenTomatoes.criticsScore,
|
||||||
criticsScore: movie.meterScore,
|
year: Number(movie.releaseYear),
|
||||||
year: movie.year,
|
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -131,14 +161,28 @@ class RottenTomatoes extends ExternalAPI {
|
|||||||
year?: number
|
year?: number
|
||||||
): Promise<RTRating | null> {
|
): Promise<RTRating | null> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
|
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
|
||||||
params: { q: name, limit: 10 },
|
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) {
|
if (year) {
|
||||||
tvshow = data.tvSeries.find((series) => series.startYear === year);
|
tvshow = contentResults.hits.find(
|
||||||
|
(series) => series.releaseYear === year.toString()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tvshow) {
|
if (!tvshow) {
|
||||||
@@ -147,10 +191,11 @@ class RottenTomatoes extends ExternalAPI {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
title: tvshow.title,
|
title: tvshow.title,
|
||||||
url: `https://www.rottentomatoes.com${tvshow.url}`,
|
url: `https://www.rottentomatoes.com/tv/${tvshow.vanity}`,
|
||||||
criticsRating: tvshow.meterClass === 'fresh' ? 'Fresh' : 'Rotten',
|
criticsRating:
|
||||||
criticsScore: tvshow.meterScore,
|
tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten',
|
||||||
year: tvshow.startYear,
|
criticsScore: tvshow.rottenTomatoes.criticsScore,
|
||||||
|
year: Number(tvshow.releaseYear),
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`);
|
throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -335,6 +335,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"
|
||||||
@@ -354,6 +356,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
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const messages = defineMessages({
|
|||||||
editrequest: 'Edit Request',
|
editrequest: 'Edit Request',
|
||||||
cancelrequest: 'Cancel Request',
|
cancelrequest: 'Cancel Request',
|
||||||
deleterequest: 'Delete Request',
|
deleterequest: 'Delete Request',
|
||||||
|
unknowntitle: 'Unknown Title',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||||
@@ -136,6 +137,14 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
|||||||
requestData.is4k ? 'status4k' : 'status'
|
requestData.is4k ? 'status4k' : 'status'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
downloadItem={
|
||||||
|
requestData.media[
|
||||||
|
requestData.is4k
|
||||||
|
? 'downloadStatus4k'
|
||||||
|
: 'downloadStatus'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
title={intl.formatMessage(messages.unknowntitle)}
|
||||||
inProgress={
|
inProgress={
|
||||||
(
|
(
|
||||||
requestData.media[
|
requestData.media[
|
||||||
@@ -146,6 +155,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
|||||||
).length > 0
|
).length > 0
|
||||||
}
|
}
|
||||||
is4k={requestData.is4k}
|
is4k={requestData.is4k}
|
||||||
|
mediaType={requestData.type}
|
||||||
plexUrl={requestData.is4k ? plexUrl4k : plexUrl}
|
plexUrl={requestData.is4k ? plexUrl4k : plexUrl}
|
||||||
serviceUrl={
|
serviceUrl={
|
||||||
requestData.is4k
|
requestData.is4k
|
||||||
@@ -397,6 +407,12 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
status={
|
status={
|
||||||
requestData.media[requestData.is4k ? 'status4k' : 'status']
|
requestData.media[requestData.is4k ? 'status4k' : 'status']
|
||||||
}
|
}
|
||||||
|
downloadItem={
|
||||||
|
requestData.media[
|
||||||
|
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
title={isMovie(title) ? title.title : title.name}
|
||||||
inProgress={
|
inProgress={
|
||||||
(
|
(
|
||||||
requestData.media[
|
requestData.media[
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const messages = defineMessages({
|
|||||||
cancelRequest: 'Cancel Request',
|
cancelRequest: 'Cancel Request',
|
||||||
tmdbid: 'TMDB ID',
|
tmdbid: 'TMDB ID',
|
||||||
tvdbid: 'TheTVDB ID',
|
tvdbid: 'TheTVDB ID',
|
||||||
|
unknowntitle: 'Unknown Title',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||||
@@ -128,6 +129,12 @@ const RequestItemError = ({
|
|||||||
requestData.is4k ? 'status4k' : 'status'
|
requestData.is4k ? 'status4k' : 'status'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
downloadItem={
|
||||||
|
requestData.media[
|
||||||
|
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
title={intl.formatMessage(messages.unknowntitle)}
|
||||||
inProgress={
|
inProgress={
|
||||||
(
|
(
|
||||||
requestData.media[
|
requestData.media[
|
||||||
@@ -138,6 +145,7 @@ const RequestItemError = ({
|
|||||||
).length > 0
|
).length > 0
|
||||||
}
|
}
|
||||||
is4k={requestData.is4k}
|
is4k={requestData.is4k}
|
||||||
|
mediaType={requestData.type}
|
||||||
plexUrl={requestData.is4k ? plexUrl4k : plexUrl}
|
plexUrl={requestData.is4k ? plexUrl4k : plexUrl}
|
||||||
serviceUrl={
|
serviceUrl={
|
||||||
requestData.is4k
|
requestData.is4k
|
||||||
@@ -463,6 +471,12 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
status={
|
status={
|
||||||
requestData.media[requestData.is4k ? 'status4k' : 'status']
|
requestData.media[requestData.is4k ? 'status4k' : 'status']
|
||||||
}
|
}
|
||||||
|
downloadItem={
|
||||||
|
requestData.media[
|
||||||
|
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
title={isMovie(title) ? title.title : title.name}
|
||||||
inProgress={
|
inProgress={
|
||||||
(
|
(
|
||||||
requestData.media[
|
requestData.media[
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
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 { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||||
import getConfig from 'next/config';
|
import getConfig from 'next/config';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
@@ -15,26 +17,31 @@ const messages = defineMessages({
|
|||||||
playonplex: 'Play on {mediaServerName}',
|
playonplex: 'Play on {mediaServerName}',
|
||||||
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();
|
||||||
@@ -44,6 +51,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 &&
|
||||||
@@ -95,21 +106,87 @@ const StatusBadge = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tooltipContent = (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 : mediaLinkDescription}
|
||||||
<div className="flex items-center">
|
className={`${
|
||||||
|
inProgress && 'hidden max-h-96 w-96 overflow-y-auto sm:block'
|
||||||
|
}`}
|
||||||
|
tooltipConfig={{
|
||||||
|
...(inProgress && { 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>
|
||||||
@@ -117,20 +194,52 @@ const StatusBadge = ({
|
|||||||
|
|
||||||
case MediaStatus.PARTIALLY_AVAILABLE:
|
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||||
return (
|
return (
|
||||||
<Tooltip content={mediaLinkDescription}>
|
<Tooltip
|
||||||
<Badge badgeType="success" href={mediaLink}>
|
content={inProgress ? tooltipContent : mediaLinkDescription}
|
||||||
<div className="flex items-center">
|
className={`${
|
||||||
|
inProgress && 'hidden max-h-96 w-96 overflow-y-auto sm:block'
|
||||||
|
}`}
|
||||||
|
tooltipConfig={{
|
||||||
|
...(inProgress && { 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>
|
||||||
@@ -138,9 +247,29 @@ const StatusBadge = ({
|
|||||||
|
|
||||||
case MediaStatus.PROCESSING:
|
case MediaStatus.PROCESSING:
|
||||||
return (
|
return (
|
||||||
<Tooltip content={mediaLinkDescription}>
|
<Tooltip
|
||||||
<Badge badgeType="primary" href={mediaLink}>
|
content={inProgress ? tooltipContent : mediaLinkDescription}
|
||||||
<div className="flex items-center">
|
className={`${
|
||||||
|
inProgress && 'hidden max-h-96 w-96 overflow-y-auto sm:block'
|
||||||
|
}`}
|
||||||
|
tooltipConfig={{
|
||||||
|
...(inProgress && { 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,
|
||||||
@@ -151,7 +280,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>
|
||||||
|
|||||||
@@ -350,6 +350,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"
|
||||||
@@ -369,6 +371,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
|
||||||
|
|||||||
@@ -35,6 +35,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",
|
||||||
@@ -332,6 +333,7 @@
|
|||||||
"components.RequestCard.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
|
"components.RequestCard.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
|
||||||
"components.RequestCard.tmdbid": "TMDB ID",
|
"components.RequestCard.tmdbid": "TMDB ID",
|
||||||
"components.RequestCard.tvdbid": "TheTVDB ID",
|
"components.RequestCard.tvdbid": "TheTVDB ID",
|
||||||
|
"components.RequestCard.unknowntitle": "Unknown Title",
|
||||||
"components.RequestList.RequestItem.cancelRequest": "Cancel Request",
|
"components.RequestList.RequestItem.cancelRequest": "Cancel Request",
|
||||||
"components.RequestList.RequestItem.deleterequest": "Delete Request",
|
"components.RequestList.RequestItem.deleterequest": "Delete Request",
|
||||||
"components.RequestList.RequestItem.editrequest": "Edit Request",
|
"components.RequestList.RequestItem.editrequest": "Edit Request",
|
||||||
@@ -344,6 +346,7 @@
|
|||||||
"components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
|
"components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
|
||||||
"components.RequestList.RequestItem.tmdbid": "TMDB ID",
|
"components.RequestList.RequestItem.tmdbid": "TMDB ID",
|
||||||
"components.RequestList.RequestItem.tvdbid": "TheTVDB ID",
|
"components.RequestList.RequestItem.tvdbid": "TheTVDB ID",
|
||||||
|
"components.RequestList.RequestItem.unknowntitle": "Unknown Title",
|
||||||
"components.RequestList.requests": "Requests",
|
"components.RequestList.requests": "Requests",
|
||||||
"components.RequestList.showallrequests": "Show All Requests",
|
"components.RequestList.showallrequests": "Show All Requests",
|
||||||
"components.RequestList.sortAdded": "Most Recent",
|
"components.RequestList.sortAdded": "Most Recent",
|
||||||
@@ -880,6 +883,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 {mediaServerName}",
|
"components.StatusBadge.playonplex": "Play on {mediaServerName}",
|
||||||
|
"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.StatusChacker.newversionDescription": "Jellyseerr has been updated! Please click the button below to reload the page.",
|
"components.StatusChacker.newversionDescription": "Jellyseerr has been updated! Please click the button below to reload the page.",
|
||||||
|
|||||||
Reference in New Issue
Block a user