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 ImdbLogo from '@app/assets/services/imdb.svg'; import Spinner from '@app/assets/spinner.svg'; import TmdbLogo from '@app/assets/tmdb_logo.svg'; import BlacklistModal from '@app/components/BlacklistModal'; 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 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 Slider from '@app/components/Slider'; import StatusBadge from '@app/components/StatusBadge'; 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 ErrorPage from '@app/pages/_error'; import { sortCrewPriority } from '@app/utils/creditHelpers'; import defineMessages from '@app/utils/defineMessages'; import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper'; import { ArrowRightCircleIcon, CloudIcon, CogIcon, ExclamationTriangleIcon, EyeSlashIcon, FilmIcon, MinusCircleIcon, PlayIcon, StarIcon, TicketIcon, } from '@heroicons/react/24/outline'; import { ChevronDoubleDownIcon, ChevronDoubleUpIcon, } from '@heroicons/react/24/solid'; import { type RatingResponse } from '@server/api/ratings'; import { IssueStatus } from '@server/constants/issue'; import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import type { MovieDetails as MovieDetailsType } from '@server/models/Movie'; import { countries } from 'country-flag-icons'; import 'country-flag-icons/3x2/flags.css'; import { uniqBy } from 'lodash'; 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.MovieDetails', { originaltitle: 'Original Title', releasedate: '{releaseCount, plural, one {Release Date} other {Release Dates}}', revenue: 'Revenue', budget: 'Budget', watchtrailer: 'Watch Trailer', originallanguage: 'Original Language', overview: 'Overview', runtime: '{minutes} minutes', cast: 'Cast', recommendations: 'Recommendations', similar: 'Similar Titles', overviewunavailable: 'Overview unavailable.', studio: '{studioCount, plural, one {Studio} other {Studios}}', viewfullcrew: 'View Full Crew', openradarr: 'Open Movie in Radarr', openradarr4k: 'Open Movie in 4K Radarr', downloadstatus: 'Download Status', play: 'Play on {mediaServerName}', play4k: 'Play 4K on {mediaServerName}', markavailable: 'Mark as Available', mark4kavailable: 'Mark as Available in 4K', showmore: 'Show More', showless: 'Show Less', streamingproviders: 'Currently Streaming On', productioncountries: 'Production {countryCount, plural, one {Country} other {Countries}}', theatricalrelease: 'Theatrical Release', digitalrelease: 'Digital Release', physicalrelease: 'Physical Release', reportissue: 'Report an Issue', managemovie: 'Manage Movie', rtcriticsscore: 'Rotten Tomatoes Tomatometer', rtaudiencescore: 'Rotten Tomatoes Audience Score', tmdbuserscore: 'TMDB User Score', imdbuserscore: 'IMDB 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 MovieDetailsProps { movie?: MovieDetailsType; } const MovieDetails = ({ movie }: MovieDetailsProps) => { const settings = useSettings(); const { user, hasPermission } = useUser(); const router = useRouter(); const intl = useIntl(); const { locale } = useLocale(); const [showManager, setShowManager] = useState( router.query.manage == '1' ? true : false ); const minStudios = 3; const [showMoreStudios, setShowMoreStudios] = useState(false); const [showIssueModal, setShowIssueModal] = useState(false); const [isUpdating, setIsUpdating] = useState(false); const [toggleWatchlist, setToggleWatchlist] = useState( !movie?.onUserWatchlist ); const [isBlacklistUpdating, setIsBlacklistUpdating] = useState(false); const [showBlacklistModal, setShowBlacklistModal] = useState(false); const { addToast } = useToasts(); const { data, error, mutate: revalidate, } = useSWR(`/api/v1/movie/${router.query.movieId}`, { fallbackData: movie, refreshInterval: refreshIntervalHelper( { downloadStatus: movie?.mediaInfo?.downloadStatus, downloadStatus4k: movie?.mediaInfo?.downloadStatus4k, }, 15000 ), }); const { data: ratingData } = useSWR( `/api/v1/movie/${router.query.movieId}/ratingscombined` ); 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 showAllStudios = data.productionCompanies.length <= minStudios + 1; const mediaLinks: PlayButtonLink[] = []; if ( plexUrl && hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], { type: 'or', }) ) { mediaLinks.push({ text: getAvailableMediaServerName(), url: plexUrl, svg: , }); } if ( settings.currentSettings.movie4kEnabled && plexUrl4k && hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], { 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 releases = data.releases.results.find( (r) => r.iso_3166_1 === discoverRegion )?.release_dates; // Release date types: // 1. Premiere // 2. Theatrical (limited) // 3. Theatrical // 4. Digital // 5. Physical // 6. TV const filteredReleases = uniqBy( releases?.filter((r) => r.type > 2 && r.type < 6), 'type' ); const movieAttributes: React.ReactNode[] = []; const certification = releases?.find((r) => r.certification)?.certification; if (certification) { movieAttributes.push( {certification} ); } if (data.runtime) { movieAttributes.push( intl.formatMessage(messages.runtime, { minutes: data.runtime }) ); } if (data.genres.length) { movieAttributes.push( data.genres .map((g) => ( {g.name} )) .reduce((prev, curr) => ( <> {intl.formatMessage(globalMessages.delimitedlist, { a: prev, b: curr, })} )) ); } 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.play4k, { 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: movie?.id, mediaType: MediaType.MOVIE, title: movie?.title, }), }); 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: movie?.title, strong: (msg: React.ReactNode) => {msg}, })} , { appearance: 'success', autoDismiss: true } ); } setIsUpdating(false); setToggleWatchlist((prevState) => !prevState); }; const onClickDeleteWatchlistBtn = async (): Promise => { setIsUpdating(true); try { const res = await fetch(`/api/v1/watchlist/${movie?.id}`, { method: 'DELETE', }); if (!res.ok) throw new Error(); if (res.status === 204) { addToast( {intl.formatMessage(messages.watchlistDeleted, { title: movie?.title, strong: (msg: React.ReactNode) => {msg}, })} , { appearance: 'info', autoDismiss: true } ); } } catch (e) { addToast(intl.formatMessage(messages.watchlistError), { appearance: 'error', autoDismiss: true, }); } finally { 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: movie?.id, mediaType: 'movie', title: movie?.title, user: user?.id, }), }); if (res.status === 201) { addToast( {intl.formatMessage(globalMessages.blacklistSuccess, { title: movie?.title, strong: (msg: React.ReactNode) => {msg}, })} , { appearance: 'success', autoDismiss: true } ); revalidate(); } else if (res.status === 412) { addToast( {intl.formatMessage(globalMessages.blacklistDuplicateError, { title: movie?.title, 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="movie" tmdbId={data.id} /> { setShowManager(false); router.push({ pathname: router.pathname, query: { movieId: router.query.movieId }, }); }} revalidate={() => revalidate()} show={showManager} />
0} tmdbId={data.mediaInfo?.tmdbId} mediaType="movie" plexUrl={plexUrl} serviceUrl={data.mediaInfo?.serviceUrl} /> {settings.currentSettings.movie4kEnabled && hasPermission( [ Permission.MANAGE_REQUESTS, Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE, ], { type: 'or', } ) && ( 0 } tmdbId={data.mediaInfo?.tmdbId} mediaType="movie" plexUrl={plexUrl4k} serviceUrl={data.mediaInfo?.serviceUrl4k} /> )}

