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 StatusBadgeMini from '@app/components/Common/StatusBadgeMini'; import Tag from '@app/components/Common/Tag'; 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 useDeepLinks from '@app/hooks/useDeepLinks'; 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 { ArrowRightCircleIcon, CogIcon, ExclamationTriangleIcon, FilmIcon, PlayIcon, } from '@heroicons/react/24/outline'; import { ChevronDownIcon } from '@heroicons/react/24/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 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 Link from 'next/link'; import { useRouter } from 'next/router'; import { useEffect, useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; const messages = defineMessages({ firstAirDate: 'First Air Date', nextAirDate: 'Next Air Date', originallanguage: 'Original Language', overview: 'Overview', cast: 'Cast', recommendations: 'Recommendations', similar: 'Similar Series', watchtrailer: 'Watch Trailer', overviewunavailable: 'Overview unavailable.', originaltitle: 'Original Title', showtype: 'Series Type', anime: 'Anime', network: '{networkCount, plural, one {Network} other {Networks}}', viewfullcrew: 'View Full Crew', playonplex: 'Play on Plex', play4konplex: 'Play in 4K on Plex', seasons: '{seasonCount, plural, one {# Season} other {# Seasons}}', episodeRuntime: 'Episode Runtime', episodeRuntimeMinutes: '{runtime} minutes', 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 = ({ tv }: TvDetailsProps) => { const settings = useSettings(); const { user, hasPermission } = useUser(); const router = useRouter(); const intl = useIntl(); const { locale } = useLocale(); const [showRequestModal, setShowRequestModal] = useState(false); const [showManager, setShowManager] = useState( router.query.manage == '1' ? true : false ); const [showIssueModal, setShowIssueModal] = useState(false); const { data, error, mutate: revalidate, } = useSWR(`/api/v1/tv/${router.query.tvId}`, { fallbackData: tv, }); const { data: ratingData } = useSWR( `/api/v1/tv/${router.query.tvId}/ratings` ); const sortedCrew = useMemo( () => sortCrewPriority(data?.credits.crew ?? []), [data] ); useEffect(() => { setShowManager(router.query.manage == '1' ? true : false); }, [router.query.manage]); const { plexUrl, plexUrl4k } = useDeepLinks({ plexUrl: data?.mediaInfo?.plexUrl, plexUrl4k: data?.mediaInfo?.plexUrl4k, iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl, iOSPlexUrl4k: data?.mediaInfo?.iOSPlexUrl4k, }); if (!data && !error) { return ; } if (!data) { return ; } const mediaLinks: PlayButtonLink[] = []; if (plexUrl) { mediaLinks.push({ text: intl.formatMessage(messages.playonplex), url: plexUrl, svg: , }); } if ( settings.currentSettings.series4kEnabled && plexUrl4k && hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], { type: 'or', }) ) { mediaLinks.push({ text: intl.formatMessage(messages.play4konplex), url: plexUrl4k, svg: , }); } const trailerUrl = data.relatedVideos ?.filter((r) => r.type === 'Trailer') .sort((a, b) => a.size - b.size) .pop()?.url; if (trailerUrl) { mediaLinks.push({ text: intl.formatMessage(messages.watchtrailer), url: trailerUrl, svg: , }); } const region = user?.settings?.region ? user.settings.region : settings.currentSettings.region ? settings.currentSettings.region : 'US'; const seriesAttributes: React.ReactNode[] = []; const contentRating = data.contentRatings.results.find( (r) => r.iso_3166_1 === region )?.rating; if (contentRating) { seriesAttributes.push( {contentRating} ); } const seasonCount = data.seasons.filter( (season) => season.seasonNumber !== 0 && season.episodeCount !== 0 ).length; if (seasonCount) { seriesAttributes.push( intl.formatMessage(messages.seasons, { seasonCount: seasonCount }) ); } if (data.genres.length) { seriesAttributes.push( data.genres .map((g) => ( {g.name} )) .reduce((prev, curr) => ( <> {intl.formatMessage(globalMessages.delimitedlist, { a: prev, b: curr, })} )) ); } const getAllRequestedSeasons = (is4k: boolean): number[] => { const requestedSeasons = (data?.mediaInfo?.requests ?? []) .filter( (request) => request.is4k === is4k && request.status !== MediaRequestStatus.DECLINED ) .reduce((requestedSeasons, request) => { return [ ...requestedSeasons, ...request.seasons.map((sr) => sr.seasonNumber), ]; }, [] as number[]); const availableSeasons = (data?.mediaInfo?.seasons ?? []) .filter( (season) => (season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || season[is4k ? 'status4k' : 'status'] === MediaStatus.PARTIALLY_AVAILABLE || season[is4k ? 'status4k' : 'status'] === MediaStatus.PROCESSING) && !requestedSeasons.includes(season.seasonNumber) ) .map((season) => season.seasonNumber); return [...requestedSeasons, ...availableSeasons]; }; const isComplete = seasonCount <= getAllRequestedSeasons(false).length; const is4kComplete = seasonCount <= getAllRequestedSeasons(true).length; const streamingProviders = data?.watchProviders?.find((provider) => provider.iso_3166_1 === region) ?.flatrate ?? []; return (
{data.backdropPath && (
)} setShowIssueModal(false)} show={showIssueModal} mediaType="tv" tmdbId={data.id} /> { revalidate(); setShowRequestModal(false); }} onCancel={() => setShowRequestModal(false)} /> { setShowManager(false); router.push({ pathname: router.pathname, query: { tvId: router.query.tvId }, }); }} revalidate={() => revalidate()} show={showManager} />
0} tmdbId={data.mediaInfo?.tmdbId} mediaType="tv" plexUrl={plexUrl} serviceUrl={data.mediaInfo?.serviceUrl} /> {settings.currentSettings.series4kEnabled && hasPermission( [ Permission.MANAGE_REQUESTS, Permission.REQUEST_4K, Permission.REQUEST_4K_TV, ], { type: 'or', } ) && ( 0 } tmdbId={data.mediaInfo?.tmdbId} mediaType="tv" plexUrl={plexUrl4k} serviceUrl={data.mediaInfo?.serviceUrl4k} /> )}

