feat(frontend/api): movie recommendations/similar request and frontend detail page update

pull/96/head
sct 4 years ago
parent be0003a85d
commit 6398e3645a

@ -286,6 +286,58 @@ class TheMovieDb {
}
};
public async getMovieRecommendations({
movieId,
page = 1,
language = 'en-US',
}: {
movieId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/recommendations`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
}
public async getMovieSimilar({
movieId,
page = 1,
language = 'en-US',
}: {
movieId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/similar`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
}
}
public getDiscoverMovies = async ({
sortBy = 'popularity.desc',
page = 1,

@ -1236,6 +1236,86 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/MovieDetails'
/movie/{movieId}/recommendations:
get:
summary: Request recommended movies
description: Returns list of recommended movies based on provided movie ID in JSON format
tags:
- movies
parameters:
- in: path
name: movieId
required: true
schema:
type: number
example: 337401
- in: query
name: page
schema:
type: number
example: 1
default: 1
responses:
'200':
description: List of movies
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/MovieResult'
/movie/{movieId}/similar:
get:
summary: Request similar movies
description: Returns list of similar movies based on provided movie ID in JSON format
tags:
- movies
parameters:
- in: path
name: movieId
required: true
schema:
type: number
example: 337401
- in: query
name: page
schema:
type: number
example: 1
default: 1
responses:
'200':
description: List of movies
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/MovieResult'
/tv/{tvId}:
get:
summary: Request tv details

@ -2,6 +2,7 @@ import { Router } from 'express';
import TheMovieDb from '../api/themoviedb';
import { mapMovieDetails } from '../models/Movie';
import { MediaRequest } from '../entity/MediaRequest';
import { mapMovieResult } from '../models/Search';
const movieRoutes = Router();
@ -15,4 +16,54 @@ movieRoutes.get('/:id', async (req, res) => {
return res.status(200).json(mapMovieDetails(movie, request));
});
movieRoutes.get('/:id/recommendations', async (req, res) => {
const tmdb = new TheMovieDb();
const results = await tmdb.getMovieRecommendations({
movieId: Number(req.params.id),
page: Number(req.query.page),
});
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) =>
mapMovieResult(
result,
requests.find((req) => req.mediaId === result.id)
)
),
});
});
movieRoutes.get('/:id/similar', async (req, res) => {
const tmdb = new TheMovieDb();
const results = await tmdb.getMovieSimilar({
movieId: Number(req.params.id),
page: Number(req.query.page),
});
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) =>
mapMovieResult(
result,
requests.find((req) => req.mediaId === result.id)
)
),
});
});
export default movieRoutes;

@ -0,0 +1,70 @@
import React from 'react';
import { useSWRInfinite } from 'swr';
import { MovieResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import { useRouter } from 'next/router';
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: MovieResult[];
}
const MovieRecommendations: React.FC = () => {
const router = useRouter();
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(pageIndex: number, previousPageData: SearchResult | null) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
return null;
}
return `/api/v1/movie/${router.query.movieId}/recommendations?page=${
pageIndex + 1
}`;
},
{
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 <div>{error}</div>;
}
const titles = data?.reduce(
(a, v) => [...a, ...v.results],
[] as MovieResult[]
);
return (
<>
<div className="md:flex md:items-center md:justify-between mb-8 mt-6">
<div className="flex-1 min-w-0">
<h2 className="text-xl leading-7 text-white sm:text-2xl sm:leading-9 sm:truncate">
Recommendations
</h2>
</div>
</div>
<ListView
items={titles}
isEmpty={!isLoadingInitialData && titles?.length === 0}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
onScrollBottom={fetchMore}
/>
</>
);
};
export default MovieRecommendations;