{data.title}{' '} {data.releaseDate && ( ({data.releaseDate.slice(0, 4)}) )}

{movieAttributes.length > 0 && movieAttributes .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()} /> {(data.mediaInfo?.status === MediaStatus.AVAILABLE || (settings.currentSettings.movie4kEnabled && hasPermission( [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], { type: 'or', } ) && data.mediaInfo?.status4k === MediaStatus.AVAILABLE)) && hasPermission( [Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES], { type: 'or', } ) && ( )} {hasPermission(Permission.MANAGE_REQUESTS) && data.mediaInfo && (data.mediaInfo.jellyfinMediaId || data.mediaInfo.jellyfinMediaId4k || data.mediaInfo.status !== MediaStatus.UNKNOWN || data.mediaInfo.status4k !== MediaStatus.UNKNOWN) && ( )}
{data.tagline &&
{data.tagline}
}

{intl.formatMessage(messages.overview)}

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

{sortedCrew.length > 0 && ( <>
    {sortedCrew.slice(0, 6).map((person) => (
  • {person.job} {person.name}
  • ))}
{intl.formatMessage(messages.viewfullcrew)}
)} {data.keywords.length > 0 && (
{data.keywords.map((keyword) => ( {keyword.name} ))}
)}
{data.collection && (
{data.collection.name}
)}
{(!!data.voteCount || (ratingData?.rt?.criticsRating && typeof ratingData?.rt?.criticsScore === 'number') || (ratingData?.rt?.audienceRating && !!ratingData?.rt?.audienceScore) || ratingData?.imdb?.criticsScore) && (
{ratingData?.rt?.criticsRating && typeof ratingData?.rt?.criticsScore === 'number' && ( {ratingData.rt.criticsRating === 'Rotten' ? ( ) : ( )} {ratingData.rt.criticsScore}% )} {ratingData?.rt?.audienceRating && !!ratingData?.rt?.audienceScore && ( {ratingData.rt.audienceRating === 'Spilled' ? ( ) : ( )} {ratingData.rt.audienceScore}% )} {ratingData?.imdb?.criticsScore && ( {ratingData.imdb.criticsScore} )} {!!data.voteCount && ( {Math.round(data.voteAverage * 10)}% )}
)} {data.originalTitle && data.originalLanguage !== locale.slice(0, 2) && (
{intl.formatMessage(messages.originaltitle)} {data.originalTitle}
)}
{intl.formatMessage(globalMessages.status)} {data.status}
{filteredReleases && filteredReleases.length > 0 ? (
{intl.formatMessage(messages.releasedate, { releaseCount: filteredReleases.length, })} {filteredReleases.map((r, i) => ( {r.type === 3 ? ( // Theatrical ) : r.type === 4 ? ( // Digital ) : ( // Physical )} {intl.formatDate(r.release_date, { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC', })} ))}
) : ( data.releaseDate && (
{intl.formatMessage(messages.releasedate, { releaseCount: 1, })} {intl.formatDate(data.releaseDate, { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC', })}
) )} {data.revenue > 0 && (
{intl.formatMessage(messages.revenue)} {intl.formatNumber(data.revenue, { currency: 'USD', style: 'currency', })}
)} {data.budget > 0 && (
{intl.formatMessage(messages.budget)} {intl.formatNumber(data.budget, { currency: 'USD', style: 'currency', })}
)} {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.productionCompanies.length > 0 && (
{intl.formatMessage(messages.studio, { studioCount: data.productionCompanies.length, })} {data.productionCompanies .slice( 0, showAllStudios || showMoreStudios ? data.productionCompanies.length : minStudios ) .map((s) => { return ( {s.name} ); })} {!showAllStudios && ( )}
)} {!!streamingProviders.length && (
{intl.formatMessage(messages.streamingproviders)} {streamingProviders.map((p) => { return ( ); })}
)}
{data.credits.cast.length > 0 && ( <>
{intl.formatMessage(messages.cast)}
( ))} /> )}
); }; export default MovieDetails;