{data.name}{' '} {data.firstAirDate && ( ({data.firstAirDate.slice(0, 4)}) )}

{seriesAttributes.length > 0 && seriesAttributes .map((t, k) => {t}) .reduce((prev, curr) => ( <> {prev} | {curr} ))}
revalidate()} tmdbId={data?.id} media={data?.mediaInfo} isShowComplete={isComplete} is4kShowComplete={is4kComplete} /> {(data.mediaInfo?.status === MediaStatus.AVAILABLE || data.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE || (settings.currentSettings.series4kEnabled && hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], { type: 'or', }) && (data.mediaInfo?.status4k === MediaStatus.AVAILABLE || data?.mediaInfo?.status4k === MediaStatus.PARTIALLY_AVAILABLE))) && hasPermission( [Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES], { type: 'or', } ) && ( )} {hasPermission(Permission.MANAGE_REQUESTS) && data.mediaInfo && ( )}
{data.tagline &&
{data.tagline}
}

{intl.formatMessage(messages.overview)}

{data.overview ? data.overview : intl.formatMessage(messages.overviewunavailable)}

{sortedCrew.length > 0 && ( <>
    {(data.createdBy.length > 0 ? [ ...data.createdBy.map( (person): Partial => ({ id: person.id, job: 'Creator', name: person.name, }) ), ...sortedCrew, ] : sortedCrew ) .slice(0, 6) .map((person) => (
  • {person.job} {person.name}
  • ))}
)} {data.keywords.length > 0 && (
{data.keywords.map((keyword) => ( {keyword.name} ))}
)}

{intl.formatMessage(messages.seasonstitle)}

