diff --git a/overseerr-api.yml b/overseerr-api.yml index 51b5f9e26..b75b9b214 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -904,6 +904,8 @@ components: $ref: '#/components/schemas/Season' status: type: string + tagline: + type: string type: type: string voteAverage: @@ -4737,6 +4739,12 @@ paths: description: Returns a list of genres in a JSON array. tags: - tmdb + parameters: + - in: query + name: language + schema: + type: string + example: en responses: '200': description: Results @@ -4759,6 +4767,12 @@ paths: description: Returns a list of genres in a JSON array. tags: - tmdb + parameters: + - in: query + name: language + schema: + type: string + example: en responses: '200': description: Results diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index ebe4088ac..f626a16de 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -254,6 +254,7 @@ export interface TmdbTvDetails { }[]; seasons: TmdbTvSeasonResult[]; status: string; + tagline?: string; type: string; vote_average: number; vote_count: number; diff --git a/server/models/Tv.ts b/server/models/Tv.ts index 438997c95..0216aada9 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -91,6 +91,7 @@ export interface TvDetails { spokenLanguages: SpokenLanguage[]; seasons: Season[]; status: string; + tagline?: string; type: string; voteAverage: number; voteCount: number; @@ -174,6 +175,7 @@ export const mapTvDetails = ( originCountry: show.origin_country, originalLanguage: show.original_language, originalName: show.original_name, + tagline: show.tagline, overview: show.overview, popularity: show.popularity, productionCompanies: show.production_companies.map((company) => ({ diff --git a/server/routes/index.ts b/server/routes/index.ts index 91dcd9ef1..af9537db0 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -95,7 +95,9 @@ router.get<{ id: string }>('/network/:id', async (req, res) => { router.get('/genres/movie', isAuthenticated(), async (req, res) => { const tmdb = new TheMovieDb(); - const genres = await tmdb.getMovieGenres(); + const genres = await tmdb.getMovieGenres({ + language: req.query.language as string, + }); return res.status(200).json(genres); }); @@ -103,7 +105,9 @@ router.get('/genres/movie', isAuthenticated(), async (req, res) => { router.get('/genres/tv', isAuthenticated(), async (req, res) => { const tmdb = new TheMovieDb(); - const genres = await tmdb.getTvGenres(); + const genres = await tmdb.getTvGenres({ + language: req.query.language as string, + }); return res.status(200).json(genres); }); diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 5148a309a..5953df1e8 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -19,12 +19,14 @@ import Transition from '../Transition'; import PageTitle from '../Common/PageTitle'; import { useUser, Permission } from '../../hooks/useUser'; import useSettings from '../../hooks/useSettings'; +import Link from 'next/link'; +import { uniq } from 'lodash'; const messages = defineMessages({ overviewunavailable: 'Overview unavailable.', overview: 'Overview', movies: 'Movies', - numberofmovies: 'Number of Movies: {count}', + numberofmovies: '{count} Movies', requesting: 'Requesting…', request: 'Request', requestcollection: 'Request Collection', @@ -62,6 +64,10 @@ const CollectionDetails: React.FC = ({ } ); + const { data: genres } = useSWR<{ id: number; name: string }[]>( + `/api/v1/genres/movie?language=${locale}` + ); + if (!data && !error) { return ; } @@ -105,6 +111,17 @@ const CollectionDetails: React.FC = ({ collectionStatus4k = MediaStatus.PARTIALLY_AVAILABLE; } + const hasRequestable = + data.parts.filter( + (part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN + ).length > 0; + + const hasRequestable4k = + data.parts.filter( + (part) => + !part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN + ).length > 0; + const requestableParts = data.parts.filter( (part) => !part.mediaInfo || @@ -147,9 +164,43 @@ const CollectionDetails: React.FC = ({ } }; + const collectionAttributes: React.ReactNode[] = []; + + collectionAttributes.push( + intl.formatMessage(messages.numberofmovies, { + count: data.parts.length, + }) + ); + + if (genres && data.parts.some((part) => part.genreIds.length)) { + collectionAttributes.push( + uniq( + data.parts.reduce( + (genresList: number[], curr) => genresList.concat(curr.genreIds), + [] + ) + ) + .map((genreId) => ( + + + {genres.find((g) => g.id === genreId)?.name} + + + )) + .reduce((prev, curr) => ( + <> + {prev}, {curr} + + )) + ); + } + return (
= ({ -
-
- -
-
-
- - (part.mediaInfo?.downloadStatus ?? []).length > 0 - )} - /> - +
+ +
+
+ (part.mediaInfo?.downloadStatus ?? []).length > 0 + )} + /> {settings.currentSettings.movie4kEnabled && hasPermission( [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], @@ -241,43 +288,83 @@ const CollectionDetails: React.FC = ({ type: 'or', } ) && ( - - - (part.mediaInfo?.downloadStatus4k ?? []).length > 0 - )} - /> - + + (part.mediaInfo?.downloadStatus4k ?? []).length > 0 + )} + /> )}
-

{data.name}

- - {intl.formatMessage(messages.numberofmovies, { - count: data.parts.length, - })} +

{data.name}

+ + {collectionAttributes.length > 0 && + collectionAttributes + .map((t, k) => {t}) + .reduce((prev, curr) => ( + <> + {prev} | {curr} + + ))}
-
+
{hasPermission(Permission.REQUEST) && - (collectionStatus !== MediaStatus.AVAILABLE || + (hasRequestable || (settings.currentSettings.movie4kEnabled && hasPermission( [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], { type: 'or' } ) && - collectionStatus4k !== MediaStatus.AVAILABLE)) && ( -
- { - setRequestModal(true); - setIs4k(collectionStatus === MediaStatus.AVAILABLE); - }} - text={ - <> + hasRequestable4k)) && ( + { + setRequestModal(true); + setIs4k(!hasRequestable); + }} + text={ + <> + + + + + {intl.formatMessage( + hasRequestable + ? messages.requestcollection + : messages.requestcollection4k + )} + + + } + > + {settings.currentSettings.movie4kEnabled && + hasPermission( + [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], + { type: 'or' } + ) && + hasRequestable && + hasRequestable4k && ( + { + setRequestModal(true); + setIs4k(true); + }} + > = ({ /> - {intl.formatMessage( - collectionStatus === MediaStatus.AVAILABLE - ? messages.requestcollection4k - : messages.requestcollection - )} + {intl.formatMessage(messages.requestcollection4k)} - - } - > - {settings.currentSettings.movie4kEnabled && - hasPermission( - [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], - { type: 'or' } - ) && - collectionStatus !== MediaStatus.AVAILABLE && - collectionStatus4k !== MediaStatus.AVAILABLE && ( - { - setRequestModal(true); - setIs4k(true); - }} - > - - - - - {intl.formatMessage(messages.requestcollection4k)} - - - )} - -
+ + )} + )}
-
-
-

- {intl.formatMessage(messages.overview)} -

-

+

+
+

{intl.formatMessage(messages.overview)}

+

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

-
-
-
- {intl.formatMessage(messages.movies)} -
+
+
+ {intl.formatMessage(messages.movies)}
( ref?: React.Ref> ): JSX.Element { const buttonStyle = [ - 'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer', + 'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50', ]; switch (buttonType) { case 'primary': buttonStyle.push( - 'text-white bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 disabled:opacity-50' + 'text-white bg-indigo-600 border-indigo-600 hover:bg-indigo-500 hover:border-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 active:border-indigo-700' ); break; case 'danger': buttonStyle.push( - 'text-white bg-red-600 hover:bg-red-500 focus:border-red-700 focus:ring-red active:bg-red-700 disabled:opacity-50' + 'text-white bg-red-600 border-red-600 hover:bg-red-500 hover:border-red-500 focus:border-red-700 focus:ring-red active:bg-red-700 active:border-red-700' ); break; case 'warning': buttonStyle.push( - 'text-white bg-yellow-500 hover:bg-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-yellow-700 disabled:opacity-50' + 'text-white bg-yellow-500 border-yellow-500 hover:bg-yellow-400 hover:border-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-yellow-700 active:border-yellow-700' ); break; case 'success': buttonStyle.push( - 'text-white bg-green-400 hover:bg-green-300 focus:border-green-700 focus:ring-green active:bg-green-700 disabled:opacity-50' + 'text-white bg-green-400 border-green-400 hover:bg-green-300 hover:border-green-300 focus:border-green-700 focus:ring-green active:bg-green-700 active:border-green-700' ); break; case 'ghost': buttonStyle.push( - 'text-white bg-transaprent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100 disabled:opacity-50' + 'text-white bg-transaprent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100' ); break; default: buttonStyle.push( - 'leading-5 font-medium rounded-md text-gray-200 bg-gray-500 hover:bg-gray-400 group-hover:bg-gray-400 hover:text-white group-hover:text-white focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 disabled:opacity-50' + 'text-gray-200 bg-gray-500 border-gray-500 hover:text-white hover:bg-gray-400 hover:border-gray-400 group-hover:text-white group-hover:bg-gray-400 group-hover:border-gray-400 focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 active:border-gray-400' ); } diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx index 054ecaea1..d81ea3fa2 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -59,24 +59,23 @@ const ButtonWithDropdown: React.FC = ({ useClickOutside(buttonRef, () => setIsOpen(false)); const styleClasses = { - mainButtonClasses: '', - dropdownSideButtonClasses: '', + mainButtonClasses: 'text-white border', + dropdownSideButtonClasses: 'border', dropdownClasses: '', }; switch (buttonType) { case 'ghost': - styleClasses.mainButtonClasses = - 'text-white bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'; - styleClasses.dropdownSideButtonClasses = - 'bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'; + styleClasses.mainButtonClasses += + ' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'; + styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses; styleClasses.dropdownClasses = 'bg-gray-700'; break; default: - styleClasses.mainButtonClasses = - 'text-white bg-indigo-600 hover:text-white hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue'; - styleClasses.dropdownSideButtonClasses = - 'bg-indigo-700 border border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue'; + styleClasses.mainButtonClasses += + ' bg-indigo-600 border-indigo-600 hover:bg-indigo-500 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue'; + styleClasses.dropdownSideButtonClasses += + ' bg-indigo-700 border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue'; styleClasses.dropdownClasses = 'bg-indigo-600'; } diff --git a/src/components/ExternalLinkBlock/index.tsx b/src/components/ExternalLinkBlock/index.tsx index 680cf1ee1..6edbea9d6 100644 --- a/src/components/ExternalLinkBlock/index.tsx +++ b/src/components/ExternalLinkBlock/index.tsx @@ -24,11 +24,11 @@ const ExternalLinkBlock: React.FC = ({ plexUrl, }) => { return ( -
+
{plexUrl && ( @@ -38,7 +38,7 @@ const ExternalLinkBlock: React.FC = ({ {tmdbId && ( @@ -48,7 +48,7 @@ const ExternalLinkBlock: React.FC = ({ {tvdbId && mediaType === MediaType.TV && ( @@ -58,7 +58,7 @@ const ExternalLinkBlock: React.FC = ({ {imdbId && ( @@ -68,7 +68,7 @@ const ExternalLinkBlock: React.FC = ({ {rtUrl && ( diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 1a6b2cd86..2c5779a21 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -1,10 +1,5 @@ import React, { useState, useContext, useMemo } from 'react'; -import { - defineMessages, - FormattedNumber, - FormattedDate, - useIntl, -} from 'react-intl'; +import { defineMessages, FormattedNumber, useIntl } from 'react-intl'; import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie'; import useSWR from 'swr'; import { useRouter } from 'next/router'; @@ -205,7 +200,7 @@ const MovieDetails: React.FC = ({ movie }) => { return (
= ({ movie }) => {
)} -
-
- -
-
-
- - 0} - plexUrl={data.mediaInfo?.plexUrl} - /> - +
+ +
+
+ 0} + plexUrl={data.mediaInfo?.plexUrl} + /> {settings.currentSettings.movie4kEnabled && hasPermission( [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], @@ -412,25 +403,25 @@ const MovieDetails: React.FC = ({ movie }) => { type: 'or', } ) && ( - - 0 - } - plexUrl4k={data.mediaInfo?.plexUrl4k} - /> - + 0 + } + plexUrl4k={data.mediaInfo?.plexUrl4k} + /> )}
-

+

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

- + {movieAttributes.length > 0 && movieAttributes .map((t, k) => {t}) @@ -441,27 +432,23 @@ const MovieDetails: React.FC = ({ movie }) => { ))}
-
-
- -
-
- revalidate()} - /> -
+
+ + revalidate()} + /> {hasPermission(Permission.MANAGE_REQUESTS) && (
-
-
-

- {intl.formatMessage(messages.overview)} -

-

+

-
+
{data.collection && (
@@ -555,80 +536,65 @@ const MovieDetails: React.FC = ({ movie }) => {
)} -
+
{(!!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 && ( <> - + - - {data.voteAverage}/10 )}
)} +
+ {intl.formatMessage(messages.status)} + {data.status} +
{data.releaseDate && ( -
- - {intl.formatMessage(messages.releasedate)} - - - +
+ {intl.formatMessage(messages.releasedate)} + + {intl.formatDate(data.releaseDate, { + year: 'numeric', + month: 'long', + day: 'numeric', + })}
)} -
- - {intl.formatMessage(messages.status)} - - - {data.status} - -
{data.revenue > 0 && ( -
- - {intl.formatMessage(messages.revenue)} - - +
+ {intl.formatMessage(messages.revenue)} + = ({ movie }) => {
)} {data.budget > 0 && ( -
- - {intl.formatMessage(messages.budget)} - - +
+ {intl.formatMessage(messages.budget)} + = ({ movie }) => {
)} {data.originalLanguage && ( -
- - {intl.formatMessage(messages.originallanguage)} - - +
+ {intl.formatMessage(messages.originallanguage)} + @@ -674,13 +636,13 @@ const MovieDetails: React.FC = ({ movie }) => {
)} {data.productionCompanies.length > 0 && ( -
- +
+ {intl.formatMessage(messages.studio, { studioCount: data.productionCompanies.length, })} - + {data.productionCompanies.map((s) => { return ( = ({ movie }) => {
)} -
-
- +
+ +
{data.credits.cast.length > 0 && ( <> -
-