From 02cbb5b030a3af5d62ab6c4cafdd4d800b4f61f4 Mon Sep 17 00:00:00 2001 From: sct Date: Sun, 20 Sep 2020 14:09:40 +0900 Subject: [PATCH] feat(frontend/api): tv details page --- server/api/themoviedb.ts | 60 ++- server/models/Movie.ts | 57 +-- server/models/Tv.ts | 17 +- server/models/common.ts | 44 ++ server/overseerr-api.yml | 106 ++++ server/routes/tv.ts | 58 ++- .../TvDetails/TvRecommendations.tsx | 69 +++ src/components/TvDetails/TvSimilar.tsx | 72 +++ src/components/TvDetails/index.tsx | 465 ++++++++++++++++++ src/pages/tv/[tvId]/index.tsx | 38 ++ src/pages/tv/[tvId]/recommendations.tsx | 9 + src/pages/tv/[tvId]/similar.tsx | 9 + 12 files changed, 953 insertions(+), 51 deletions(-) create mode 100644 src/components/TvDetails/TvRecommendations.tsx create mode 100644 src/components/TvDetails/TvSimilar.tsx create mode 100644 src/components/TvDetails/index.tsx create mode 100644 src/pages/tv/[tvId]/index.tsx create mode 100644 src/pages/tv/[tvId]/recommendations.tsx create mode 100644 src/pages/tv/[tvId]/similar.tsx diff --git a/server/api/themoviedb.ts b/server/api/themoviedb.ts index 6e305de81..f4fec0726 100644 --- a/server/api/themoviedb.ts +++ b/server/api/themoviedb.ts @@ -238,6 +238,10 @@ export interface TmdbTvDetails { type: string; vote_average: number; vote_count: number; + credits: { + cast: TmdbCreditCast[]; + crew: TmdbCreditCrew[]; + }; } class TheMovieDb { @@ -304,7 +308,7 @@ class TheMovieDb { }): Promise => { try { const response = await this.axios.get(`/tv/${tvId}`, { - params: { language }, + params: { language, append_to_response: 'credits' }, }); return response.data; @@ -365,6 +369,60 @@ class TheMovieDb { } } + public async getTvRecommendations({ + tvId, + page = 1, + language = 'en-US', + }: { + tvId: number; + page?: number; + language?: string; + }): Promise { + try { + const response = await this.axios.get( + `/tv/${tvId}/recommendations`, + { + params: { + page, + language, + }, + } + ); + + return response.data; + } catch (e) { + throw new Error( + `[TMDB] Failed to fetch tv recommendations: ${e.message}` + ); + } + } + + public async getTvSimilar({ + tvId, + page = 1, + language = 'en-US', + }: { + tvId: number; + page?: number; + language?: string; + }): Promise { + try { + const response = await this.axios.get( + `/tv/${tvId}/similar`, + { + params: { + page, + language, + }, + } + ); + + return response.data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch tv similar: ${e.message}`); + } + } + public getDiscoverMovies = async ({ sortBy = 'popularity.desc', page = 1, diff --git a/server/models/Movie.ts b/server/models/Movie.ts index f03f4c110..912fb70e5 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -1,31 +1,13 @@ -import { - TmdbMovieDetails, - TmdbCreditCast, - TmdbCreditCrew, -} from '../api/themoviedb'; +import { TmdbMovieDetails } from '../api/themoviedb'; import { MediaRequest } from '../entity/MediaRequest'; -import { ProductionCompany, Genre } from './common'; - -export interface Cast { - id: number; - castId: number; - character: string; - creditId: string; - gender?: number; - name: string; - order: number; - profilePath?: string; -} - -export interface Crew { - id: number; - creditId: string; - department: string; - gender?: number; - job: string; - name: string; - profilePath?: string; -} +import { + ProductionCompany, + Genre, + Cast, + Crew, + mapCast, + mapCrew, +} from './common'; export interface MovieDetails { id: number; @@ -65,27 +47,6 @@ export interface MovieDetails { request?: MediaRequest; } -const mapCast = (person: TmdbCreditCast): Cast => ({ - castId: person.cast_id, - character: person.character, - creditId: person.credit_id, - id: person.id, - name: person.name, - order: person.order, - gender: person.gender, - profilePath: person.profile_path, -}); - -const mapCrew = (person: TmdbCreditCrew): Crew => ({ - creditId: person.credit_id, - department: person.department, - id: person.id, - job: person.job, - name: person.name, - gender: person.gender, - profilePath: person.profile_path, -}); - export const mapMovieDetails = ( movie: TmdbMovieDetails, request?: MediaRequest diff --git a/server/models/Tv.ts b/server/models/Tv.ts index e06fe06e8..b4078858a 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -1,4 +1,11 @@ -import { Genre, ProductionCompany } from './common'; +import { + Genre, + ProductionCompany, + Cast, + Crew, + mapCast, + mapCrew, +} from './common'; import { MediaRequest } from '../entity/MediaRequest'; import { TmdbTvEpisodeDetails, @@ -64,6 +71,10 @@ export interface TvDetails { type: string; voteAverage: number; voteCount: number; + credits: { + cast: Cast[]; + crew: Crew[]; + }; request?: MediaRequest; } @@ -140,5 +151,9 @@ export const mapTvDetails = ( ? mapEpisodeDetails(show.next_episode_to_air) : undefined, posterPath: show.poster_path, + credits: { + cast: show.credits.cast.map(mapCast), + crew: show.credits.crew.map(mapCrew), + }, request, }); diff --git a/server/models/common.ts b/server/models/common.ts index 344e0e82d..0e4ed7e07 100644 --- a/server/models/common.ts +++ b/server/models/common.ts @@ -1,3 +1,5 @@ +import { TmdbCreditCast, TmdbCreditCrew } from '../api/themoviedb'; + export interface ProductionCompany { id: number; logoPath?: string; @@ -9,3 +11,45 @@ export interface Genre { id: number; name: string; } + +export interface Cast { + id: number; + castId: number; + character: string; + creditId: string; + gender?: number; + name: string; + order: number; + profilePath?: string; +} + +export interface Crew { + id: number; + creditId: string; + department: string; + gender?: number; + job: string; + name: string; + profilePath?: string; +} + +export const mapCast = (person: TmdbCreditCast): Cast => ({ + castId: person.cast_id, + character: person.character, + creditId: person.credit_id, + id: person.id, + name: person.name, + order: person.order, + gender: person.gender, + profilePath: person.profile_path, +}); + +export const mapCrew = (person: TmdbCreditCrew): Crew => ({ + creditId: person.credit_id, + department: person.department, + id: person.id, + job: person.job, + name: person.name, + gender: person.gender, + profilePath: person.profile_path, +}); diff --git a/server/overseerr-api.yml b/server/overseerr-api.yml index 49b9af6bf..7c6c44180 100644 --- a/server/overseerr-api.yml +++ b/server/overseerr-api.yml @@ -562,6 +562,17 @@ components: type: number voteCount: type: number + credits: + type: object + properties: + cast: + type: array + items: + $ref: '#/components/schemas/Cast' + crew: + type: array + items: + $ref: '#/components/schemas/Crew' request: $ref: '#/components/schemas/MediaRequest' MediaRequest: @@ -1415,6 +1426,11 @@ paths: schema: type: number example: 76479 + - in: query + name: language + schema: + type: string + example: en responses: '200': description: TV details @@ -1422,6 +1438,96 @@ paths: application/json: schema: $ref: '#/components/schemas/TvDetails' + /tv/{tvId}/recommendations: + get: + summary: Request recommended tv series + description: Returns list of recommended tv series based on provided tv ID in JSON format + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: number + example: 76479 + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of tv series + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /tv/{tvId}/similar: + get: + summary: Request similar tv series + description: Returns list of similar tv series based on provided movie ID in JSON format + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: number + example: 76479 + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of tv series + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/TvResult' security: - cookieAuth: [] diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 7eb3b19e5..d24a91116 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -2,17 +2,73 @@ import { Router } from 'express'; import TheMovieDb from '../api/themoviedb'; import { MediaRequest } from '../entity/MediaRequest'; import { mapTvDetails } from '../models/Tv'; +import { mapTvResult } from '../models/Search'; const tvRoutes = Router(); tvRoutes.get('/:id', async (req, res) => { const tmdb = new TheMovieDb(); - const tv = await tmdb.getTvShow({ tvId: Number(req.params.id) }); + const tv = await tmdb.getTvShow({ + tvId: Number(req.params.id), + language: req.query.language as string, + }); const request = await MediaRequest.getRequest(tv.id); return res.status(200).json(mapTvDetails(tv, request)); }); +tvRoutes.get('/:id/recommendations', async (req, res) => { + const tmdb = new TheMovieDb(); + + const results = await tmdb.getTvRecommendations({ + tvId: Number(req.params.id), + page: Number(req.query.page), + language: req.query.language as string, + }); + + const requests = await MediaRequest.getRelatedRequests( + results.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: results.page, + totalPages: results.total_pages, + totalResults: results.total_results, + results: results.results.map((result) => + mapTvResult( + result, + requests.find((req) => req.mediaId === result.id) + ) + ), + }); +}); + +tvRoutes.get('/:id/similar', async (req, res) => { + const tmdb = new TheMovieDb(); + + const results = await tmdb.getTvSimilar({ + tvId: Number(req.params.id), + page: Number(req.query.page), + language: req.query.language as string, + }); + + const requests = await MediaRequest.getRelatedRequests( + results.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: results.page, + totalPages: results.total_pages, + totalResults: results.total_results, + results: results.results.map((result) => + mapTvResult( + result, + requests.find((req) => req.mediaId === result.id) + ) + ), + }); +}); + export default tvRoutes; diff --git a/src/components/TvDetails/TvRecommendations.tsx b/src/components/TvDetails/TvRecommendations.tsx new file mode 100644 index 000000000..64086188c --- /dev/null +++ b/src/components/TvDetails/TvRecommendations.tsx @@ -0,0 +1,69 @@ +import React, { useContext } from 'react'; +import { useSWRInfinite } from 'swr'; +import type { TvResult } from '../../../server/models/Search'; +import ListView from '../Common/ListView'; +import { useRouter } from 'next/router'; +import { LanguageContext } from '../../context/LanguageContext'; + +interface SearchResult { + page: number; + totalResults: number; + totalPages: number; + results: TvResult[]; +} + +const TvRecommendations: React.FC = () => { + const { locale } = useContext(LanguageContext); + const router = useRouter(); + const { data, error, size, setSize } = useSWRInfinite( + (pageIndex: number, previousPageData: SearchResult | null) => { + if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { + return null; + } + + return `/api/v1/tv/${router.query.tvId}/recommendations?page=${ + pageIndex + 1 + }&language=${locale}`; + }, + { + initialSize: 3, + } + ); + + const isLoadingInitialData = !data && !error; + const isLoadingMore = + isLoadingInitialData || + (size > 0 && data && typeof data[size - 1] === 'undefined'); + + const fetchMore = () => { + setSize(size + 1); + }; + + if (error) { + return
{error}
; + } + + const titles = data?.reduce((a, v) => [...a, ...v.results], [] as TvResult[]); + + return ( + <> +
+
+

+ Recommendations +

+
+
+ 0) + } + onScrollBottom={fetchMore} + /> + + ); +}; + +export default TvRecommendations; diff --git a/src/components/TvDetails/TvSimilar.tsx b/src/components/TvDetails/TvSimilar.tsx new file mode 100644 index 000000000..566843fc0 --- /dev/null +++ b/src/components/TvDetails/TvSimilar.tsx @@ -0,0 +1,72 @@ +import React, { useContext } from 'react'; +import { useSWRInfinite } from 'swr'; +import { MovieResult } from '../../../server/models/Search'; +import ListView from '../Common/ListView'; +import { useRouter } from 'next/router'; +import { LanguageContext } from '../../context/LanguageContext'; + +interface SearchResult { + page: number; + totalResults: number; + totalPages: number; + results: MovieResult[]; +} + +const TvSimilar: React.FC = () => { + const router = useRouter(); + const { locale } = useContext(LanguageContext); + const { data, error, size, setSize } = useSWRInfinite( + (pageIndex: number, previousPageData: SearchResult | null) => { + if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { + return null; + } + + return `/api/v1/tv/${router.query.tvId}/similar?page=${ + pageIndex + 1 + }&language=${locale}`; + }, + { + initialSize: 3, + } + ); + + const isLoadingInitialData = !data && !error; + const isLoadingMore = + isLoadingInitialData || + (size > 0 && data && typeof data[size - 1] === 'undefined'); + + const fetchMore = () => { + setSize(size + 1); + }; + + if (error) { + return
{error}
; + } + + const titles = data?.reduce( + (a, v) => [...a, ...v.results], + [] as MovieResult[] + ); + + return ( + <> +
+
+

+ Similar Series +

+
+
+ 0) + } + onScrollBottom={fetchMore} + /> + + ); +}; + +export default TvSimilar; diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx new file mode 100644 index 000000000..fc02872ea --- /dev/null +++ b/src/components/TvDetails/index.tsx @@ -0,0 +1,465 @@ +import React, { useState, useContext } from 'react'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; +import useSWR from 'swr'; +import { useRouter } from 'next/router'; +import { useToasts } from 'react-toast-notifications'; +import Button from '../Common/Button'; +import MovieRequestModal from '../RequestModal/MovieRequestModal'; +import type { MediaRequest } from '../../../server/entity/MediaRequest'; +import axios from 'axios'; +import type { TvResult } from '../../../server/models/Search'; +import Link from 'next/link'; +import Slider from '../Slider'; +import TitleCard from '../TitleCard'; +import PersonCard from '../PersonCard'; +import { LanguageContext } from '../../context/LanguageContext'; +import LoadingSpinner from '../Common/LoadingSpinner'; +import { useUser, Permission } from '../../hooks/useUser'; +import PendingRequest from '../PendingRequest'; +import { TvDetails as TvDetailsType } from '../../../server/models/Tv'; + +const messages = defineMessages({ + userrating: 'User Rating', + status: 'Status', + originallanguage: 'Original Language', + overview: 'Overview', + cast: 'Cast', + recommendations: 'Recommendations', + similar: 'Similar Series', + cancelrequest: 'Cancel Request', + available: 'Available', + unavailable: 'Unavailable', + request: 'Request', + pending: 'Pending', + overviewunavailable: 'Overview unavailable', +}); + +interface TvDetailsProps { + tv?: TvDetailsType; +} + +interface SearchResult { + page: number; + totalResults: number; + totalPages: number; + results: TvResult[]; +} + +enum MediaRequestStatus { + PENDING = 1, + APPROVED, + DECLINED, + AVAILABLE, +} + +const TvDetails: React.FC = ({ tv }) => { + const { user, hasPermission } = useUser(); + const router = useRouter(); + const intl = useIntl(); + const { locale } = useContext(LanguageContext); + const { addToast } = useToasts(); + const [showRequestModal, setShowRequestModal] = useState(false); + const [showCancelModal, setShowCancelModal] = useState(false); + const { data, error, revalidate } = useSWR( + `/api/v1/tv/${router.query.tvId}?language=${locale}`, + { + initialData: tv, + } + ); + const { data: recommended, error: recommendedError } = useSWR( + `/api/v1/tv/${router.query.tvId}/recommendations?language=${locale}` + ); + const { data: similar, error: similarError } = useSWR( + `/api/v1/tv/${router.query.tvId}/similar?language=${locale}` + ); + + const request = async () => { + const response = await axios.post('/api/v1/request', { + mediaId: data?.id, + mediaType: 'movie', + }); + + if (response.data) { + revalidate(); + addToast( + + {data?.name} succesfully requested! + , + { appearance: 'success', autoDismiss: true } + ); + } + }; + + const cancelRequest = async () => { + const response = await axios.delete( + `/api/v1/request/${data?.request?.id}` + ); + + if (response.data.id) { + revalidate(); + } + }; + + if (!data && !error) { + return ; + } + + if (!data) { + return
Broken?
; + } + + return ( +
+ setShowRequestModal(false)} + onOk={() => request()} + /> + setShowCancelModal(false)} + onOk={() => cancelRequest()} + /> +
+
+ +
+
+ + {data.firstAirDate.slice(0, 4)} + +

{data.name}

+ + {data.genres.map((g) => g.name).join(', ')} + +
+
+ {!data.request && ( + + )} + {data.request?.status === MediaRequestStatus.PENDING && ( + + )} + {data.request?.status === MediaRequestStatus.APPROVED && ( + + )} + {data.request?.status === MediaRequestStatus.AVAILABLE && ( + + )} + + {hasPermission(Permission.MANAGE_REQUESTS) && ( + + )} +
+
+
+
+ {data.request?.status === MediaRequestStatus.PENDING && + hasPermission(Permission.MANAGE_REQUESTS) && ( + revalidate()} + /> + )} +

+ +

+

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

+
+
+
+ {data.voteCount > 0 && ( +
+ + + + + {data.voteAverage}/10 + +
+ )} +
+ + + + + {data.status} + +
+
+ + + + + {data.originalLanguage} + +
+
+
+
+
+ +
+ ( + + ))} + /> +
+ +
+ ( + + ))} + /> +
+ +
+ ( + + ))} + /> +
+
+ ); +}; + +export default TvDetails; diff --git a/src/pages/tv/[tvId]/index.tsx b/src/pages/tv/[tvId]/index.tsx new file mode 100644 index 000000000..f5eb6e294 --- /dev/null +++ b/src/pages/tv/[tvId]/index.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { NextPage } from 'next'; +import axios from 'axios'; +import { parseCookies } from 'nookies'; +import TvDetails from '../../../components/TvDetails'; +import type { TvDetails as TvDetailsType } from '../../../../server/models/Tv'; + +interface TvPageProps { + tv?: TvDetailsType; +} + +const TvPage: NextPage = ({ tv }) => { + return ; +}; + +TvPage.getInitialProps = async (ctx) => { + if (ctx.req) { + const cookies = parseCookies(ctx); + const response = await axios.get( + `http://localhost:${process.env.PORT || 3000}/api/v1/tv/${ + ctx.query.tvId + }${cookies.locale ? `?language=${cookies.locale}` : ''}`, + { + headers: ctx.req?.headers?.cookie + ? { cookie: ctx.req.headers.cookie } + : undefined, + } + ); + + return { + tv: response.data, + }; + } + + return {}; +}; + +export default TvPage; diff --git a/src/pages/tv/[tvId]/recommendations.tsx b/src/pages/tv/[tvId]/recommendations.tsx new file mode 100644 index 000000000..37269e2d5 --- /dev/null +++ b/src/pages/tv/[tvId]/recommendations.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { NextPage } from 'next'; +import TvRecommendations from '../../../components/TvDetails/TvRecommendations'; + +const TvRecommendationsPage: NextPage = () => { + return ; +}; + +export default TvRecommendationsPage; diff --git a/src/pages/tv/[tvId]/similar.tsx b/src/pages/tv/[tvId]/similar.tsx new file mode 100644 index 000000000..cca4500a7 --- /dev/null +++ b/src/pages/tv/[tvId]/similar.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { NextPage } from 'next'; +import TvSimilar from '../../../components/TvDetails/TvSimilar'; + +const TvSimilarPage: NextPage = () => { + return ; +}; + +export default TvSimilarPage;