{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 ); if (season.episodeCount === 0) { return null; } return ( {({ open }) => ( <>
{intl.formatMessage(messages.seasonnumber, { seasonNumber: season.seasonNumber, })} {intl.formatMessage(messages.episodeCount, { episodeCount: season.episodeCount, })}
{((!mSeason && request?.status === MediaRequestStatus.APPROVED) || mSeason?.status === MediaStatus.PROCESSING) && ( <>
{intl.formatMessage(globalMessages.requested)}
)} {((!mSeason && request?.status === MediaRequestStatus.PENDING) || mSeason?.status === MediaStatus.PENDING) && ( <>
{intl.formatMessage(globalMessages.pending)}
)} {mSeason?.status === MediaStatus.PARTIALLY_AVAILABLE && ( <>
{intl.formatMessage( globalMessages.partiallyavailable )}
)} {mSeason?.status === MediaStatus.AVAILABLE && ( <>
{intl.formatMessage(globalMessages.available)}
)} {((!mSeason4k && request4k?.status === MediaRequestStatus.APPROVED) || mSeason4k?.status4k === MediaStatus.PROCESSING) && show4k && ( <>
{intl.formatMessage(messages.status4k, { status: intl.formatMessage( globalMessages.requested ), })}
)} {((!mSeason4k && request4k?.status === MediaRequestStatus.PENDING) || mSeason?.status4k === MediaStatus.PENDING) && show4k && ( <>
{intl.formatMessage(messages.status4k, { status: intl.formatMessage( globalMessages.pending ), })}
)} {mSeason4k?.status4k === MediaStatus.PARTIALLY_AVAILABLE && show4k && ( <>
{intl.formatMessage(messages.status4k, { status: intl.formatMessage( globalMessages.partiallyavailable ), })}
)} {mSeason4k?.status4k === MediaStatus.AVAILABLE && show4k && ( <>
{intl.formatMessage(messages.status4k, { status: intl.formatMessage( globalMessages.available ), })}
)}
)}
); })}
{(!!data.voteCount || (ratingData?.criticsRating && !!ratingData?.criticsScore) || (ratingData?.audienceRating && !!ratingData?.audienceScore)) && (
{ratingData?.criticsRating && !!ratingData?.criticsScore && ( {ratingData.criticsRating === 'Rotten' ? ( ) : ( )} {ratingData.criticsScore}% )} {ratingData?.audienceRating && !!ratingData?.audienceScore && ( {ratingData.audienceRating === 'Spilled' ? ( ) : ( )} {ratingData.audienceScore}% )} {!!data.voteCount && ( {Math.round(data.voteAverage * 10)}% )}
)} {data.originalName && data.originalLanguage !== locale.slice(0, 2) && (
{intl.formatMessage(messages.originaltitle)} {data.originalName}
)} {data.keywords.some( (keyword) => keyword.id === ANIME_KEYWORD_ID ) && (
{intl.formatMessage(messages.showtype)} {intl.formatMessage(messages.anime)}
)}
{intl.formatMessage(globalMessages.status)} {data.status}
{data.firstAirDate && (
{intl.formatMessage(messages.firstAirDate)} {intl.formatDate(data.firstAirDate, { year: 'numeric', month: 'long', day: 'numeric', })}
)} {data.nextEpisodeToAir && data.nextEpisodeToAir.airDate && data.nextEpisodeToAir.airDate !== data.firstAirDate && (
{intl.formatMessage(messages.nextAirDate)} {intl.formatDate(data.nextEpisodeToAir.airDate, { year: 'numeric', month: 'long', day: 'numeric', })}
)} {data.episodeRunTime.length > 0 && (
{intl.formatMessage(messages.episodeRuntime)} {intl.formatMessage(messages.episodeRuntimeMinutes, { runtime: data.episodeRunTime[0], })}
)} {data.originalLanguage && ( )} {data.productionCountries.length > 0 && (
{intl.formatMessage(messages.productioncountries, { countryCount: data.productionCountries.length, })} {data.productionCountries.map((c) => { return ( {hasFlag(c.iso_3166_1) && ( )} {intl.formatDisplayName(c.iso_3166_1, { type: 'region', fallback: 'none', }) ?? c.name} ); })}
)} {data.networks.length > 0 && (
{intl.formatMessage(messages.network, { networkCount: data.networks.length, })} {data.networks .map((n) => ( {n.name} )) .reduce((prev, curr) => ( <> {intl.formatMessage(globalMessages.delimitedlist, { a: prev, b: curr, })} ))}
)} {!!streamingProviders.length && (
{intl.formatMessage(messages.streamingproviders)} {streamingProviders.map((p) => { return ( {p.name} ); })}
)}
{data.credits.cast.length > 0 && ( <> ( ))} /> )}
); }; export default TvDetails;