Merge remote-tracking branch 'overseerr/develop' into develop

This commit is contained in:
notfakie
2022-09-01 18:11:15 +12:00
473 changed files with 15548 additions and 8433 deletions

View File

@@ -1,3 +1,33 @@
import RTAudFresh from '@app/assets/rt_aud_fresh.svg';
import RTAudRotten from '@app/assets/rt_aud_rotten.svg';
import RTFresh from '@app/assets/rt_fresh.svg';
import RTRotten from '@app/assets/rt_rotten.svg';
import TmdbLogo from '@app/assets/tmdb_logo.svg';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import type { PlayButtonLink } from '@app/components/Common/PlayButton';
import PlayButton from '@app/components/Common/PlayButton';
import Tooltip from '@app/components/Common/Tooltip';
import ExternalLinkBlock from '@app/components/ExternalLinkBlock';
import IssueModal from '@app/components/IssueModal';
import ManageSlideOver from '@app/components/ManageSlideOver';
import MediaSlider from '@app/components/MediaSlider';
import PersonCard from '@app/components/PersonCard';
import RequestButton from '@app/components/RequestButton';
import RequestModal from '@app/components/RequestModal';
import Slider from '@app/components/Slider';
import StatusBadge from '@app/components/StatusBadge';
import Season from '@app/components/TvDetails/Season';
import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { sortCrewPriority } from '@app/utils/creditHelpers';
import { Disclosure, Transition } from '@headlessui/react';
import {
ArrowCircleRightIcon,
CogIcon,
@@ -5,46 +35,22 @@ import {
FilmIcon,
PlayIcon,
} from '@heroicons/react/outline';
import { ChevronUpIcon } from '@heroicons/react/solid';
import type { RTRating } from '@server/api/rottentomatoes';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import { IssueStatus } from '@server/constants/issue';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import type { Crew } from '@server/models/common';
import type { TvDetails as TvDetailsType } from '@server/models/Tv';
import { hasFlag } from 'country-flag-icons';
import 'country-flag-icons/3x2/flags.css';
import getConfig from 'next/config';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { RTRating } from '../../../server/api/rottentomatoes';
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb/constants';
import { IssueStatus } from '../../../server/constants/issue';
import { MediaStatus } from '../../../server/constants/media';
import { MediaServerType } from '../../../server/constants/server';
import { Crew } from '../../../server/models/common';
import { TvDetails as TvDetailsType } from '../../../server/models/Tv';
import RTAudFresh from '../../assets/rt_aud_fresh.svg';
import RTAudRotten from '../../assets/rt_aud_rotten.svg';
import RTFresh from '../../assets/rt_fresh.svg';
import RTRotten from '../../assets/rt_rotten.svg';
import TmdbLogo from '../../assets/tmdb_logo.svg';
import useLocale from '../../hooks/useLocale';
import useSettings from '../../hooks/useSettings';
import { Permission, useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Error from '../../pages/_error';
import { sortCrewPriority } from '../../utils/creditHelpers';
import Button from '../Common/Button';
import CachedImage from '../Common/CachedImage';
import LoadingSpinner from '../Common/LoadingSpinner';
import PageTitle from '../Common/PageTitle';
import PlayButton, { PlayButtonLink } from '../Common/PlayButton';
import ExternalLinkBlock from '../ExternalLinkBlock';
import IssueModal from '../IssueModal';
import ManageSlideOver from '../ManageSlideOver';
import MediaSlider from '../MediaSlider';
import PersonCard from '../PersonCard';
import RequestButton from '../RequestButton';
import RequestModal from '../RequestModal';
import Slider from '../Slider';
import StatusBadge from '../StatusBadge';
import getConfig from 'next/config';
const messages = defineMessages({
firstAirDate: 'First Air Date',
@@ -69,13 +75,22 @@ const messages = defineMessages({
streamingproviders: 'Currently Streaming On',
productioncountries:
'Production {countryCount, plural, one {Country} other {Countries}}',
reportissue: 'Report an Issue',
manageseries: 'Manage Series',
seasonstitle: 'Seasons',
episodeCount: '{episodeCount, plural, one {# Episode} other {# Episodes}}',
seasonnumber: 'Season {seasonNumber}',
status4k: '4K {status}',
rtcriticsscore: 'Rotten Tomatoes Tomatometer',
rtaudiencescore: 'Rotten Tomatoes Audience Score',
tmdbuserscore: 'TMDB User Score',
});
interface TvDetailsProps {
tv?: TvDetailsType;
}
const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
const TvDetails = ({ tv }: TvDetailsProps) => {
const settings = useSettings();
const { user, hasPermission } = useUser();
const router = useRouter();
@@ -109,6 +124,32 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
setShowManager(router.query.manage == '1' ? true : false);
}, [router.query.manage]);
const [plexUrl, setPlexUrl] = useState(data?.mediaInfo?.mediaUrl);
const [plexUrl4k, setPlexUrl4k] = useState(data?.mediaInfo?.mediaUrl4k);
useEffect(() => {
if (data) {
if (
settings.currentSettings.mediaServerType === MediaServerType.PLEX &&
(/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.userAgent === 'MacIntel' && navigator.maxTouchPoints > 1))
) {
setPlexUrl(data.mediaInfo?.iOSPlexUrl);
setPlexUrl4k(data.mediaInfo?.iOSPlexUrl4k);
} else {
setPlexUrl(data.mediaInfo?.mediaUrl);
setPlexUrl4k(data.mediaInfo?.mediaUrl4k);
}
}
}, [
data,
data?.mediaInfo?.iOSPlexUrl,
data?.mediaInfo?.iOSPlexUrl4k,
data?.mediaInfo?.mediaUrl,
data?.mediaInfo?.mediaUrl4k,
settings.currentSettings.mediaServerType,
]);
if (!data && !error) {
return <LoadingSpinner />;
}
@@ -120,28 +161,28 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
const mediaLinks: PlayButtonLink[] = [];
if (
data.mediaInfo?.mediaUrl &&
plexUrl &&
hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
type: 'or',
})
) {
mediaLinks.push({
text: getAvalaibleMediaServerName(),
url: data.mediaInfo?.mediaUrl,
url: plexUrl,
svg: <PlayIcon />,
});
}
if (
settings.currentSettings.series4kEnabled &&
data.mediaInfo?.mediaUrl4k &&
plexUrl4k &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
type: 'or',
})
) {
mediaLinks.push({
text: getAvalaible4kMediaServerName(),
url: data.mediaInfo?.mediaUrl4k,
url: plexUrl4k,
svg: <PlayIcon />,
});
}
@@ -208,7 +249,9 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
seasonCount <=
(
data.mediaInfo?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
(season) =>
season.status === MediaStatus.AVAILABLE ||
season.status === MediaStatus.PARTIALLY_AVAILABLE
) ?? []
).length;
@@ -216,7 +259,9 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
seasonCount <=
(
data.mediaInfo?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
(season) =>
season.status4k === MediaStatus.AVAILABLE ||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE
) ?? []
).length;
@@ -325,7 +370,8 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="tv"
plexUrl={data.mediaInfo?.mediaUrl}
plexUrl={plexUrl}
serviceUrl={data.mediaInfo?.serviceUrl}
/>
{settings.currentSettings.series4kEnabled &&
hasPermission(
@@ -346,11 +392,12 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="tv"
plexUrl={data.mediaInfo?.mediaUrl4k}
plexUrl={plexUrl4k}
serviceUrl={data.mediaInfo?.serviceUrl4k}
/>
)}
</div>
<h1>
<h1 data-testid="media-title">
{data.name}{' '}
{data.firstAirDate && (
<span className="media-year">
@@ -396,38 +443,42 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
type: 'or',
}
) && (
<Button
buttonType="warning"
className="ml-2 first:ml-0"
onClick={() => setShowIssueModal(true)}
>
<ExclamationIcon className="w-5" />
</Button>
<Tooltip content={intl.formatMessage(messages.reportissue)}>
<Button
buttonType="warning"
onClick={() => setShowIssueModal(true)}
className="ml-2 first:ml-0"
>
<ExclamationIcon />
</Button>
</Tooltip>
)}
{hasPermission(Permission.MANAGE_REQUESTS) && data.mediaInfo && (
<Button
buttonType="default"
className="relative ml-2 first:ml-0"
onClick={() => setShowManager(true)}
>
<CogIcon className="!mr-0" />
{hasPermission(
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
{
type: 'or',
}
) &&
(
data.mediaInfo?.issues.filter(
(issue) => issue.status === IssueStatus.OPEN
) ?? []
).length > 0 && (
<>
<div className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-red-600" />
<div className="absolute -right-1 -top-1 h-3 w-3 animate-ping rounded-full bg-red-600" />
</>
)}
</Button>
<Tooltip content={intl.formatMessage(messages.manageseries)}>
<Button
buttonType="ghost"
onClick={() => setShowManager(true)}
className="relative ml-2 first:ml-0"
>
<CogIcon className="!mr-0" />
{hasPermission(
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
{
type: 'or',
}
) &&
(
data.mediaInfo?.issues.filter(
(issue) => issue.status === IssueStatus.OPEN
) ?? []
).length > 0 && (
<>
<div className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-red-600" />
<div className="absolute -right-1 -top-1 h-3 w-3 animate-ping rounded-full bg-red-600" />
</>
)}
</Button>
</Tooltip>
)}
</div>
</div>
@@ -476,6 +527,174 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</div>
</>
)}
<h2 className="py-4">{intl.formatMessage(messages.seasonstitle)}</h2>
<div className="flex w-full flex-col space-y-2">
{data.seasons
.slice()
.reverse()
.filter((season) => season.seasonNumber !== 0)
.map((season) => {
const show4k =
settings.currentSettings.series4kEnabled &&
hasPermission(
[
Permission.MANAGE_REQUESTS,
Permission.REQUEST_4K,
Permission.REQUEST_4K_TV,
],
{
type: 'or',
}
);
const mSeason = (data.mediaInfo?.seasons ?? []).find(
(s) =>
season.seasonNumber === s.seasonNumber &&
s.status !== MediaStatus.UNKNOWN
);
const mSeason4k = (data.mediaInfo?.seasons ?? []).find(
(s) =>
season.seasonNumber === s.seasonNumber &&
s.status4k !== MediaStatus.UNKNOWN
);
const request = (data.mediaInfo?.requests ?? []).find(
(r) =>
!!r.seasons.find(
(s) => s.seasonNumber === season.seasonNumber
) && !r.is4k
);
const request4k = (data.mediaInfo?.requests ?? []).find(
(r) =>
!!r.seasons.find(
(s) => s.seasonNumber === season.seasonNumber
) && r.is4k
);
return (
<Disclosure key={`season-discoslure-${season.seasonNumber}`}>
{({ open }) => (
<>
<Disclosure.Button
className={`mt-2 flex w-full items-center justify-between space-x-2 border-gray-700 bg-gray-800 px-4 py-2 text-gray-200 ${
open
? 'rounded-t-md border-t border-l border-r'
: 'rounded-md border'
}`}
>
<div className="flex flex-1 items-center space-x-2 text-lg">
<span>
{intl.formatMessage(messages.seasonnumber, {
seasonNumber: season.seasonNumber,
})}
</span>
<Badge badgeType="dark">
{intl.formatMessage(messages.episodeCount, {
episodeCount: season.episodeCount,
})}
</Badge>
</div>
{((!mSeason &&
request?.status === MediaRequestStatus.APPROVED) ||
mSeason?.status === MediaStatus.PROCESSING) && (
<Badge badgeType="primary">
{intl.formatMessage(globalMessages.requested)}
</Badge>
)}
{((!mSeason &&
request?.status === MediaRequestStatus.PENDING) ||
mSeason?.status === MediaStatus.PENDING) && (
<Badge badgeType="warning">
{intl.formatMessage(globalMessages.pending)}
</Badge>
)}
{mSeason?.status ===
MediaStatus.PARTIALLY_AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(
globalMessages.partiallyavailable
)}
</Badge>
)}
{mSeason?.status === MediaStatus.AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
</Badge>
)}
{((!mSeason4k &&
request4k?.status ===
MediaRequestStatus.APPROVED) ||
mSeason4k?.status4k === MediaStatus.PROCESSING) &&
show4k && (
<Badge badgeType="primary">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(
globalMessages.requested
),
})}
</Badge>
)}
{((!mSeason4k &&
request4k?.status === MediaRequestStatus.PENDING) ||
mSeason?.status4k === MediaStatus.PENDING) &&
show4k && (
<Badge badgeType="warning">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(
globalMessages.pending
),
})}
</Badge>
)}
{mSeason4k?.status4k ===
MediaStatus.PARTIALLY_AVAILABLE &&
show4k && (
<Badge badgeType="success">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(
globalMessages.partiallyavailable
),
})}
</Badge>
)}
{mSeason4k?.status4k === MediaStatus.AVAILABLE &&
show4k && (
<Badge badgeType="success">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(
globalMessages.available
),
})}
</Badge>
)}
<ChevronUpIcon
className={`${
open ? 'rotate-180 transform' : ''
} h-6 w-6 text-gray-500`}
/>
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
// Not sure why this transition is adding a margin without this here
style={{ margin: '0px' }}
>
<Disclosure.Panel className="w-full rounded-b-md border-b border-l border-r border-gray-700 px-4 pb-2">
<Season
tvId={data.id}
seasonNumber={season.seasonNumber}
/>
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
);
})}
</div>
</div>
<div className="media-overview-right">
<div className="media-facts">
@@ -484,30 +703,55 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
(ratingData?.audienceRating && !!ratingData?.audienceScore)) && (
<div className="media-ratings">
{ratingData?.criticsRating && !!ratingData?.criticsScore && (
<span className="media-rating">
{ratingData.criticsRating === 'Rotten' ? (
<RTRotten className="mr-1 w-6" />
) : (
<RTFresh className="mr-1 w-6" />
)}
{ratingData.criticsScore}%
</span>
<Tooltip
content={intl.formatMessage(messages.rtcriticsscore)}
>
<a
href={ratingData.url}
className="media-rating"
target="_blank"
rel="noreferrer"
>
{ratingData.criticsRating === 'Rotten' ? (
<RTRotten className="mr-1 w-6" />
) : (
<RTFresh className="mr-1 w-6" />
)}
<span>{ratingData.criticsScore}%</span>
</a>
</Tooltip>
)}
{ratingData?.audienceRating && !!ratingData?.audienceScore && (
<span className="media-rating">
{ratingData.audienceRating === 'Spilled' ? (
<RTAudRotten className="mr-1 w-6" />
) : (
<RTAudFresh className="mr-1 w-6" />
)}
{ratingData.audienceScore}%
</span>
<Tooltip
content={intl.formatMessage(messages.rtaudiencescore)}
>
<a
href={ratingData.url}
className="media-rating"
target="_blank"
rel="noreferrer"
>
{ratingData.audienceRating === 'Spilled' ? (
<RTAudRotten className="mr-1 w-6" />
) : (
<RTAudFresh className="mr-1 w-6" />
)}
<span>{ratingData.audienceScore}%</span>
</a>
</Tooltip>
)}
{!!data.voteCount && (
<span className="media-rating">
<TmdbLogo className="mr-2 w-6" />
{data.voteAverage}/10
</span>
<Tooltip content={intl.formatMessage(messages.tmdbuserscore)}>
<a
href={`https://www.themoviedb.org/tv/${data.id}?language=${locale}`}
className="media-rating"
target="_blank"
rel="noreferrer"
>
<TmdbLogo className="mr-1 w-6" />
<span>{Math.round(data.voteAverage * 10)}%</span>
</a>
</Tooltip>
)}
</div>
)}