@ -0,0 +1,70 @@
import React from 'react';
import { useSWRInfinite } from 'swr';
import { MovieResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import { useRouter } from 'next/router';
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: MovieResult[];
}
const MovieSimilar: React.FC = () => {
const router = useRouter();
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(pageIndex: number, previousPageData: SearchResult | null) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
return null;
}
return `/api/v1/movie/${router.query.movieId}/similar?page=${
pageIndex + 1
}`;
},
{
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 <div>{error}</div>;
}
const titles = data?.reduce(
(a, v) => [...a, ...v.results],
[] as MovieResult[]
);
return (
<>
<div className="md:flex md:items-center md:justify-between mb-8 mt-6">
<div className="flex-1 min-w-0">
<h2 className="text-xl leading-7 text-white sm:text-2xl sm:leading-9 sm:truncate">
Similar Titles
</h2>
</div>
</div>
<ListView
items={titles}
isEmpty={!isLoadingInitialData && titles?.length === 0}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
onScrollBottom={fetchMore}
/>
</>
);
};
export default MovieSimilar;

@ -7,11 +7,22 @@ import Button from '../Common/Button';
import MovieRequestModal from '../RequestModal/MovieRequestModal';
import type { MediaRequest } from '../../../server/entity/MediaRequest';
import axios from 'axios';
import type { MovieResult } from '../../../server/models/Search';
import Link from 'next/link';
import Slider from '../Slider';
import TitleCard from '../TitleCard';
interface MovieDetailsProps {
movie?: MovieDetailsType;
}
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: MovieResult[];
}
enum MediaRequestStatus {
PENDING = 1,
APPROVED,
@ -30,6 +41,12 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
initialData: movie,
}
);
const { data: recommended, error: recommendedError } = useSWR<SearchResult>(
`/api/v1/movie/${router.query.movieId}/recommendations`
);
const { data: similar, error: similarError } = useSWR<SearchResult>(
`/api/v1/movie/${router.query.movieId}/similar`
);
const request = async () => {
const response = await axios.post<MediaRequest>('/api/v1/request', {
@ -263,6 +280,97 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</div>
</div>
</div>
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="flex-1 min-w-0">
<Link
href="/movie/[movieId]/recommendations"
as={`/movie/${data.id}/recommendations`}
>
<a className="inline-flex text-xl leading-7 text-cool-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<span>Recommendations</span>
<svg
className="w-6 h-6 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div>
</div>
<Slider
sliderKey="recommendations"
isLoading={!recommended && !recommendedError}
isEmpty={false}
items={recommended?.results.map((title) => (
<TitleCard
key={`recommended-${title.id}`}
id={title.id}
image={title.posterPath}
status={title.request?.status}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={title.mediaType}
requestId={title.request?.id}
/>
))}
/>
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="flex-1 min-w-0">
<Link
href="/movie/[movieId]/similar"
as={`/movie/${data.id}/similar`}
>
<a className="inline-flex text-xl leading-7 text-cool-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<span>Similar Titles</span>
<svg
className="w-6 h-6 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div>
</div>
<Slider
sliderKey="recommendations"
isLoading={!similar && !similarError}
isEmpty={false}
items={similar?.results.map((title) => (
<TitleCard
key={`recommended-${title.id}`}
id={title.id}
image={title.posterPath}
status={title.request?.status}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={title.mediaType}
requestId={title.request?.id}
/>
))}
/>
<div className="pb-8" />
</div>
);
};

@ -1,7 +1,7 @@
import React from 'react';
import { NextPage } from 'next';
import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie';
import MovieDetails from '../../components/MovieDetails';
import type { MovieDetails as MovieDetailsType } from '../../../../server/models/Movie';
import MovieDetails from '../../../components/MovieDetails';
import axios from 'axios';
interface MoviePageProps {

@ -0,0 +1,9 @@
import React from 'react';
import { NextPage } from 'next';
import MovieRecommendations from '../../../components/MovieDetails/MovieRecommendations';
const MovieRecommendationsPage: NextPage = () => {
return <MovieRecommendations />;
};
export default MovieRecommendationsPage;

@ -0,0 +1,9 @@
import React from 'react';
import { NextPage } from 'next';
import MovieSimilar from '../../../components/MovieDetails/MovieSimilar';
const MovieSimilarPage: NextPage = () => {
return <MovieSimilar />;
};
export default MovieSimilarPage;
Loading…
Cancel
Save