diff --git a/overseerr-api.yml b/overseerr-api.yml index b4047e52..c6502b3f 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -3223,6 +3223,16 @@ paths: schema: type: string example: en + - in: query + name: genre + schema: + type: number + example: 10751 + - in: query + name: studio + schema: + type: number + example: 2 responses: '200': description: Results @@ -3301,6 +3311,16 @@ paths: schema: type: string example: en + - in: query + name: genre + schema: + type: number + example: 18 + - in: query + name: network + schema: + type: number + example: 1 responses: '200': description: Results @@ -4326,14 +4346,16 @@ paths: content: application/json: schema: - type: object - properties: - iso_3166_1: - type: string - example: US - english_name: - type: string - example: United States of America + type: array + items: + type: object + properties: + iso_3166_1: + type: string + example: US + english_name: + type: string + example: United States of America /languages: get: summary: Languages supported by TMDb @@ -4346,17 +4368,103 @@ paths: content: application/json: schema: - type: object - properties: - iso_639_1: - type: string - example: en - english_name: - type: string - example: English - name: - type: string - example: English + type: array + items: + type: object + properties: + iso_639_1: + type: string + example: en + english_name: + type: string + example: English + name: + type: string + example: English + /studio/{studioId}: + get: + summary: Get movie studio details + description: Returns movie studio details in a JSON object. + tags: + - tmdb + parameters: + - in: path + name: studioId + required: true + schema: + type: number + example: 2 + responses: + '200': + description: Movie studio details + content: + application/json: + schema: + $ref: '#/components/schemas/ProductionCompany' + /network/{networkId}: + get: + summary: Get TV network details + description: Returns TV network details in a JSON object. + tags: + - tmdb + parameters: + - in: path + name: networkId + required: true + schema: + type: number + example: 1 + responses: + '200': + description: TV network details + content: + application/json: + schema: + $ref: '#/components/schemas/ProductionCompany' + /genres/movie: + get: + summary: Get list of official TMDb movie genres + description: Returns a list of genres in a JSON array. + tags: + - tmdb + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: number + example: 10751 + name: + type: string + example: Family + /genres/tv: + get: + summary: Get list of official TMDb movie genres + description: Returns a list of genres in a JSON array. + tags: + - tmdb + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: number + example: 18 + name: + type: string + example: Drama security: - cookieAuth: [] diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index b7bfeb92..8fbedaa2 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -3,9 +3,13 @@ import cacheManager from '../../lib/cache'; import ExternalAPI from '../externalapi'; import { TmdbCollection, + TmdbStudio, TmdbExternalIdResponse, + TmdbGenre, + TmdbGenresResult, TmdbLanguage, TmdbMovieDetails, + TmdbNetwork, TmdbPersonCombinedCredits, TmdbPersonDetail, TmdbRegion, @@ -30,6 +34,8 @@ interface DiscoverMovieOptions { language?: string; primaryReleaseDateGte?: string; primaryReleaseDateLte?: string; + genre?: number; + studio?: number; sortBy?: | 'popularity.asc' | 'popularity.desc' @@ -53,6 +59,8 @@ interface DiscoverTvOptions { firstAirDateGte?: string; firstAirDateLte?: string; includeEmptyReleaseDate?: boolean; + genre?: number; + network?: number; sortBy?: | 'popularity.asc' | 'popularity.desc' @@ -120,7 +128,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch person details: ${e.message}`); } }; @@ -142,7 +150,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { throw new Error( - `[TMDB] Failed to fetch person combined credits: ${e.message}` + `[TMDb] Failed to fetch person combined credits: ${e.message}` ); } }; @@ -168,7 +176,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch movie details: ${e.message}`); } }; @@ -194,7 +202,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`); } }; @@ -220,7 +228,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`); } }; @@ -246,7 +254,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); } } @@ -272,7 +280,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); } } @@ -298,7 +306,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch movies by keyword: ${e.message}`); } } @@ -325,7 +333,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { throw new Error( - `[TMDB] Failed to fetch tv recommendations: ${e.message}` + `[TMDb] Failed to fetch TV recommendations: ${e.message}` ); } } @@ -349,7 +357,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch tv similar: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch TV similar: ${e.message}`); } } @@ -360,6 +368,8 @@ class TheMovieDb extends ExternalAPI { language = 'en', primaryReleaseDateGte, primaryReleaseDateLte, + genre, + studio, }: DiscoverMovieOptions = {}): Promise => { try { const data = await this.get('/discover/movie', { @@ -368,17 +378,18 @@ class TheMovieDb extends ExternalAPI { page, include_adult: includeAdult, language, - with_release_type: '3|2', region: this.region, with_original_language: this.originalLanguage, 'primary_release_date.gte': primaryReleaseDateGte, 'primary_release_date.lte': primaryReleaseDateLte, + with_genres: genre, + with_companies: studio, }, }); return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); } }; @@ -389,6 +400,8 @@ class TheMovieDb extends ExternalAPI { firstAirDateGte, firstAirDateLte, includeEmptyReleaseDate = false, + genre, + network, }: DiscoverTvOptions = {}): Promise => { try { const data = await this.get('/discover/tv', { @@ -401,12 +414,14 @@ class TheMovieDb extends ExternalAPI { 'first_air_date.lte': firstAirDateLte, with_original_language: this.originalLanguage, include_null_first_air_dates: includeEmptyReleaseDate, + with_genres: genre, + with_networks: network, }, }); return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch discover tv: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch discover TV: ${e.message}`); } }; @@ -432,7 +447,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch upcoming movies: ${e.message}`); } }; @@ -459,7 +474,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); } }; @@ -482,7 +497,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); } }; @@ -505,7 +520,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); } }; @@ -537,7 +552,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`); + throw new Error(`[TMDb] Failed to find by external ID: ${e.message}`); } } @@ -564,11 +579,11 @@ class TheMovieDb extends ExternalAPI { } throw new Error( - '[TMDB] Failed to find a title with the provided IMDB id' + '[TMDb] Failed to find a title with the provided IMDB id' ); } catch (e) { throw new Error( - `[TMDB] Failed to get movie by external imdb ID: ${e.message}` + `[TMDb] Failed to get movie by external imdb ID: ${e.message}` ); } } @@ -596,11 +611,11 @@ class TheMovieDb extends ExternalAPI { } throw new Error( - `[TMDB] Failed to find a TV show with the provided TVDB ID: ${tvdbId}` + `[TMDb] Failed to find a TV show with the provided TVDB ID: ${tvdbId}` ); } catch (e) { throw new Error( - `[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}` + `[TMDb] Failed to get TV show using the external TVDB ID: ${e.message}` ); } } @@ -624,7 +639,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch collection: ${e.message}`); } } @@ -640,7 +655,7 @@ class TheMovieDb extends ExternalAPI { return regions; } catch (e) { - throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch countries: ${e.message}`); } } @@ -656,7 +671,59 @@ class TheMovieDb extends ExternalAPI { return languages; } catch (e) { - throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch langauges: ${e.message}`); + } + } + + public async getStudio(studioId: number): Promise { + try { + const data = await this.get(`/company/${studioId}`); + + return data; + } catch (e) { + throw new Error(`[TMDb] Failed to fetch movie studio: ${e.message}`); + } + } + + public async getNetwork(networkId: number): Promise { + try { + const data = await this.get(`/network/${networkId}`); + + return data; + } catch (e) { + throw new Error(`[TMDb] Failed to fetch TV network: ${e.message}`); + } + } + + public async getMovieGenres(): Promise { + try { + const data = await this.get( + '/genre/movie/list', + {}, + 86400 // 24 hours + ); + + const movieGenres = sortBy(data.genres, 'name'); + + return movieGenres; + } catch (e) { + throw new Error(`[TMDb] Failed to fetch movie genres: ${e.message}`); + } + } + + public async getTvGenres(): Promise { + try { + const data = await this.get( + '/genre/tv/list', + {}, + 86400 // 24 hours + ); + + const tvGenres = sortBy(data.genres, 'name'); + + return tvGenres; + } catch (e) { + throw new Error(`[TMDb] Failed to fetch TV genres: ${e.message}`); } } } diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index 1b0da07e..51ae3f27 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -381,3 +381,32 @@ export interface TmdbLanguage { english_name: string; name: string; } + +export interface TmdbGenresResult { + genres: TmdbGenre[]; +} + +export interface TmdbGenre { + id: number; + name: string; +} + +export interface TmdbStudio { + id: number; + name: string; + description?: string; + headquarters?: string; + homepage?: string; + logo_path?: string; + origin_country?: string; + parent_company?: TmdbStudio; +} + +export interface TmdbNetwork { + id: number; + name: string; + headquarters?: string; + homepage?: string; + logo_path?: string; + origin_country?: string; +} diff --git a/server/routes/discover.ts b/server/routes/discover.ts index e248870a..4879b4b3 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -38,6 +38,8 @@ discoverRoutes.get('/movies', async (req, res) => { const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), language: req.query.language as string, + genre: req.query.genre ? Number(req.query.genre) : undefined, + studio: req.query.studio ? Number(req.query.studio) : undefined, }); const media = await Media.getRelatedMedia( @@ -99,6 +101,8 @@ discoverRoutes.get('/tv', async (req, res) => { const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), language: req.query.language as string, + genre: req.query.genre ? Number(req.query.genre) : undefined, + network: req.query.network ? Number(req.query.network) : undefined, }); const media = await Media.getRelatedMedia( diff --git a/server/routes/index.ts b/server/routes/index.ts index 7527c030..b4a41624 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -74,6 +74,38 @@ router.get('/languages', isAuthenticated(), async (req, res) => { return res.status(200).json(languages); }); +router.get<{ id: string }>('/studio/:id', async (req, res) => { + const tmdb = new TheMovieDb(); + + const studio = await tmdb.getStudio(Number(req.params.id)); + + return res.status(200).json(studio); +}); + +router.get<{ id: string }>('/network/:id', async (req, res) => { + const tmdb = new TheMovieDb(); + + const network = await tmdb.getNetwork(Number(req.params.id)); + + return res.status(200).json(network); +}); + +router.get('/genres/movie', isAuthenticated(), async (req, res) => { + const tmdb = new TheMovieDb(); + + const genres = await tmdb.getMovieGenres(); + + return res.status(200).json(genres); +}); + +router.get('/genres/tv', isAuthenticated(), async (req, res) => { + const tmdb = new TheMovieDb(); + + const genres = await tmdb.getTvGenres(); + + return res.status(200).json(genres); +}); + router.get('/', (_req, res) => { return res.status(200).json({ api: 'Overseerr API', diff --git a/src/components/Discover/DiscoverMovies.tsx b/src/components/Discover/DiscoverMovies.tsx index 4ebad143..fb96b740 100644 --- a/src/components/Discover/DiscoverMovies.tsx +++ b/src/components/Discover/DiscoverMovies.tsx @@ -1,16 +1,23 @@ import React, { useContext } from 'react'; -import { useSWRInfinite } from 'swr'; +import useSWR, { useSWRInfinite } from 'swr'; import type { MovieResult } from '../../../server/models/Search'; import ListView from '../Common/ListView'; import { LanguageContext } from '../../context/LanguageContext'; -import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; import Header from '../Common/Header'; import useSettings from '../../hooks/useSettings'; import { MediaStatus } from '../../../server/constants/media'; import PageTitle from '../Common/PageTitle'; +import { useRouter } from 'next/router'; +import { + TmdbStudio, + TmdbGenre, +} from '../../../server/api/themoviedb/interfaces'; const messages = defineMessages({ discovermovies: 'Popular Movies', + genreMovies: '{genre} Movies', + studioMovies: '{studio} Movies', }); interface SearchResult { @@ -21,16 +28,27 @@ interface SearchResult { } const DiscoverMovies: React.FC = () => { + const router = useRouter(); const intl = useIntl(); const settings = useSettings(); const { locale } = useContext(LanguageContext); + + const { data: genres } = useSWR('/api/v1/genres/movie'); + const genre = genres?.find((g) => g.id === Number(router.query.genreId)); + + const { data: studio } = useSWR( + `/api/v1/studio/${router.query.studioId}` + ); + const { data, error, size, setSize } = useSWRInfinite( (pageIndex: number, previousPageData: SearchResult | null) => { if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { return null; } - return `/api/v1/discover/movies?page=${pageIndex + 1}&language=${locale}`; + return `/api/v1/discover/movies?page=${pageIndex + 1}&language=${locale}${ + genre ? `&genre=${genre.id}` : '' + }${studio ? `&studio=${studio.id}` : ''}`; }, { initialSize: 3, @@ -68,13 +86,17 @@ const DiscoverMovies: React.FC = () => { const isReachingEnd = isEmpty || (data && data[data.length - 1]?.results.length < 20); + const title = genre + ? intl.formatMessage(messages.genreMovies, { genre: genre.name }) + : studio + ? intl.formatMessage(messages.studioMovies, { studio: studio.name }) + : intl.formatMessage(messages.discovermovies); + return ( <> - +
-
- -
+
{title}
{ + const router = useRouter(); const intl = useIntl(); const settings = useSettings(); const { locale } = useContext(LanguageContext); + + const { data: genres } = useSWR('/api/v1/genres/tv'); + const genre = genres?.find((g) => g.id === Number(router.query.genreId)); + + const { data: network } = useSWR( + `/api/v1/network/${router.query.networkId}` + ); + const { data, error, size, setSize } = useSWRInfinite( (pageIndex: number, previousPageData: SearchResult | null) => { if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { return null; } - return `/api/v1/discover/tv?page=${pageIndex + 1}&language=${locale}`; + return `/api/v1/discover/tv?page=${pageIndex + 1}&language=${locale}${ + genre ? `&genre=${genre.id}` : '' + }${network ? `&network=${network.id}` : ''}`; }, { initialSize: 3, @@ -67,13 +85,17 @@ const DiscoverTv: React.FC = () => { const isReachingEnd = isEmpty || (data && data[data.length - 1]?.results.length < 20); + const title = genre + ? intl.formatMessage(messages.genreSeries, { genre: genre.name }) + : network + ? intl.formatMessage(messages.networkSeries, { network: network.name }) + : intl.formatMessage(messages.discovertv); + return ( <> - +
-
- -
+
{title}
{ <>
-
- -
+
{intl.formatMessage(messages.upcomingtv)}
{ <>
-
- -
+
{intl.formatMessage(messages.trending)}
{ <>
-
- -
+
{intl.formatMessage(messages.upcomingmovies)}
{
- - - + {intl.formatMessage(messages.recentlyAdded)}
@@ -64,9 +62,7 @@ const Discover: React.FC = () => { )} diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 204cd61b..5902da47 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -189,7 +189,19 @@ const TvDetails: React.FC = ({ tv }) => { } if (data.genres.length) { - seriesAttributes.push(data.genres.map((g) => g.name).join(', ')); + seriesAttributes.push( + data.genres + .map((g) => ( + + {g.name} + + )) + .reduce((prev, curr) => ( + <> + {prev}, {curr} + + )) + ); } const isComplete = @@ -684,7 +696,20 @@ const TvDetails: React.FC = ({ tv }) => { {intl.formatMessage(messages.network)} - {data.networks.map((n) => n.name).join(', ')} + {data.networks + .map((n) => ( + + {n.name} + + )) + .reduce((prev, curr) => ( + <> + {prev}, {curr} + + ))} )} diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index edf19871..75f3ad26 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -17,11 +17,15 @@ "components.Discover.discover": "Discover", "components.Discover.discovermovies": "Popular Movies", "components.Discover.discovertv": "Popular Series", + "components.Discover.genreMovies": "{genre} Movies", + "components.Discover.genreSeries": "{genre} Series", + "components.Discover.networkSeries": "{network} Series", "components.Discover.nopending": "No Pending Requests", "components.Discover.popularmovies": "Popular Movies", "components.Discover.populartv": "Popular Series", "components.Discover.recentlyAdded": "Recently Added", "components.Discover.recentrequests": "Recent Requests", + "components.Discover.studioMovies": "{studio} Movies", "components.Discover.trending": "Trending", "components.Discover.upcoming": "Upcoming Movies", "components.Discover.upcomingmovies": "Upcoming Movies", diff --git a/src/pages/discover/movies/genre/[genreId]/index.tsx b/src/pages/discover/movies/genre/[genreId]/index.tsx new file mode 100644 index 00000000..f49e8169 --- /dev/null +++ b/src/pages/discover/movies/genre/[genreId]/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { NextPage } from 'next'; +import DiscoverMovies from '../../../../../components/Discover/DiscoverMovies'; + +const DiscoverMoviesGenrePage: NextPage = () => { + return ; +}; + +export default DiscoverMoviesGenrePage; diff --git a/src/pages/discover/movies/studio/[studioId]/index.tsx b/src/pages/discover/movies/studio/[studioId]/index.tsx new file mode 100644 index 00000000..e1371e60 --- /dev/null +++ b/src/pages/discover/movies/studio/[studioId]/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { NextPage } from 'next'; +import DiscoverMovies from '../../../../../components/Discover/DiscoverMovies'; + +const DiscoverMoviesStudioPage: NextPage = () => { + return ; +}; + +export default DiscoverMoviesStudioPage; diff --git a/src/pages/discover/tv/genre/[genreId]/index.tsx b/src/pages/discover/tv/genre/[genreId]/index.tsx new file mode 100644 index 00000000..344e5d9c --- /dev/null +++ b/src/pages/discover/tv/genre/[genreId]/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { NextPage } from 'next'; +import DiscoverTv from '../../../../../components/Discover/DiscoverTv'; + +const DiscoverTvGenrePage: NextPage = () => { + return ; +}; + +export default DiscoverTvGenrePage; diff --git a/src/pages/discover/tv/network/[networkId]/index.tsx b/src/pages/discover/tv/network/[networkId]/index.tsx new file mode 100644 index 00000000..b30f5377 --- /dev/null +++ b/src/pages/discover/tv/network/[networkId]/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { NextPage } from 'next'; +import DiscoverTv from '../../../../../components/Discover/DiscoverTv'; + +const DiscoverTvNetworkPage: NextPage = () => { + return ; +}; + +export default DiscoverTvNetworkPage;