feat: add pagination to watchlist and full watchlist page

feature/watchlist-sync
Ryan Cohen 3 years ago committed by Ryan Cohen
parent 4e6c9ec545
commit 6e96f4ce52

@ -4408,26 +4408,41 @@ paths:
summary: Get the Plex watchlist.
tags:
- search
parameters:
- in: query
name: page
schema:
type: number
example: 1
default: 1
responses:
'200':
description: Watchlist data returned
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: number
example: 1
backdrops:
type: array
items:
type: string
name:
type: string
example: Genre Name
type: object
properties:
page:
type: number
totalPages:
type: number
totalResults:
type: number
results:
type: array
items:
type: object
properties:
tmdbId:
type: number
example: 1
ratingKey:
type: string
type:
type: string
title:
type: string
/request:
get:
summary: Get all requests

@ -114,6 +114,7 @@ interface UsersResponse {
interface WatchlistResponse {
MediaContainer: {
totalSize: number;
Metadata: {
ratingKey: string;
}[];
@ -289,7 +290,12 @@ class PlexTvAPI extends ExternalAPI {
public async getWatchlist({
offset = 0,
size = 20,
}: { offset?: number; size?: number } = {}): Promise<PlexWatchlistItem[]> {
}: { offset?: number; size?: number } = {}): Promise<{
offset: number;
size: number;
totalSize: number;
items: PlexWatchlistItem[];
}> {
try {
const response = await this.axios.get<WatchlistResponse>(
'/library/sections/watchlist/all',
@ -330,13 +336,23 @@ class PlexTvAPI extends ExternalAPI {
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
return filteredList;
return {
offset,
size,
totalSize: response.data.MediaContainer.totalSize,
items: filteredList,
};
} catch (e) {
logger.error('Failed to retrieve watchlist items', {
label: 'Plex.TV Metadata API',
errorMessage: e.message,
});
return [];
return {
offset,
size,
totalSize: 0,
items: [],
};
}
}
}

@ -94,7 +94,7 @@ class TheMovieDb extends ExternalAPI {
nodeCache: cacheManager.getCache('tmdb').data,
rateLimit: {
maxRequests: 20,
maxRPS: 1,
maxRPS: 50,
},
}
);

@ -7,6 +7,6 @@ export interface GenreSliderItem {
export interface WatchlistItem {
ratingKey: string;
tmdbId: number;
type: 'movie' | 'tv';
mediaType: 'movie' | 'tv';
title: string;
}

@ -709,8 +709,19 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
}
);
discoverRoutes.get<never, WatchlistItem[]>('/watchlist', async (req, res) => {
discoverRoutes.get<
{ page?: number },
{
page: number;
totalPages: number;
totalResults: number;
results: WatchlistItem[];
}
>('/watchlist', async (req, res) => {
const userRepository = getRepository(User);
const itemsPerPage = 20;
const page = req.params.page ?? 1;
const offset = (page - 1) * itemsPerPage;
const activeUser = await userRepository.findOne({
where: { id: req.user?.id },
@ -719,14 +730,29 @@ discoverRoutes.get<never, WatchlistItem[]>('/watchlist', async (req, res) => {
if (!activeUser?.plexToken) {
// We will just return an empty array if the user has no plex token
return res.json([]);
return res.json({
page: 1,
totalPages: 1,
totalResults: 0,
results: [],
});
}
const plexTV = new PlexTvAPI(activeUser?.plexToken);
const watchlist = await plexTV.getWatchlist();
return res.json(watchlist);
const watchlist = await plexTV.getWatchlist({ offset });
return res.json({
page,
totalPages: Math.ceil(watchlist.size / itemsPerPage),
totalResults: watchlist.size,
results: watchlist.items.map((item) => ({
ratingKey: item.ratingKey,
title: item.title,
mediaType: item.type,
tmdbId: item.tmdbId,
})),
});
});
export default discoverRoutes;

@ -1,4 +1,5 @@
import { useIntl } from 'react-intl';
import type { WatchlistItem } from '../../../../server/interfaces/api/discoverInterfaces';
import type {
MovieResult,
PersonResult,
@ -8,14 +9,16 @@ import useVerticalScroll from '../../../hooks/useVerticalScroll';
import globalMessages from '../../../i18n/globalMessages';
import PersonCard from '../../PersonCard';
import TitleCard from '../../TitleCard';
import TmdbTitleCard from '../../TitleCard/TmdbTitleCard';
interface ListViewProps {
type ListViewProps = {
items?: (TvResult | MovieResult | PersonResult)[];
plexItems?: WatchlistItem[];
isEmpty?: boolean;
isLoading?: boolean;
isReachingEnd?: boolean;
onScrollBottom: () => void;
}
};
const ListView = ({
items,
@ -23,6 +26,7 @@ const ListView = ({
isLoading,
onScrollBottom,
isReachingEnd,
plexItems,
}: ListViewProps) => {
const intl = useIntl();
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
@ -34,6 +38,17 @@ const ListView = ({
</div>
)}
<ul className="cards-vertical">
{plexItems?.map((title, index) => {
return (
<li key={`${title.ratingKey}-${index}`}>
<TmdbTitleCard
tmdbId={title.tmdbId}
type={title.mediaType}
canExpand
/>
</li>
);
})}
{items?.map((title, index) => {
let titleCard: React.ReactNode;

@ -0,0 +1,52 @@
import React from 'react';
import ListView from '../../Common/ListView';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../../Common/Header';
import PageTitle from '../../Common/PageTitle';
import useDiscover from '../../../hooks/useDiscover';
import Error from '../../../pages/_error';
import type { WatchlistItem } from '../../../../server/interfaces/api/discoverInterfaces';
const messages = defineMessages({
discoverwatchlist: 'Plex Watchlist',
});
const DiscoverWatchlist = () => {
const intl = useIntl();
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<WatchlistItem>('/api/v1/discover/watchlist');
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discoverwatchlist);
return (
<>
<PageTitle title={title} />
<div className="mt-1 mb-5">
<Header>{title}</Header>
</div>
<ListView
plexItems={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverWatchlist;

@ -26,6 +26,7 @@ const messages = defineMessages({
noRequests: 'No requests.',
upcoming: 'Upcoming Movies',
trending: 'Trending',
plexwatchlist: 'Plex Watchlist',
});
const Discover = () => {
@ -45,9 +46,12 @@ const Discover = () => {
}
);
const { data: watchlistItems, error: watchlistError } = useSWR<
WatchlistItem[]
>('/api/v1/discover/watchlist', {
const { data: watchlistItems, error: watchlistError } = useSWR<{
page: number;
totalPages: number;
totalResults: number;
results: WatchlistItem[];
}>('/api/v1/discover/watchlist', {
revalidateOnMount: true,
});
@ -101,26 +105,31 @@ const Discover = () => {
emptyMessage={intl.formatMessage(messages.noRequests)}
/>
<div className="slider-header">
<div className="slider-title">
<span>Plex Watchlist</span>
</div>
<Link href="/discover/watchlist">
<a className="slider-title">
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
<ArrowCircleRightIcon />
</a>
</Link>
</div>
{!(
!!watchlistItems &&
!watchlistError &&
watchlistItems.length === 0
watchlistItems.results.length === 0
) && (
<Slider
sliderKey="watchlist"
isLoading={!watchlistItems && !watchlistError}
isEmpty={
!!watchlistItems && !watchlistError && watchlistItems.length === 0
!!watchlistItems &&
!watchlistError &&
watchlistItems.results.length === 0
}
items={watchlistItems?.map((item) => (
items={watchlistItems?.results.map((item) => (
<TmdbTitleCard
key={`watchlist-slider-item-${item.ratingKey}`}
tmdbId={item.tmdbId}
type={item.type}
type={item.mediaType}
/>
))}
/>

@ -10,13 +10,20 @@ export interface TmdbTitleCardProps {
tmdbId: number;
tvdbId?: number;
type: 'movie' | 'tv';
canExpand?: boolean;
}
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
const TmdbTitleCard = ({ id, tmdbId, tvdbId, type }: TmdbTitleCardProps) => {
const TmdbTitleCard = ({
id,
tmdbId,
tvdbId,
type,
canExpand,
}: TmdbTitleCardProps) => {
const { hasPermission } = useUser();
const { ref, inView } = useInView({
@ -31,7 +38,7 @@ const TmdbTitleCard = ({ id, tmdbId, tvdbId, type }: TmdbTitleCardProps) => {
if (!title && !error) {
return (
<div ref={ref}>
<TitleCard.Placeholder />
<TitleCard.Placeholder canExpand={canExpand} />
</div>
);
}
@ -57,6 +64,7 @@ const TmdbTitleCard = ({ id, tmdbId, tvdbId, type }: TmdbTitleCardProps) => {
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={'movie'}
canExpand={canExpand}
/>
) : (
<TitleCard
@ -68,6 +76,7 @@ const TmdbTitleCard = ({ id, tmdbId, tvdbId, type }: TmdbTitleCardProps) => {
userScore={title.voteAverage}
year={title.firstAirDate}
mediaType={'tv'}
canExpand={canExpand}
/>
);
};

@ -10,6 +10,7 @@
"components.Discover.DiscoverStudio.studioMovies": "{studio} Movies",
"components.Discover.DiscoverTvGenre.genreSeries": "{genre} Series",
"components.Discover.DiscoverTvLanguage.languageSeries": "{language} Series",
"components.Discover.DiscoverWatchlist.discoverwatchlist": "Plex Watchlist",
"components.Discover.MovieGenreList.moviegenres": "Movie Genres",
"components.Discover.MovieGenreSlider.moviegenres": "Movie Genres",
"components.Discover.NetworkSlider.networks": "Networks",
@ -20,6 +21,7 @@
"components.Discover.discovermovies": "Popular Movies",
"components.Discover.discovertv": "Popular Series",
"components.Discover.noRequests": "No requests.",
"components.Discover.plexwatchlist": "Plex Watchlist",
"components.Discover.popularmovies": "Popular Movies",
"components.Discover.populartv": "Popular Series",
"components.Discover.recentlyAdded": "Recently Added",

@ -0,0 +1,9 @@
import React from 'react';
import type { NextPage } from 'next';
import DiscoverWatchlist from '../../components/Discover/DiscoverWatchlist';
const WatchlistPage: NextPage = () => {
return <DiscoverWatchlist />;
};
export default WatchlistPage;
Loading…
Cancel
Save