From 63c122e5e087a1d5baabe540bcd98acc3df1039b Mon Sep 17 00:00:00 2001 From: sct Date: Thu, 4 Mar 2021 10:18:46 +0000 Subject: [PATCH] refactor: move genre/studio/network calls into their own endpoints this commit also adds a `useDiscover` hook to help with creating discover pages with less repeating code --- overseerr-api.yml | 205 +++++++++++++++++- server/api/themoviedb/index.ts | 32 ++- server/api/themoviedb/interfaces.ts | 35 +-- server/models/Movie.ts | 20 +- server/models/Tv.ts | 20 +- server/models/common.ts | 12 + server/routes/discover.ts | 152 +++++++++++++ server/routes/index.ts | 6 +- .../Discover/DiscoverMovieGenre/index.tsx | 62 ++++++ src/components/Discover/DiscoverMovies.tsx | 88 ++------ .../Discover/DiscoverNetwork/index.tsx | 75 +++++++ .../Discover/DiscoverStudio/index.tsx | 75 +++++++ src/components/Discover/DiscoverTv.tsx | 87 ++------ .../Discover/DiscoverTvGenre/index.tsx | 62 ++++++ .../Discover/DiscoverTvUpcoming.tsx | 67 ++---- src/components/Discover/Trending.tsx | 69 ++---- src/components/Discover/Upcoming.tsx | 67 ++---- src/hooks/useDiscover.ts | 99 +++++++++ src/i18n/globalMessages.ts | 1 + .../discover/movies/genre/[genreId]/index.tsx | 4 +- .../movies/studio/[studioId]/index.tsx | 4 +- .../discover/tv/genre/[genreId]/index.tsx | 4 +- .../discover/tv/network/[networkId]/index.tsx | 4 +- 23 files changed, 885 insertions(+), 365 deletions(-) create mode 100644 src/components/Discover/DiscoverMovieGenre/index.tsx create mode 100644 src/components/Discover/DiscoverNetwork/index.tsx create mode 100644 src/components/Discover/DiscoverStudio/index.tsx create mode 100644 src/components/Discover/DiscoverTvGenre/index.tsx create mode 100644 src/hooks/useDiscover.ts diff --git a/overseerr-api.yml b/overseerr-api.yml index c6502b3f6..63c874e69 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -574,6 +574,19 @@ components: type: string name: type: string + Network: + type: object + properties: + id: + type: number + example: 1 + logoPath: + type: string + nullable: true + originCountry: + type: string + name: + type: string RelatedVideo: type: object properties: @@ -3227,12 +3240,12 @@ paths: name: genre schema: type: number - example: 10751 + example: 18 - in: query name: studio schema: type: number - example: 2 + example: 1 responses: '200': description: Results @@ -3254,6 +3267,100 @@ paths: type: array items: $ref: '#/components/schemas/MovieResult' + /discover/movies/genre/{genreId}: + get: + summary: Discover movies by genre + description: Returns a list of movies based on the provided genre ID in a JSON object. + tags: + - search + parameters: + - in: path + name: genreId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + genre: + $ref: '#/components/schemas/Genre' + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/movies/studio/{studioId}: + get: + summary: Discover movies by studio + description: Returns a list of movies based on the provided studio ID in a JSON object. + tags: + - search + parameters: + - in: path + name: studioId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + studio: + $ref: '#/components/schemas/ProductionCompany' + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' /discover/movies/upcoming: get: summary: Upcoming movies @@ -3342,6 +3449,100 @@ paths: type: array items: $ref: '#/components/schemas/TvResult' + /discover/tv/genre/{genreId}: + get: + summary: Discover TV shows by genre + description: Returns a list of TV shows based on the provided genre ID in a JSON object. + tags: + - search + parameters: + - in: path + name: genreId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + genre: + $ref: '#/components/schemas/Genre' + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/tv/network/{networkId}: + get: + summary: Discover TV shows by network + description: Returns a list of TV shows based on the provided network ID in a JSON object. + tags: + - search + parameters: + - in: path + name: networkId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + network: + $ref: '#/components/schemas/Network' + results: + type: array + items: + $ref: '#/components/schemas/TvResult' /discover/tv/upcoming: get: summary: Discover Upcoming TV shows diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 8fbedaa2e..6a0595202 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -3,7 +3,6 @@ import cacheManager from '../../lib/cache'; import ExternalAPI from '../externalapi'; import { TmdbCollection, - TmdbStudio, TmdbExternalIdResponse, TmdbGenre, TmdbGenresResult, @@ -19,6 +18,7 @@ import { TmdbSeasonWithEpisodes, TmdbTvDetails, TmdbUpcomingMoviesResponse, + TmdbProductionCompany, } from './interfaces'; interface SearchOptions { @@ -675,9 +675,11 @@ class TheMovieDb extends ExternalAPI { } } - public async getStudio(studioId: number): Promise { + public async getStudio(studioId: number): Promise { try { - const data = await this.get(`/company/${studioId}`); + const data = await this.get( + `/company/${studioId}` + ); return data; } catch (e) { @@ -695,11 +697,19 @@ class TheMovieDb extends ExternalAPI { } } - public async getMovieGenres(): Promise { + public async getMovieGenres({ + language = 'en', + }: { + language?: string; + } = {}): Promise { try { const data = await this.get( '/genre/movie/list', - {}, + { + params: { + language, + }, + }, 86400 // 24 hours ); @@ -711,11 +721,19 @@ class TheMovieDb extends ExternalAPI { } } - public async getTvGenres(): Promise { + public async getTvGenres({ + language = 'en', + }: { + language?: string; + } = {}): Promise { try { const data = await this.get( '/genre/tv/list', - {}, + { + params: { + language, + }, + }, 86400 // 24 hours ); diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index 51ae3f270..ebe4088ac 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -109,6 +109,16 @@ export interface TmdbExternalIds { twitter_id?: string; } +export interface TmdbProductionCompany { + id: number; + logo_path?: string; + name: string; + origin_country: string; + homepage?: string; + headquarters?: string; + description?: string; +} + export interface TmdbMovieDetails { id: number; imdb_id?: string; @@ -125,12 +135,7 @@ export interface TmdbMovieDetails { original_title: string; overview?: string; popularity: number; - production_companies: { - id: number; - name: string; - logo_path?: string; - origin_country: string; - }[]; + production_companies: TmdbProductionCompany[]; production_countries: { iso_3166_1: string; name: string; @@ -227,12 +232,7 @@ export interface TmdbTvDetails { last_episode_to_air?: TmdbTvEpisodeResult; name: string; next_episode_to_air?: TmdbTvEpisodeResult; - networks: { - id: number; - name: string; - logo_path: string; - origin_country: string; - }[]; + networks: TmdbNetwork[]; number_of_episodes: number; number_of_seasons: number; origin_country: string[]; @@ -391,17 +391,6 @@ export interface TmdbGenre { 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; diff --git a/server/models/Movie.ts b/server/models/Movie.ts index be4828ec6..58b4fff69 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -1,6 +1,7 @@ import type { TmdbMovieDetails, TmdbMovieReleaseResult, + TmdbProductionCompany, } from '../api/themoviedb/interfaces'; import { ProductionCompany, @@ -79,6 +80,18 @@ export interface MovieDetails { plexUrl?: string; } +export const mapProductionCompany = ( + company: TmdbProductionCompany +): ProductionCompany => ({ + id: company.id, + name: company.name, + originCountry: company.origin_country, + description: company.description, + headquarters: company.headquarters, + homepage: company.homepage, + logoPath: company.logo_path, +}); + export const mapMovieDetails = ( movie: TmdbMovieDetails, media?: Media @@ -91,12 +104,7 @@ export const mapMovieDetails = ( originalLanguage: movie.original_language, originalTitle: movie.original_title, popularity: movie.popularity, - productionCompanies: movie.production_companies.map((company) => ({ - id: company.id, - logoPath: company.logo_path, - originCountry: company.origin_country, - name: company.name, - })), + productionCompanies: movie.production_companies.map(mapProductionCompany), productionCountries: movie.production_countries, releaseDate: movie.release_date, releases: movie.release_dates, diff --git a/server/models/Tv.ts b/server/models/Tv.ts index 3631573ea..438997c95 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -9,6 +9,7 @@ import { mapExternalIds, Keyword, mapVideos, + TvNetwork, } from './common'; import type { TmdbTvEpisodeResult, @@ -16,6 +17,7 @@ import type { TmdbTvDetails, TmdbSeasonWithEpisodes, TmdbTvRatingResult, + TmdbNetwork, } from '../api/themoviedb/interfaces'; import type Media from '../entity/Media'; import { Video } from './Movie'; @@ -77,7 +79,7 @@ export interface TvDetails { lastEpisodeToAir?: Episode; name: string; nextEpisodeToAir?: Episode; - networks: ProductionCompany[]; + networks: TvNetwork[]; numberOfEpisodes: number; numberOfSeasons: number; originCountry: string[]; @@ -139,6 +141,15 @@ export const mapSeasonWithEpisodes = ( posterPath: season.poster_path, }); +export const mapNetwork = (network: TmdbNetwork): TvNetwork => ({ + id: network.id, + name: network.name, + originCountry: network.origin_country, + headquarters: network.headquarters, + homepage: network.homepage, + logoPath: network.logo_path, +}); + export const mapTvDetails = ( show: TmdbTvDetails, media?: Media @@ -157,12 +168,7 @@ export const mapTvDetails = ( languages: show.languages, lastAirDate: show.last_air_date, name: show.name, - networks: show.networks.map((network) => ({ - id: network.id, - name: network.name, - originCountry: network.origin_country, - logoPath: network.logo_path, - })), + networks: show.networks.map(mapNetwork), numberOfEpisodes: show.number_of_episodes, numberOfSeasons: show.number_of_seasons, originCountry: show.origin_country, diff --git a/server/models/common.ts b/server/models/common.ts index d26cf6379..be276562a 100644 --- a/server/models/common.ts +++ b/server/models/common.ts @@ -14,6 +14,18 @@ export interface ProductionCompany { logoPath?: string; originCountry: string; name: string; + description?: string; + headquarters?: string; + homepage?: string; +} + +export interface TvNetwork { + id: number; + logoPath?: string; + originCountry?: string; + name: string; + headquarters?: string; + homepage?: string; } export interface Keyword { diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 4879b4b3b..88f0fce51 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -6,6 +6,8 @@ import { isMovie, isPerson } from '../utils/typeHelpers'; import { MediaType } from '../constants/media'; import { getSettings } from '../lib/settings'; import { User } from '../entity/User'; +import { mapProductionCompany } from '../models/Movie'; +import { mapNetwork } from '../models/Tv'; const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => { const settings = getSettings(); @@ -61,6 +63,82 @@ discoverRoutes.get('/movies', async (req, res) => { }); }); +discoverRoutes.get<{ genreId: string }>( + '/movies/genre/:genreId', + async (req, res) => { + const tmdb = createTmdbWithRegionLanaguage(req.user); + + const genres = await tmdb.getMovieGenres({ + language: req.query.language as string, + }); + + const genre = genres.find( + (genre) => genre.id === Number(req.params.genreId) + ); + + const data = await tmdb.getDiscoverMovies({ + page: Number(req.query.page), + language: req.query.language as string, + genre: Number(req.params.genreId), + }); + + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + genre, + results: data.results.map((result) => + mapMovieResult( + result, + media.find( + (req) => + req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + ) + ) + ), + }); + } +); + +discoverRoutes.get<{ studioId: string }>( + '/movies/studio/:studioId', + async (req, res) => { + const tmdb = createTmdbWithRegionLanaguage(req.user); + + const studio = await tmdb.getStudio(Number(req.params.studioId)); + + const data = await tmdb.getDiscoverMovies({ + page: Number(req.query.page), + language: req.query.language as string, + studio: Number(req.params.studioId), + }); + + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + studio: mapProductionCompany(studio), + results: data.results.map((result) => + mapMovieResult( + result, + media.find( + (req) => + req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + ) + ) + ), + }); + } +); + discoverRoutes.get('/movies/upcoming', async (req, res) => { const tmdb = createTmdbWithRegionLanaguage(req.user); @@ -124,6 +202,80 @@ discoverRoutes.get('/tv', async (req, res) => { }); }); +discoverRoutes.get<{ genreId: string }>( + '/tv/genre/:genreId', + async (req, res) => { + const tmdb = createTmdbWithRegionLanaguage(req.user); + + const genres = await tmdb.getTvGenres({ + language: req.query.language as string, + }); + + const genre = genres.find( + (genre) => genre.id === Number(req.params.genreId) + ); + + const data = await tmdb.getDiscoverTv({ + page: Number(req.query.page), + language: req.query.language as string, + genre: Number(req.params.genreId), + }); + + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + genre, + results: data.results.map((result) => + mapTvResult( + result, + media.find( + (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV + ) + ) + ), + }); + } +); + +discoverRoutes.get<{ networkId: string }>( + '/tv/network/:networkId', + async (req, res) => { + const tmdb = createTmdbWithRegionLanaguage(req.user); + + const network = await tmdb.getNetwork(Number(req.params.networkId)); + + const data = await tmdb.getDiscoverTv({ + page: Number(req.query.page), + language: req.query.language as string, + network: Number(req.params.networkId), + }); + + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + network: mapNetwork(network), + results: data.results.map((result) => + mapTvResult( + result, + media.find( + (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV + ) + ) + ), + }); + } +); + discoverRoutes.get('/tv/upcoming', async (req, res) => { const tmdb = createTmdbWithRegionLanaguage(req.user); diff --git a/server/routes/index.ts b/server/routes/index.ts index b4a416246..91dcd9ef1 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -17,6 +17,8 @@ import { getAppVersion, getCommitTag } from '../utils/appVersion'; import serviceRoutes from './service'; import { appDataStatus, appDataPath } from '../utils/appDataVolume'; import TheMovieDb from '../api/themoviedb'; +import { mapProductionCompany } from '../models/Movie'; +import { mapNetwork } from '../models/Tv'; const router = Router(); @@ -79,7 +81,7 @@ router.get<{ id: string }>('/studio/:id', async (req, res) => { const studio = await tmdb.getStudio(Number(req.params.id)); - return res.status(200).json(studio); + return res.status(200).json(mapProductionCompany(studio)); }); router.get<{ id: string }>('/network/:id', async (req, res) => { @@ -87,7 +89,7 @@ router.get<{ id: string }>('/network/:id', async (req, res) => { const network = await tmdb.getNetwork(Number(req.params.id)); - return res.status(200).json(network); + return res.status(200).json(mapNetwork(network)); }); router.get('/genres/movie', isAuthenticated(), async (req, res) => { diff --git a/src/components/Discover/DiscoverMovieGenre/index.tsx b/src/components/Discover/DiscoverMovieGenre/index.tsx new file mode 100644 index 000000000..e340f4eb9 --- /dev/null +++ b/src/components/Discover/DiscoverMovieGenre/index.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import type { MovieResult } from '../../../../server/models/Search'; +import ListView from '../../Common/ListView'; +import { defineMessages, useIntl } from 'react-intl'; +import Header from '../../Common/Header'; +import PageTitle from '../../Common/PageTitle'; +import { useRouter } from 'next/router'; +import globalMessages from '../../../i18n/globalMessages'; +import useDiscover from '../../../hooks/useDiscover'; +import Error from '../../../pages/_error'; + +const messages = defineMessages({ + genreMovies: '{genre} Movies', +}); + +const DiscoverMovieGenre: React.FC = () => { + const router = useRouter(); + const intl = useIntl(); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + firstResultData, + } = useDiscover( + `/api/v1/discover/movies/genre/${router.query.genreId}` + ); + + if (error) { + return ; + } + + const title = isLoadingInitialData + ? intl.formatMessage(globalMessages.loading) + : intl.formatMessage(messages.genreMovies, { + genre: firstResultData?.genre.name, + }); + + return ( + <> + +
+
{title}
+
+ 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverMovieGenre; diff --git a/src/components/Discover/DiscoverMovies.tsx b/src/components/Discover/DiscoverMovies.tsx index fb96b740f..a84e83eff 100644 --- a/src/components/Discover/DiscoverMovies.tsx +++ b/src/components/Discover/DiscoverMovies.tsx @@ -1,18 +1,11 @@ -import React, { useContext } from 'react'; -import useSWR, { useSWRInfinite } from 'swr'; +import React from 'react'; import type { MovieResult } from '../../../server/models/Search'; import ListView from '../Common/ListView'; -import { LanguageContext } from '../../context/LanguageContext'; 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'; +import useDiscover from '../../hooks/useDiscover'; +import Error from '../../pages/_error'; const messages = defineMessages({ discovermovies: 'Popular Movies', @@ -20,77 +13,24 @@ const messages = defineMessages({ studioMovies: '{studio} Movies', }); -interface SearchResult { - page: number; - totalResults: number; - totalPages: number; - results: MovieResult[]; -} - 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}${ - genre ? `&genre=${genre.id}` : '' - }${studio ? `&studio=${studio.id}` : ''}`; - }, - { - initialSize: 3, - } - ); - const isLoadingInitialData = !data && !error; - const isLoadingMore = - isLoadingInitialData || - (size > 0 && data && typeof data[size - 1] === 'undefined'); - - const fetchMore = () => { - setSize(size + 1); - }; + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover('/api/v1/discover/movies'); if (error) { - return
{error}
; - } - - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as MovieResult[] - ); - - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - (i.mediaType === 'movie' || i.mediaType === 'tv') && - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); + return ; } - const isEmpty = !isLoadingInitialData && titles?.length === 0; - 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); + const title = intl.formatMessage(messages.discovermovies); return ( <> diff --git a/src/components/Discover/DiscoverNetwork/index.tsx b/src/components/Discover/DiscoverNetwork/index.tsx new file mode 100644 index 000000000..b73171c40 --- /dev/null +++ b/src/components/Discover/DiscoverNetwork/index.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import type { TvResult } from '../../../../server/models/Search'; +import ListView from '../../Common/ListView'; +import { defineMessages, useIntl } from 'react-intl'; +import Header from '../../Common/Header'; +import PageTitle from '../../Common/PageTitle'; +import { useRouter } from 'next/router'; +import globalMessages from '../../../i18n/globalMessages'; +import useDiscover from '../../../hooks/useDiscover'; +import Error from '../../../pages/_error'; +import { TvNetwork } from '../../../../server/models/common'; + +const messages = defineMessages({ + networkSeries: '{network} Series', +}); + +const DiscoverTvNetwork: React.FC = () => { + const router = useRouter(); + const intl = useIntl(); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + firstResultData, + } = useDiscover( + `/api/v1/discover/tv/network/${router.query.networkId}` + ); + + if (error) { + return ; + } + + const title = isLoadingInitialData + ? intl.formatMessage(globalMessages.loading) + : intl.formatMessage(messages.networkSeries, { + network: firstResultData?.network.name, + }); + + return ( + <> + +
+
+ {firstResultData?.network.logoPath ? ( +
+ +
+ ) : ( + title + )} +
+
+ 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverTvNetwork; diff --git a/src/components/Discover/DiscoverStudio/index.tsx b/src/components/Discover/DiscoverStudio/index.tsx new file mode 100644 index 000000000..beca4a3f6 --- /dev/null +++ b/src/components/Discover/DiscoverStudio/index.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import type { MovieResult } from '../../../../server/models/Search'; +import ListView from '../../Common/ListView'; +import { defineMessages, useIntl } from 'react-intl'; +import Header from '../../Common/Header'; +import PageTitle from '../../Common/PageTitle'; +import { useRouter } from 'next/router'; +import globalMessages from '../../../i18n/globalMessages'; +import useDiscover from '../../../hooks/useDiscover'; +import Error from '../../../pages/_error'; +import { ProductionCompany } from '../../../../server/models/common'; + +const messages = defineMessages({ + studioMovies: '{studio} Movies', +}); + +const DiscoverMovieStudio: React.FC = () => { + const router = useRouter(); + const intl = useIntl(); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + firstResultData, + } = useDiscover( + `/api/v1/discover/movies/studio/${router.query.studioId}` + ); + + if (error) { + return ; + } + + const title = isLoadingInitialData + ? intl.formatMessage(globalMessages.loading) + : intl.formatMessage(messages.studioMovies, { + studio: firstResultData?.studio.name, + }); + + return ( + <> + +
+
+ {firstResultData?.studio.logoPath ? ( +
+ +
+ ) : ( + title + )} +
+
+ 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverMovieStudio; diff --git a/src/components/Discover/DiscoverTv.tsx b/src/components/Discover/DiscoverTv.tsx index 05243dfa3..b7e312378 100644 --- a/src/components/Discover/DiscoverTv.tsx +++ b/src/components/Discover/DiscoverTv.tsx @@ -1,18 +1,11 @@ -import React, { useContext } from 'react'; -import useSWR, { useSWRInfinite } from 'swr'; +import React from 'react'; import type { TvResult } from '../../../server/models/Search'; import ListView from '../Common/ListView'; import { defineMessages, useIntl } from 'react-intl'; -import { LanguageContext } from '../../context/LanguageContext'; 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 { - TmdbGenre, - TmdbNetwork, -} from '../../../server/api/themoviedb/interfaces'; +import useDiscover from '../../hooks/useDiscover'; +import Error from '../../pages/_error'; const messages = defineMessages({ discovertv: 'Popular Series', @@ -20,76 +13,24 @@ const messages = defineMessages({ networkSeries: '{network} Series', }); -interface SearchResult { - page: number; - totalResults: number; - totalPages: number; - results: TvResult[]; -} - const DiscoverTv: React.FC = () => { - 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}${ - genre ? `&genre=${genre.id}` : '' - }${network ? `&network=${network.id}` : ''}`; - }, - { - initialSize: 3, - } - ); - const isLoadingInitialData = !data && !error; - const isLoadingMore = - isLoadingInitialData || - (size > 0 && data && typeof data[size - 1] === 'undefined'); - - const fetchMore = () => { - setSize(size + 1); - }; + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover('/api/v1/discover/tv'); if (error) { - return
{error}
; - } - - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as TvResult[] - ); - - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); + return ; } - const isEmpty = !isLoadingInitialData && titles?.length === 0; - 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); + const title = intl.formatMessage(messages.discovertv); return ( <> diff --git a/src/components/Discover/DiscoverTvGenre/index.tsx b/src/components/Discover/DiscoverTvGenre/index.tsx new file mode 100644 index 000000000..d4b672a5d --- /dev/null +++ b/src/components/Discover/DiscoverTvGenre/index.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import type { TvResult } from '../../../../server/models/Search'; +import ListView from '../../Common/ListView'; +import { defineMessages, useIntl } from 'react-intl'; +import Header from '../../Common/Header'; +import PageTitle from '../../Common/PageTitle'; +import { useRouter } from 'next/router'; +import globalMessages from '../../../i18n/globalMessages'; +import useDiscover from '../../../hooks/useDiscover'; +import Error from '../../../pages/_error'; + +const messages = defineMessages({ + genreSeries: '{genre} Series', +}); + +const DiscoverTvGenre: React.FC = () => { + const router = useRouter(); + const intl = useIntl(); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + firstResultData, + } = useDiscover( + `/api/v1/discover/tv/genre/${router.query.genreId}` + ); + + if (error) { + return ; + } + + const title = isLoadingInitialData + ? intl.formatMessage(globalMessages.loading) + : intl.formatMessage(messages.genreSeries, { + genre: firstResultData?.genre.name, + }); + + return ( + <> + +
+
{title}
+
+ 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverTvGenre; diff --git a/src/components/Discover/DiscoverTvUpcoming.tsx b/src/components/Discover/DiscoverTvUpcoming.tsx index bd2c13160..5b59f26a2 100644 --- a/src/components/Discover/DiscoverTvUpcoming.tsx +++ b/src/components/Discover/DiscoverTvUpcoming.tsx @@ -1,74 +1,33 @@ -import React, { useContext } from 'react'; -import { useSWRInfinite } from 'swr'; +import React from 'react'; import type { TvResult } from '../../../server/models/Search'; import ListView from '../Common/ListView'; import { defineMessages, useIntl } from 'react-intl'; -import { LanguageContext } from '../../context/LanguageContext'; import Header from '../Common/Header'; -import useSettings from '../../hooks/useSettings'; -import { MediaStatus } from '../../../server/constants/media'; import PageTitle from '../Common/PageTitle'; +import useDiscover from '../../hooks/useDiscover'; +import Error from '../../pages/_error'; const messages = defineMessages({ upcomingtv: 'Upcoming Series', }); -interface SearchResult { - page: number; - totalResults: number; - totalPages: number; - results: TvResult[]; -} - const DiscoverTvUpcoming: React.FC = () => { const intl = useIntl(); - const settings = useSettings(); - const { locale } = useContext(LanguageContext); - const { data, error, size, setSize } = useSWRInfinite( - (pageIndex: number, previousPageData: SearchResult | null) => { - if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { - return null; - } - - return `/api/v1/discover/tv/upcoming?page=${ - pageIndex + 1 - }&language=${locale}`; - }, - { - initialSize: 3, - } - ); - const isLoadingInitialData = !data && !error; - const isLoadingMore = - isLoadingInitialData || - (size > 0 && data && typeof data[size - 1] === 'undefined'); - - const fetchMore = () => { - setSize(size + 1); - }; + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover('/api/v1/discover/tv/upcoming'); if (error) { - return
{error}
; + return ; } - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as TvResult[] - ); - - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); - } - - const isEmpty = !isLoadingInitialData && titles?.length === 0; - const isReachingEnd = - isEmpty || (data && data[data.length - 1]?.results.length < 20); - return ( <> diff --git a/src/components/Discover/Trending.tsx b/src/components/Discover/Trending.tsx index 45359bf10..c0f2e222d 100644 --- a/src/components/Discover/Trending.tsx +++ b/src/components/Discover/Trending.tsx @@ -1,79 +1,38 @@ -import React, { useContext } from 'react'; -import { useSWRInfinite } from 'swr'; +import React from 'react'; import type { MovieResult, TvResult, PersonResult, } from '../../../server/models/Search'; import ListView from '../Common/ListView'; -import { LanguageContext } from '../../context/LanguageContext'; 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 useDiscover from '../../hooks/useDiscover'; +import Error from '../../pages/_error'; const messages = defineMessages({ trending: 'Trending', }); -interface SearchResult { - page: number; - totalResults: number; - totalPages: number; - results: (MovieResult | TvResult | PersonResult)[]; -} - const Trending: React.FC = () => { const intl = useIntl(); - const settings = useSettings(); - const { locale } = useContext(LanguageContext); - const { data, error, size, setSize } = useSWRInfinite( - (pageIndex: number, previousPageData: SearchResult | null) => { - if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { - return null; - } - - return `/api/v1/discover/trending?page=${ - pageIndex + 1 - }&language=${locale}`; - }, - { - initialSize: 3, - } + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover( + '/api/v1/discover/trending' ); - const isLoadingInitialData = !data && !error; - const isLoadingMore = - isLoadingInitialData || - (size > 0 && data && typeof data[size - 1] === 'undefined'); - - const fetchMore = () => { - setSize(size + 1); - }; - if (error) { - return
{error}
; + return ; } - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as (MovieResult | TvResult | PersonResult)[] - ); - - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - (i.mediaType === 'movie' || i.mediaType === 'tv') && - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); - } - - const isEmpty = !isLoadingInitialData && titles?.length === 0; - const isReachingEnd = - isEmpty || (data && data[data.length - 1]?.results.length < 20); - return ( <> diff --git a/src/components/Discover/Upcoming.tsx b/src/components/Discover/Upcoming.tsx index e89eb9950..1e14f73dc 100644 --- a/src/components/Discover/Upcoming.tsx +++ b/src/components/Discover/Upcoming.tsx @@ -1,74 +1,33 @@ -import React, { useContext } from 'react'; -import { useSWRInfinite } from 'swr'; +import React from 'react'; import type { MovieResult } from '../../../server/models/Search'; import ListView from '../Common/ListView'; -import { LanguageContext } from '../../context/LanguageContext'; 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 useDiscover from '../../hooks/useDiscover'; +import Error from '../../pages/_error'; const messages = defineMessages({ upcomingmovies: 'Upcoming Movies', }); -interface SearchResult { - page: number; - totalResults: number; - totalPages: number; - results: MovieResult[]; -} - const UpcomingMovies: React.FC = () => { const intl = useIntl(); - const settings = useSettings(); - const { locale } = useContext(LanguageContext); - const { data, error, size, setSize } = useSWRInfinite( - (pageIndex: number, previousPageData: SearchResult | null) => { - if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { - return null; - } - - return `/api/v1/discover/movies/upcoming?page=${ - pageIndex + 1 - }&language=${locale}`; - }, - { - initialSize: 3, - } - ); - const isLoadingInitialData = !data && !error; - const isLoadingMore = - isLoadingInitialData || - (size > 0 && data && typeof data[size - 1] === 'undefined'); - - const fetchMore = () => { - setSize(size + 1); - }; + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover('/api/v1/discover/movies/upcoming'); if (error) { - return
{error}
; + return ; } - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as MovieResult[] - ); - - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); - } - - const isEmpty = !isLoadingInitialData && titles?.length === 0; - const isReachingEnd = - isEmpty || (data && data[data.length - 1]?.results.length < 20); - return ( <> diff --git a/src/hooks/useDiscover.ts b/src/hooks/useDiscover.ts new file mode 100644 index 000000000..57bf26b04 --- /dev/null +++ b/src/hooks/useDiscover.ts @@ -0,0 +1,99 @@ +import { useContext } from 'react'; +import { useSWRInfinite } from 'swr'; +import { MediaStatus } from '../../server/constants/media'; +import { LanguageContext } from '../context/LanguageContext'; +import useSettings from './useSettings'; + +export interface BaseSearchResult { + page: number; + totalResults: number; + totalPages: number; + results: T[]; +} + +interface BaseMedia { + mediaType: string; + mediaInfo?: { + status: MediaStatus; + }; +} + +interface DiscoverResult { + isLoadingInitialData: boolean; + isLoadingMore: boolean; + fetchMore: () => void; + isEmpty: boolean; + isReachingEnd: boolean; + error: unknown; + titles: T[]; + firstResultData?: BaseSearchResult & S; +} + +const useDiscover = >( + endpoint: string, + options?: Record +): DiscoverResult => { + const settings = useSettings(); + const { locale } = useContext(LanguageContext); + const { data, error, size, setSize } = useSWRInfinite< + BaseSearchResult & S + >( + (pageIndex: number, previousPageData) => { + if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { + return null; + } + + const params: Record = { + page: pageIndex + 1, + language: locale, + ...options, + }; + + const finalQueryString = Object.keys(params) + .map((paramKey) => `${paramKey}=${params[paramKey]}`) + .join('&'); + + return `${endpoint}?${finalQueryString}`; + }, + { + initialSize: 3, + } + ); + + const isLoadingInitialData = !data && !error; + const isLoadingMore = + isLoadingInitialData || + (size > 0 && !!data && typeof data[size - 1] === 'undefined'); + + const fetchMore = () => { + setSize(size + 1); + }; + + let titles = (data ?? []).reduce((a, v) => [...a, ...v.results], [] as T[]); + + if (settings.currentSettings.hideAvailable) { + titles = titles.filter( + (i) => + (i.mediaType === 'movie' || i.mediaType === 'tv') && + i.mediaInfo?.status !== MediaStatus.AVAILABLE && + i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + } + + const isEmpty = !isLoadingInitialData && titles?.length === 0; + const isReachingEnd = + isEmpty || (!!data && (data[data?.length - 1]?.results.length ?? 0) < 20); + + return { + isLoadingInitialData, + isLoadingMore, + fetchMore, + isEmpty, + isReachingEnd, + error, + titles, + firstResultData: data?.[0], + }; +}; + +export default useDiscover; diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts index 4c30d1efc..daaace5e8 100644 --- a/src/i18n/globalMessages.ts +++ b/src/i18n/globalMessages.ts @@ -23,6 +23,7 @@ const globalMessages = defineMessages({ edit: 'Edit', experimental: 'Experimental', advanced: 'Advanced', + loading: 'Loading…', }); export default globalMessages; diff --git a/src/pages/discover/movies/genre/[genreId]/index.tsx b/src/pages/discover/movies/genre/[genreId]/index.tsx index f49e81697..71fd2b01d 100644 --- a/src/pages/discover/movies/genre/[genreId]/index.tsx +++ b/src/pages/discover/movies/genre/[genreId]/index.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { NextPage } from 'next'; -import DiscoverMovies from '../../../../../components/Discover/DiscoverMovies'; +import DiscoverMovieGenre from '../../../../../components/Discover/DiscoverMovieGenre'; const DiscoverMoviesGenrePage: NextPage = () => { - return ; + return ; }; export default DiscoverMoviesGenrePage; diff --git a/src/pages/discover/movies/studio/[studioId]/index.tsx b/src/pages/discover/movies/studio/[studioId]/index.tsx index e1371e60d..4756ffbfe 100644 --- a/src/pages/discover/movies/studio/[studioId]/index.tsx +++ b/src/pages/discover/movies/studio/[studioId]/index.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { NextPage } from 'next'; -import DiscoverMovies from '../../../../../components/Discover/DiscoverMovies'; +import DiscoverMovieStudio from '../../../../../components/Discover/DiscoverStudio'; const DiscoverMoviesStudioPage: NextPage = () => { - return ; + return ; }; export default DiscoverMoviesStudioPage; diff --git a/src/pages/discover/tv/genre/[genreId]/index.tsx b/src/pages/discover/tv/genre/[genreId]/index.tsx index 344e5d9c0..bb5cbd0e8 100644 --- a/src/pages/discover/tv/genre/[genreId]/index.tsx +++ b/src/pages/discover/tv/genre/[genreId]/index.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { NextPage } from 'next'; -import DiscoverTv from '../../../../../components/Discover/DiscoverTv'; +import DiscoverTvGenre from '../../../../../components/Discover/DiscoverTvGenre'; const DiscoverTvGenrePage: NextPage = () => { - return ; + return ; }; export default DiscoverTvGenrePage; diff --git a/src/pages/discover/tv/network/[networkId]/index.tsx b/src/pages/discover/tv/network/[networkId]/index.tsx index b30f53774..d864a4f92 100644 --- a/src/pages/discover/tv/network/[networkId]/index.tsx +++ b/src/pages/discover/tv/network/[networkId]/index.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { NextPage } from 'next'; -import DiscoverTv from '../../../../../components/Discover/DiscoverTv'; +import DiscoverNetwork from '../../../../../components/Discover/DiscoverNetwork'; const DiscoverTvNetworkPage: NextPage = () => { - return ; + return ; }; export default DiscoverTvNetworkPage;