diff --git a/server/api/themoviedb.ts b/server/api/themoviedb.ts index f59653c32..7790b6a7c 100644 --- a/server/api/themoviedb.ts +++ b/server/api/themoviedb.ts @@ -286,6 +286,58 @@ class TheMovieDb { } }; + public async getMovieRecommendations({ + movieId, + page = 1, + language = 'en-US', + }: { + movieId: number; + page?: number; + language?: string; + }): Promise { + try { + const response = await this.axios.get( + `/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 { + try { + const response = await this.axios.get( + `/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, diff --git a/server/overseerr-api.yml b/server/overseerr-api.yml index 6afa48614..d7812d185 100644 --- a/server/overseerr-api.yml +++ b/server/overseerr-api.yml @@ -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 diff --git a/server/routes/movie.ts b/server/routes/movie.ts index 129da8f54..ff0a55853 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -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; diff --git a/src/components/MovieDetails/MovieRecommendations.tsx b/src/components/MovieDetails/MovieRecommendations.tsx new file mode 100644 index 000000000..66fbf5a6e --- /dev/null +++ b/src/components/MovieDetails/MovieRecommendations.tsx @@ -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( + (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
{error}
; + } + + const titles = data?.reduce( + (a, v) => [...a, ...v.results], + [] as MovieResult[] + ); + + return ( + <> +
+
+

+ Recommendations +

+
+
+ 0) + } + onScrollBottom={fetchMore} + /> + + ); +}; + +export default MovieRecommendations; diff --git a/src/components/MovieDetails/MovieSimilar.tsx b/src/components/MovieDetails/MovieSimilar.tsx new file mode 100644 index 000000000..19ddbb732 --- /dev/null +++ b/src/components/MovieDetails/MovieSimilar.tsx @@ -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( + (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
{error}
; + } + + const titles = data?.reduce( + (a, v) => [...a, ...v.results], + [] as MovieResult[] + ); + + return ( + <> +
+
+

+ Similar Titles +

+
+
+ 0) + } + onScrollBottom={fetchMore} + /> + + ); +}; + +export default MovieSimilar; diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 53e1d906f..a6c794661 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -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 = ({ movie }) => { initialData: movie, } ); + const { data: recommended, error: recommendedError } = useSWR( + `/api/v1/movie/${router.query.movieId}/recommendations` + ); + const { data: similar, error: similarError } = useSWR( + `/api/v1/movie/${router.query.movieId}/similar` + ); const request = async () => { const response = await axios.post('/api/v1/request', { @@ -263,6 +280,97 @@ const MovieDetails: React.FC = ({ movie }) => { + + ( + + ))} + /> + + ( + + ))} + /> +
); }; diff --git a/src/pages/movie/[movieId].tsx b/src/pages/movie/[movieId]/index.tsx similarity index 89% rename from src/pages/movie/[movieId].tsx rename to src/pages/movie/[movieId]/index.tsx index 50b85dbab..fc5ae500a 100644 --- a/src/pages/movie/[movieId].tsx +++ b/src/pages/movie/[movieId]/index.tsx @@ -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 { diff --git a/src/pages/movie/[movieId]/recommendations.tsx b/src/pages/movie/[movieId]/recommendations.tsx new file mode 100644 index 000000000..3fd1d6226 --- /dev/null +++ b/src/pages/movie/[movieId]/recommendations.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { NextPage } from 'next'; +import MovieRecommendations from '../../../components/MovieDetails/MovieRecommendations'; + +const MovieRecommendationsPage: NextPage = () => { + return ; +}; + +export default MovieRecommendationsPage; diff --git a/src/pages/movie/[movieId]/similar.tsx b/src/pages/movie/[movieId]/similar.tsx new file mode 100644 index 000000000..90ee37d98 --- /dev/null +++ b/src/pages/movie/[movieId]/similar.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { NextPage } from 'next'; +import MovieSimilar from '../../../components/MovieDetails/MovieSimilar'; + +const MovieSimilarPage: NextPage = () => { + return ; +}; + +export default MovieSimilarPage;