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
pull/1093/head
sct 4 years ago
parent ed0a7fbdf5
commit 63c122e5e0

@ -574,6 +574,19 @@ components:
type: string type: string
name: name:
type: string type: string
Network:
type: object
properties:
id:
type: number
example: 1
logoPath:
type: string
nullable: true
originCountry:
type: string
name:
type: string
RelatedVideo: RelatedVideo:
type: object type: object
properties: properties:
@ -3227,12 +3240,12 @@ paths:
name: genre name: genre
schema: schema:
type: number type: number
example: 10751 example: 18
- in: query - in: query
name: studio name: studio
schema: schema:
type: number type: number
example: 2 example: 1
responses: responses:
'200': '200':
description: Results description: Results
@ -3254,6 +3267,100 @@ paths:
type: array type: array
items: items:
$ref: '#/components/schemas/MovieResult' $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: /discover/movies/upcoming:
get: get:
summary: Upcoming movies summary: Upcoming movies
@ -3342,6 +3449,100 @@ paths:
type: array type: array
items: items:
$ref: '#/components/schemas/TvResult' $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: /discover/tv/upcoming:
get: get:
summary: Discover Upcoming TV shows summary: Discover Upcoming TV shows

@ -3,7 +3,6 @@ import cacheManager from '../../lib/cache';
import ExternalAPI from '../externalapi'; import ExternalAPI from '../externalapi';
import { import {
TmdbCollection, TmdbCollection,
TmdbStudio,
TmdbExternalIdResponse, TmdbExternalIdResponse,
TmdbGenre, TmdbGenre,
TmdbGenresResult, TmdbGenresResult,
@ -19,6 +18,7 @@ import {
TmdbSeasonWithEpisodes, TmdbSeasonWithEpisodes,
TmdbTvDetails, TmdbTvDetails,
TmdbUpcomingMoviesResponse, TmdbUpcomingMoviesResponse,
TmdbProductionCompany,
} from './interfaces'; } from './interfaces';
interface SearchOptions { interface SearchOptions {
@ -675,9 +675,11 @@ class TheMovieDb extends ExternalAPI {
} }
} }
public async getStudio(studioId: number): Promise<TmdbStudio> { public async getStudio(studioId: number): Promise<TmdbProductionCompany> {
try { try {
const data = await this.get<TmdbStudio>(`/company/${studioId}`); const data = await this.get<TmdbProductionCompany>(
`/company/${studioId}`
);
return data; return data;
} catch (e) { } catch (e) {
@ -695,11 +697,19 @@ class TheMovieDb extends ExternalAPI {
} }
} }
public async getMovieGenres(): Promise<TmdbGenre[]> { public async getMovieGenres({
language = 'en',
}: {
language?: string;
} = {}): Promise<TmdbGenre[]> {
try { try {
const data = await this.get<TmdbGenresResult>( const data = await this.get<TmdbGenresResult>(
'/genre/movie/list', '/genre/movie/list',
{}, {
params: {
language,
},
},
86400 // 24 hours 86400 // 24 hours
); );
@ -711,11 +721,19 @@ class TheMovieDb extends ExternalAPI {
} }
} }
public async getTvGenres(): Promise<TmdbGenre[]> { public async getTvGenres({
language = 'en',
}: {
language?: string;
} = {}): Promise<TmdbGenre[]> {
try { try {
const data = await this.get<TmdbGenresResult>( const data = await this.get<TmdbGenresResult>(
'/genre/tv/list', '/genre/tv/list',
{}, {
params: {
language,
},
},
86400 // 24 hours 86400 // 24 hours
); );

@ -109,6 +109,16 @@ export interface TmdbExternalIds {
twitter_id?: string; 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 { export interface TmdbMovieDetails {
id: number; id: number;
imdb_id?: string; imdb_id?: string;
@ -125,12 +135,7 @@ export interface TmdbMovieDetails {
original_title: string; original_title: string;
overview?: string; overview?: string;
popularity: number; popularity: number;
production_companies: { production_companies: TmdbProductionCompany[];
id: number;
name: string;
logo_path?: string;
origin_country: string;
}[];
production_countries: { production_countries: {
iso_3166_1: string; iso_3166_1: string;
name: string; name: string;
@ -227,12 +232,7 @@ export interface TmdbTvDetails {
last_episode_to_air?: TmdbTvEpisodeResult; last_episode_to_air?: TmdbTvEpisodeResult;
name: string; name: string;
next_episode_to_air?: TmdbTvEpisodeResult; next_episode_to_air?: TmdbTvEpisodeResult;
networks: { networks: TmdbNetwork[];
id: number;
name: string;
logo_path: string;
origin_country: string;
}[];
number_of_episodes: number; number_of_episodes: number;
number_of_seasons: number; number_of_seasons: number;
origin_country: string[]; origin_country: string[];
@ -391,17 +391,6 @@ export interface TmdbGenre {
name: string; 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 { export interface TmdbNetwork {
id: number; id: number;
name: string; name: string;

@ -1,6 +1,7 @@
import type { import type {
TmdbMovieDetails, TmdbMovieDetails,
TmdbMovieReleaseResult, TmdbMovieReleaseResult,
TmdbProductionCompany,
} from '../api/themoviedb/interfaces'; } from '../api/themoviedb/interfaces';
import { import {
ProductionCompany, ProductionCompany,
@ -79,6 +80,18 @@ export interface MovieDetails {
plexUrl?: string; 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 = ( export const mapMovieDetails = (
movie: TmdbMovieDetails, movie: TmdbMovieDetails,
media?: Media media?: Media
@ -91,12 +104,7 @@ export const mapMovieDetails = (
originalLanguage: movie.original_language, originalLanguage: movie.original_language,
originalTitle: movie.original_title, originalTitle: movie.original_title,
popularity: movie.popularity, popularity: movie.popularity,
productionCompanies: movie.production_companies.map((company) => ({ productionCompanies: movie.production_companies.map(mapProductionCompany),
id: company.id,
logoPath: company.logo_path,
originCountry: company.origin_country,
name: company.name,
})),
productionCountries: movie.production_countries, productionCountries: movie.production_countries,
releaseDate: movie.release_date, releaseDate: movie.release_date,
releases: movie.release_dates, releases: movie.release_dates,

@ -9,6 +9,7 @@ import {
mapExternalIds, mapExternalIds,
Keyword, Keyword,
mapVideos, mapVideos,
TvNetwork,
} from './common'; } from './common';
import type { import type {
TmdbTvEpisodeResult, TmdbTvEpisodeResult,
@ -16,6 +17,7 @@ import type {
TmdbTvDetails, TmdbTvDetails,
TmdbSeasonWithEpisodes, TmdbSeasonWithEpisodes,
TmdbTvRatingResult, TmdbTvRatingResult,
TmdbNetwork,
} from '../api/themoviedb/interfaces'; } from '../api/themoviedb/interfaces';
import type Media from '../entity/Media'; import type Media from '../entity/Media';
import { Video } from './Movie'; import { Video } from './Movie';
@ -77,7 +79,7 @@ export interface TvDetails {
lastEpisodeToAir?: Episode; lastEpisodeToAir?: Episode;
name: string; name: string;
nextEpisodeToAir?: Episode; nextEpisodeToAir?: Episode;
networks: ProductionCompany[]; networks: TvNetwork[];
numberOfEpisodes: number; numberOfEpisodes: number;
numberOfSeasons: number; numberOfSeasons: number;
originCountry: string[]; originCountry: string[];
@ -139,6 +141,15 @@ export const mapSeasonWithEpisodes = (
posterPath: season.poster_path, 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 = ( export const mapTvDetails = (
show: TmdbTvDetails, show: TmdbTvDetails,
media?: Media media?: Media
@ -157,12 +168,7 @@ export const mapTvDetails = (
languages: show.languages, languages: show.languages,
lastAirDate: show.last_air_date, lastAirDate: show.last_air_date,
name: show.name, name: show.name,
networks: show.networks.map((network) => ({ networks: show.networks.map(mapNetwork),
id: network.id,
name: network.name,
originCountry: network.origin_country,
logoPath: network.logo_path,
})),
numberOfEpisodes: show.number_of_episodes, numberOfEpisodes: show.number_of_episodes,
numberOfSeasons: show.number_of_seasons, numberOfSeasons: show.number_of_seasons,
originCountry: show.origin_country, originCountry: show.origin_country,

@ -14,6 +14,18 @@ export interface ProductionCompany {
logoPath?: string; logoPath?: string;
originCountry: string; originCountry: string;
name: 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 { export interface Keyword {

@ -6,6 +6,8 @@ import { isMovie, isPerson } from '../utils/typeHelpers';
import { MediaType } from '../constants/media'; import { MediaType } from '../constants/media';
import { getSettings } from '../lib/settings'; import { getSettings } from '../lib/settings';
import { User } from '../entity/User'; import { User } from '../entity/User';
import { mapProductionCompany } from '../models/Movie';
import { mapNetwork } from '../models/Tv';
const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => { const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => {
const settings = getSettings(); 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) => { discoverRoutes.get('/movies/upcoming', async (req, res) => {
const tmdb = createTmdbWithRegionLanaguage(req.user); 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) => { discoverRoutes.get('/tv/upcoming', async (req, res) => {
const tmdb = createTmdbWithRegionLanaguage(req.user); const tmdb = createTmdbWithRegionLanaguage(req.user);

@ -17,6 +17,8 @@ import { getAppVersion, getCommitTag } from '../utils/appVersion';
import serviceRoutes from './service'; import serviceRoutes from './service';
import { appDataStatus, appDataPath } from '../utils/appDataVolume'; import { appDataStatus, appDataPath } from '../utils/appDataVolume';
import TheMovieDb from '../api/themoviedb'; import TheMovieDb from '../api/themoviedb';
import { mapProductionCompany } from '../models/Movie';
import { mapNetwork } from '../models/Tv';
const router = Router(); 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)); 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) => { 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)); 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) => { router.get('/genres/movie', isAuthenticated(), async (req, res) => {

@ -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<MovieResult, { genre: { id: number; name: string } }>(
`/api/v1/discover/movies/genre/${router.query.genreId}`
);
if (error) {
return <Error statusCode={500} />;
}
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.genreMovies, {
genre: firstResultData?.genre.name,
});
return (
<>
<PageTitle title={title} />
<div className="mt-1 mb-5">
<Header>{title}</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverMovieGenre;

@ -1,18 +1,11 @@
import React, { useContext } from 'react'; import React from 'react';
import useSWR, { useSWRInfinite } from 'swr';
import type { MovieResult } from '../../../server/models/Search'; import type { MovieResult } from '../../../server/models/Search';
import ListView from '../Common/ListView'; import ListView from '../Common/ListView';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header'; import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle'; import PageTitle from '../Common/PageTitle';
import { useRouter } from 'next/router'; import useDiscover from '../../hooks/useDiscover';
import { import Error from '../../pages/_error';
TmdbStudio,
TmdbGenre,
} from '../../../server/api/themoviedb/interfaces';
const messages = defineMessages({ const messages = defineMessages({
discovermovies: 'Popular Movies', discovermovies: 'Popular Movies',
@ -20,77 +13,24 @@ const messages = defineMessages({
studioMovies: '{studio} Movies', studioMovies: '{studio} Movies',
}); });
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: MovieResult[];
}
const DiscoverMovies: React.FC = () => { const DiscoverMovies: React.FC = () => {
const router = useRouter();
const intl = useIntl(); const intl = useIntl();
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data: genres } = useSWR<TmdbGenre[]>('/api/v1/genres/movie');
const genre = genres?.find((g) => g.id === Number(router.query.genreId));
const { data: studio } = useSWR<TmdbStudio>(
`/api/v1/studio/${router.query.studioId}`
);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(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 {
const isLoadingMore = isLoadingInitialData,
isLoadingInitialData || isEmpty,
(size > 0 && data && typeof data[size - 1] === 'undefined'); isLoadingMore,
isReachingEnd,
const fetchMore = () => { titles,
setSize(size + 1); fetchMore,
}; error,
} = useDiscover<MovieResult>('/api/v1/discover/movies');
if (error) { if (error) {
return <div>{error}</div>; return <Error statusCode={500} />;
}
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
);
} }
const isEmpty = !isLoadingInitialData && titles?.length === 0; const title = intl.formatMessage(messages.discovermovies);
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 ( return (
<> <>

@ -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<TvResult, { network: TvNetwork }>(
`/api/v1/discover/tv/network/${router.query.networkId}`
);
if (error) {
return <Error statusCode={500} />;
}
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.networkSeries, {
network: firstResultData?.network.name,
});
return (
<>
<PageTitle title={title} />
<div className="mt-1 mb-5">
<Header>
{firstResultData?.network.logoPath ? (
<div className="flex justify-center mb-6">
<img
src={`//image.tmdb.org/t/p/w780_filter(negate,000,666)/${firstResultData?.network.logoPath}`}
alt=""
className="text-white max-h-24 sm:max-h-32"
/>
</div>
) : (
title
)}
</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverTvNetwork;

@ -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<MovieResult, { studio: ProductionCompany }>(
`/api/v1/discover/movies/studio/${router.query.studioId}`
);
if (error) {
return <Error statusCode={500} />;
}
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.studioMovies, {
studio: firstResultData?.studio.name,
});
return (
<>
<PageTitle title={title} />
<div className="mt-1 mb-5">
<Header>
{firstResultData?.studio.logoPath ? (
<div className="flex justify-center mb-6">
<img
src={`//image.tmdb.org/t/p/w780_filter(negate,000,666)/${firstResultData?.studio.logoPath}`}
alt=""
className="text-white max-h-24 sm:max-h-32"
/>
</div>
) : (
title
)}
</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverMovieStudio;

@ -1,18 +1,11 @@
import React, { useContext } from 'react'; import React from 'react';
import useSWR, { useSWRInfinite } from 'swr';
import type { TvResult } from '../../../server/models/Search'; import type { TvResult } from '../../../server/models/Search';
import ListView from '../Common/ListView'; import ListView from '../Common/ListView';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { LanguageContext } from '../../context/LanguageContext';
import Header from '../Common/Header'; import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle'; import PageTitle from '../Common/PageTitle';
import { useRouter } from 'next/router'; import useDiscover from '../../hooks/useDiscover';
import { import Error from '../../pages/_error';
TmdbGenre,
TmdbNetwork,
} from '../../../server/api/themoviedb/interfaces';
const messages = defineMessages({ const messages = defineMessages({
discovertv: 'Popular Series', discovertv: 'Popular Series',
@ -20,76 +13,24 @@ const messages = defineMessages({
networkSeries: '{network} Series', networkSeries: '{network} Series',
}); });
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: TvResult[];
}
const DiscoverTv: React.FC = () => { const DiscoverTv: React.FC = () => {
const router = useRouter();
const intl = useIntl(); const intl = useIntl();
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data: genres } = useSWR<TmdbGenre[]>('/api/v1/genres/tv');
const genre = genres?.find((g) => g.id === Number(router.query.genreId));
const { data: network } = useSWR<TmdbNetwork>(
`/api/v1/network/${router.query.networkId}`
);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(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 {
const isLoadingMore = isLoadingInitialData,
isLoadingInitialData || isEmpty,
(size > 0 && data && typeof data[size - 1] === 'undefined'); isLoadingMore,
isReachingEnd,
const fetchMore = () => { titles,
setSize(size + 1); fetchMore,
}; error,
} = useDiscover<TvResult>('/api/v1/discover/tv');
if (error) { if (error) {
return <div>{error}</div>; return <Error statusCode={500} />;
}
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 title = intl.formatMessage(messages.discovertv);
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 ( return (
<> <>

@ -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<TvResult, { genre: { id: number; name: string } }>(
`/api/v1/discover/tv/genre/${router.query.genreId}`
);
if (error) {
return <Error statusCode={500} />;
}
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.genreSeries, {
genre: firstResultData?.genre.name,
});
return (
<>
<PageTitle title={title} />
<div className="mt-1 mb-5">
<Header>{title}</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverTvGenre;

@ -1,74 +1,33 @@
import React, { useContext } from 'react'; import React from 'react';
import { useSWRInfinite } from 'swr';
import type { TvResult } from '../../../server/models/Search'; import type { TvResult } from '../../../server/models/Search';
import ListView from '../Common/ListView'; import ListView from '../Common/ListView';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { LanguageContext } from '../../context/LanguageContext';
import Header from '../Common/Header'; import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle'; import PageTitle from '../Common/PageTitle';
import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error';
const messages = defineMessages({ const messages = defineMessages({
upcomingtv: 'Upcoming Series', upcomingtv: 'Upcoming Series',
}); });
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: TvResult[];
}
const DiscoverTvUpcoming: React.FC = () => { const DiscoverTvUpcoming: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(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 {
const isLoadingMore = isLoadingInitialData,
isLoadingInitialData || isEmpty,
(size > 0 && data && typeof data[size - 1] === 'undefined'); isLoadingMore,
isReachingEnd,
const fetchMore = () => { titles,
setSize(size + 1); fetchMore,
}; error,
} = useDiscover<TvResult>('/api/v1/discover/tv/upcoming');
if (error) { if (error) {
return <div>{error}</div>; return <Error statusCode={500} />;
} }
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 ( return (
<> <>
<PageTitle title={intl.formatMessage(messages.upcomingtv)} /> <PageTitle title={intl.formatMessage(messages.upcomingtv)} />

@ -1,79 +1,38 @@
import React, { useContext } from 'react'; import React from 'react';
import { useSWRInfinite } from 'swr';
import type { import type {
MovieResult, MovieResult,
TvResult, TvResult,
PersonResult, PersonResult,
} from '../../../server/models/Search'; } from '../../../server/models/Search';
import ListView from '../Common/ListView'; import ListView from '../Common/ListView';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header'; import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle'; import PageTitle from '../Common/PageTitle';
import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error';
const messages = defineMessages({ const messages = defineMessages({
trending: 'Trending', trending: 'Trending',
}); });
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: (MovieResult | TvResult | PersonResult)[];
}
const Trending: React.FC = () => { const Trending: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const settings = useSettings(); const {
const { locale } = useContext(LanguageContext); isLoadingInitialData,
const { data, error, size, setSize } = useSWRInfinite<SearchResult>( isEmpty,
(pageIndex: number, previousPageData: SearchResult | null) => { isLoadingMore,
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { isReachingEnd,
return null; titles,
} fetchMore,
error,
return `/api/v1/discover/trending?page=${ } = useDiscover<MovieResult | TvResult | PersonResult>(
pageIndex + 1 '/api/v1/discover/trending'
}&language=${locale}`;
},
{
initialSize: 3,
}
); );
const isLoadingInitialData = !data && !error;
const isLoadingMore =
isLoadingInitialData ||
(size > 0 && data && typeof data[size - 1] === 'undefined');
const fetchMore = () => {
setSize(size + 1);
};
if (error) { if (error) {
return <div>{error}</div>; return <Error statusCode={500} />;
} }
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 ( return (
<> <>
<PageTitle title={intl.formatMessage(messages.trending)} /> <PageTitle title={intl.formatMessage(messages.trending)} />

@ -1,74 +1,33 @@
import React, { useContext } from 'react'; import React from 'react';
import { useSWRInfinite } from 'swr';
import type { MovieResult } from '../../../server/models/Search'; import type { MovieResult } from '../../../server/models/Search';
import ListView from '../Common/ListView'; import ListView from '../Common/ListView';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header'; import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle'; import PageTitle from '../Common/PageTitle';
import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error';
const messages = defineMessages({ const messages = defineMessages({
upcomingmovies: 'Upcoming Movies', upcomingmovies: 'Upcoming Movies',
}); });
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: MovieResult[];
}
const UpcomingMovies: React.FC = () => { const UpcomingMovies: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(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 {
const isLoadingMore = isLoadingInitialData,
isLoadingInitialData || isEmpty,
(size > 0 && data && typeof data[size - 1] === 'undefined'); isLoadingMore,
isReachingEnd,
const fetchMore = () => { titles,
setSize(size + 1); fetchMore,
}; error,
} = useDiscover<MovieResult>('/api/v1/discover/movies/upcoming');
if (error) { if (error) {
return <div>{error}</div>; return <Error statusCode={500} />;
} }
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 ( return (
<> <>
<PageTitle title={intl.formatMessage(messages.upcomingmovies)} /> <PageTitle title={intl.formatMessage(messages.upcomingmovies)} />

@ -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<T> {
page: number;
totalResults: number;
totalPages: number;
results: T[];
}
interface BaseMedia {
mediaType: string;
mediaInfo?: {
status: MediaStatus;
};
}
interface DiscoverResult<T, S> {
isLoadingInitialData: boolean;
isLoadingMore: boolean;
fetchMore: () => void;
isEmpty: boolean;
isReachingEnd: boolean;
error: unknown;
titles: T[];
firstResultData?: BaseSearchResult<T> & S;
}
const useDiscover = <T extends BaseMedia, S = Record<string, never>>(
endpoint: string,
options?: Record<string, unknown>
): DiscoverResult<T, S> => {
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<
BaseSearchResult<T> & S
>(
(pageIndex: number, previousPageData) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
return null;
}
const params: Record<string, unknown> = {
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;

@ -23,6 +23,7 @@ const globalMessages = defineMessages({
edit: 'Edit', edit: 'Edit',
experimental: 'Experimental', experimental: 'Experimental',
advanced: 'Advanced', advanced: 'Advanced',
loading: 'Loading…',
}); });
export default globalMessages; export default globalMessages;

@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { NextPage } from 'next'; import { NextPage } from 'next';
import DiscoverMovies from '../../../../../components/Discover/DiscoverMovies'; import DiscoverMovieGenre from '../../../../../components/Discover/DiscoverMovieGenre';
const DiscoverMoviesGenrePage: NextPage = () => { const DiscoverMoviesGenrePage: NextPage = () => {
return <DiscoverMovies />; return <DiscoverMovieGenre />;
}; };
export default DiscoverMoviesGenrePage; export default DiscoverMoviesGenrePage;

@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { NextPage } from 'next'; import { NextPage } from 'next';
import DiscoverMovies from '../../../../../components/Discover/DiscoverMovies'; import DiscoverMovieStudio from '../../../../../components/Discover/DiscoverStudio';
const DiscoverMoviesStudioPage: NextPage = () => { const DiscoverMoviesStudioPage: NextPage = () => {
return <DiscoverMovies />; return <DiscoverMovieStudio />;
}; };
export default DiscoverMoviesStudioPage; export default DiscoverMoviesStudioPage;

@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { NextPage } from 'next'; import { NextPage } from 'next';
import DiscoverTv from '../../../../../components/Discover/DiscoverTv'; import DiscoverTvGenre from '../../../../../components/Discover/DiscoverTvGenre';
const DiscoverTvGenrePage: NextPage = () => { const DiscoverTvGenrePage: NextPage = () => {
return <DiscoverTv />; return <DiscoverTvGenre />;
}; };
export default DiscoverTvGenrePage; export default DiscoverTvGenrePage;

@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { NextPage } from 'next'; import { NextPage } from 'next';
import DiscoverTv from '../../../../../components/Discover/DiscoverTv'; import DiscoverNetwork from '../../../../../components/Discover/DiscoverNetwork';
const DiscoverTvNetworkPage: NextPage = () => { const DiscoverTvNetworkPage: NextPage = () => {
return <DiscoverTv />; return <DiscoverNetwork />;
}; };
export default DiscoverTvNetworkPage; export default DiscoverTvNetworkPage;

Loading…
Cancel
Save