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 ({
|
public getDiscoverMovies = async ({
|
||||||
sortBy = 'popularity.desc',
|
sortBy = 'popularity.desc',
|
||||||
page = 1,
|
page = 1,
|
||||||
|
@@ -1236,6 +1236,86 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/MovieDetails'
|
$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}:
|
/tv/{tvId}:
|
||||||
get:
|
get:
|
||||||
summary: Request tv details
|
summary: Request tv details
|
||||||
|
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
|||||||
import TheMovieDb from '../api/themoviedb';
|
import TheMovieDb from '../api/themoviedb';
|
||||||
import { mapMovieDetails } from '../models/Movie';
|
import { mapMovieDetails } from '../models/Movie';
|
||||||
import { MediaRequest } from '../entity/MediaRequest';
|
import { MediaRequest } from '../entity/MediaRequest';
|
||||||
|
import { mapMovieResult } from '../models/Search';
|
||||||
|
|
||||||
const movieRoutes = Router();
|
const movieRoutes = Router();
|
||||||
|
|
||||||
@@ -15,4 +16,54 @@ movieRoutes.get('/:id', async (req, res) => {
|
|||||||
return res.status(200).json(mapMovieDetails(movie, request));
|
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;
|
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 MovieRequestModal from '../RequestModal/MovieRequestModal';
|
||||||
import type { MediaRequest } from '../../../server/entity/MediaRequest';
|
import type { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||||
import axios from 'axios';
|
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 {
|
interface MovieDetailsProps {
|
||||||
movie?: MovieDetailsType;
|
movie?: MovieDetailsType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
page: number;
|
||||||
|
totalResults: number;
|
||||||
|
totalPages: number;
|
||||||
|
results: MovieResult[];
|
||||||
|
}
|
||||||
|
|
||||||
enum MediaRequestStatus {
|
enum MediaRequestStatus {
|
||||||
PENDING = 1,
|
PENDING = 1,
|
||||||
APPROVED,
|
APPROVED,
|
||||||
@@ -30,6 +41,12 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
initialData: 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 request = async () => {
|
||||||
const response = await axios.post<MediaRequest>('/api/v1/request', {
|
const response = await axios.post<MediaRequest>('/api/v1/request', {
|
||||||
@@ -263,6 +280,97 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NextPage } from 'next';
|
import { NextPage } from 'next';
|
||||||
import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie';
|
import type { MovieDetails as MovieDetailsType } from '../../../../server/models/Movie';
|
||||||
import MovieDetails from '../../components/MovieDetails';
|
import MovieDetails from '../../../components/MovieDetails';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
interface MoviePageProps {
|
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