mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat(frontend/api): movie recommendations/similar request and frontend detail page update
This commit is contained in:
@@ -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;
|
||||
|
70
src/components/MovieDetails/MovieRecommendations.tsx
Normal file
70
src/components/MovieDetails/MovieRecommendations.tsx
Normal file
@@ -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;
|
70
src/components/MovieDetails/MovieSimilar.tsx
Normal file
70
src/components/MovieDetails/MovieSimilar.tsx
Normal file
@@ -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 {
|
9
src/pages/movie/[movieId]/recommendations.tsx
Normal file
9
src/pages/movie/[movieId]/recommendations.tsx
Normal file
@@ -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;
|
9
src/pages/movie/[movieId]/similar.tsx
Normal file
9
src/pages/movie/[movieId]/similar.tsx
Normal file
@@ -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;
|
Reference in New Issue
Block a user