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 Spinner from '@app/assets/spinner.svg'; import TmdbLogo from '@app/assets/tmdb_logo.svg'; import BlacklistModal from '@app/components/BlacklistModal'; 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, UserType, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import Error from '@app/pages/_error'; import { sortCrewPriority } from '@app/utils/creditHelpers'; import defineMessages from '@app/utils/defineMessages'; import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper'; import { Disclosure, Transition } from '@headlessui/react'; import { ArrowRightCircleIcon, CogIcon, ExclamationTriangleIcon, EyeSlashIcon, FilmIcon, MinusCircleIcon, PlayIcon, StarIcon, } from '@heroicons/react/24/outline'; import { ChevronDownIcon } from '@heroicons/react/24/solid'; import type { RTRating } from '@server/api/rating/rottentomatoes'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import { IssueStatus } from '@server/constants/issue'; import { MediaRequestStatus, MediaStatus, MediaType, } 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 { countries } from 'country-flag-icons'; import 'country-flag-icons/3x2/flags.css'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; const messages = defineMessages('components.TvDetails', { 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', play: 'Play on {mediaServerName}', play4k: 'Play 4K on {mediaServerName}', 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', watchlistSuccess: '{title} added to watchlist successfully!', watchlistDeleted: '{title} Removed from watchlist successfully!', watchlistError: 'Something went wrong. Please try again.', removefromwatchlist: 'Remove From Watchlist', addtowatchlist: 'Add To Watchlist', }); 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 [isUpdating, setIsUpdating] = useState(false); const [toggleWatchlist, setToggleWatchlist] = useState( !tv?.onUserWatchlist ); const [isBlacklistUpdating, setIsBlacklistUpdating] = useState(false); const [showBlacklistModal, setShowBlacklistModal] = useState(false); const { addToast } = useToasts(); const { data, error, mutate: revalidate, } = useSWR(`/api/v1/tv/${router.query.tvId}`, { fallbackData: tv, refreshInterval: refreshIntervalHelper( { downloadStatus: tv?.mediaInfo?.downloadStatus, downloadStatus4k: tv?.mediaInfo?.downloadStatus4k, }, 15000 ), }); 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 closeBlacklistModal = useCallback( () => setShowBlacklistModal(false), [] ); const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({ mediaUrl: data?.mediaInfo?.mediaUrl, mediaUrl4k: data?.mediaInfo?.mediaUrl4k, iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl, iOSPlexUrl4k: data?.mediaInfo?.iOSPlexUrl4k, }); if (!data && !error) { return ; } if (!data) { return ; } const mediaLinks: PlayButtonLink[] = []; if ( plexUrl && hasPermission([Permission.REQUEST, Permission.REQUEST_TV], { type: 'or', }) ) { mediaLinks.push({ text: getAvailableMediaServerName(), url: plexUrl, svg: , }); } if ( settings.currentSettings.series4kEnabled && plexUrl4k && hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], { type: 'or', }) ) { mediaLinks.push({ text: getAvailable4kMediaServerName(), 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 discoverRegion = user?.settings?.discoverRegion ? user.settings.discoverRegion : settings.currentSettings.discoverRegion ? settings.currentSettings.discoverRegion : 'US'; const seriesAttributes: React.ReactNode[] = []; const contentRating = data.contentRatings.results.find( (r) => r.iso_3166_1 === discoverRegion )?.rating; if (contentRating) { seriesAttributes.push( {contentRating} ); } // Does NOT include "Specials" 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 showHasSpecials = data.seasons.some( (season) => season.seasonNumber === 0 && settings.currentSettings.enableSpecialEpisodes ); const isComplete = (showHasSpecials ? seasonCount + 1 : seasonCount) <= getAllRequestedSeasons(false).length; const is4kComplete = (showHasSpecials ? seasonCount + 1 : seasonCount) <= getAllRequestedSeasons(true).length; const streamingRegion = user?.settings?.streamingRegion ? user.settings.streamingRegion : settings.currentSettings.streamingRegion ? settings.currentSettings.streamingRegion : 'US'; const streamingProviders = data?.watchProviders?.find( (provider) => provider.iso_3166_1 === streamingRegion )?.flatrate ?? []; function getAvailableMediaServerName() { if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) { return intl.formatMessage(messages.play, { mediaServerName: 'Emby' }); } if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) { return intl.formatMessage(messages.play, { mediaServerName: 'Plex' }); } return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' }); } function getAvailable4kMediaServerName() { if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) { return intl.formatMessage(messages.play, { mediaServerName: 'Emby' }); } if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) { return intl.formatMessage(messages.play4k, { mediaServerName: 'Plex' }); } return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' }); } const onClickWatchlistBtn = async (): Promise => { setIsUpdating(true); const res = await fetch('/api/v1/watchlist', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ tmdbId: tv?.id, mediaType: MediaType.TV, title: tv?.name, }), }); if (!res.ok) { addToast(intl.formatMessage(messages.watchlistError), { appearance: 'error', autoDismiss: true, }); setIsUpdating(false); return; } const data = await res.json(); if (data) { addToast( {intl.formatMessage(messages.watchlistSuccess, { title: tv?.name, strong: (msg: React.ReactNode) => {msg}, })} , { appearance: 'success', autoDismiss: true } ); } setIsUpdating(false); setToggleWatchlist((prevState) => !prevState); }; const onClickDeleteWatchlistBtn = async (): Promise => { setIsUpdating(true); const res = await fetch('/api/v1/watchlist/' + tv?.id, { method: 'DELETE', }); if (!res.ok) { addToast(intl.formatMessage(messages.watchlistError), { appearance: 'error', autoDismiss: true, }); setIsUpdating(false); return; } if (res.status === 204) { addToast( {intl.formatMessage(messages.watchlistDeleted, { title: tv?.name, strong: (msg: React.ReactNode) => {msg}, })} , { appearance: 'info', autoDismiss: true } ); setIsUpdating(false); setToggleWatchlist((prevState) => !prevState); } }; const onClickHideItemBtn = async (): Promise => { setIsBlacklistUpdating(true); const res = await fetch('/api/v1/blacklist', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ tmdbId: tv?.id, mediaType: 'tv', title: tv?.name, user: user?.id, }), }); if (res.status === 201) { addToast( {intl.formatMessage(globalMessages.blacklistSuccess, { title: tv?.name, strong: (msg: React.ReactNode) => {msg}, })} , { appearance: 'success', autoDismiss: true } ); revalidate(); } else if (res.status === 412) { addToast( {intl.formatMessage(globalMessages.blacklistDuplicateError, { title: tv?.name, strong: (msg: React.ReactNode) => {msg}, })} , { appearance: 'info', autoDismiss: true } ); } else { addToast(intl.formatMessage(globalMessages.blacklistError), { appearance: 'error', autoDismiss: true, }); } setIsBlacklistUpdating(false); closeBlacklistModal(); }; const showHideButton = hasPermission([Permission.MANAGE_BLACKLIST], { type: 'or', }); 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} ))}
{showHideButton && data?.mediaInfo?.status !== MediaStatus.PROCESSING && data?.mediaInfo?.status !== MediaStatus.AVAILABLE && data?.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE && data?.mediaInfo?.status !== MediaStatus.PENDING && data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && ( )} {data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && user?.userType !== UserType.PLEX && ( <> {toggleWatchlist ? ( ) : ( )} )}
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}
  • ))}
{intl.formatMessage(messages.viewfullcrew)}
)} {data.keywords.length > 0 && (
{data.keywords.map((keyword) => ( {keyword.name} ))}
)}

{intl.formatMessage(messages.seasonstitle)}

{data.seasons .slice() .reverse() .filter( (season) => settings.currentSettings.enableSpecialEpisodes || 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 }) => ( <>
{season.seasonNumber === 0 ? intl.formatMessage(globalMessages.specials) : 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', timeZone: 'UTC', })}
)} {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', timeZone: 'UTC', })}
)} {data.episodeRunTime.length > 0 && (
{intl.formatMessage(messages.episodeRuntime)} {intl.formatMessage(messages.episodeRuntimeMinutes, { runtime: data.episodeRunTime[0], })}
)} {data.originalLanguage && (
{intl.formatMessage(messages.originallanguage)} {intl.formatDisplayName(data.originalLanguage, { type: 'language', fallback: 'none', }) ?? data.spokenLanguages.find( (lng) => lng.iso_639_1 === data.originalLanguage )?.name}
)} {data.productionCountries.length > 0 && (
{intl.formatMessage(messages.productioncountries, { countryCount: data.productionCountries.length, })} {data.productionCountries.map((c) => { return ( {countries.includes(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 ( ); })}
)}
{data.credits.cast.length > 0 && ( <>
{intl.formatMessage(messages.cast)}
( ))} /> )}
); }; export default TvDetails;