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. summary: Get the Plex watchlist.
tags: tags:
- search - search
parameters:
- in: query
name: page
schema:
type: number
example: 1
default: 1
responses: responses:
'200': '200':
description: Watchlist data returned description: Watchlist data returned
content: content:
application/json: application/json:
schema: schema:
type: array type: object
items: properties:
type: object page:
properties: type: number
id: totalPages:
type: number type: number
example: 1 totalResults:
backdrops: type: number
type: array results:
items: type: array
type: string items:
name: type: object
type: string properties:
example: Genre Name tmdbId:
type: number
example: 1
ratingKey:
type: string
type:
type: string
title:
type: string
/request: /request:
get: get:
summary: Get all requests summary: Get all requests

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

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

@ -7,6 +7,6 @@ export interface GenreSliderItem {
export interface WatchlistItem { export interface WatchlistItem {
ratingKey: string; ratingKey: string;
tmdbId: number; tmdbId: number;
type: 'movie' | 'tv'; mediaType: 'movie' | 'tv';
title: string; 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 userRepository = getRepository(User);
const itemsPerPage = 20;
const page = req.params.page ?? 1;
const offset = (page - 1) * itemsPerPage;
const activeUser = await userRepository.findOne({ const activeUser = await userRepository.findOne({
where: { id: req.user?.id }, where: { id: req.user?.id },
@ -719,14 +730,29 @@ discoverRoutes.get<never, WatchlistItem[]>('/watchlist', async (req, res) => {
if (!activeUser?.plexToken) { if (!activeUser?.plexToken) {
// We will just return an empty array if the user has no plex token // 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 plexTV = new PlexTvAPI(activeUser?.plexToken);
const watchlist = await plexTV.getWatchlist(); const watchlist = await plexTV.getWatchlist({ offset });
return res.json(watchlist); 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; export default discoverRoutes;

@ -1,4 +1,5 @@
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import type { WatchlistItem } from '../../../../server/interfaces/api/discoverInterfaces';
import type { import type {
MovieResult, MovieResult,
PersonResult, PersonResult,
@ -8,14 +9,16 @@ import useVerticalScroll from '../../../hooks/useVerticalScroll';
import globalMessages from '../../../i18n/globalMessages'; import globalMessages from '../../../i18n/globalMessages';
import PersonCard from '../../PersonCard'; import PersonCard from '../../PersonCard';
import TitleCard from '../../TitleCard'; import TitleCard from '../../TitleCard';
import TmdbTitleCard from '../../TitleCard/TmdbTitleCard';
interface ListViewProps { type ListViewProps = {
items?: (TvResult | MovieResult | PersonResult)[]; items?: (TvResult | MovieResult | PersonResult)[];
plexItems?: WatchlistItem[];
isEmpty?: boolean; isEmpty?: boolean;
isLoading?: boolean; isLoading?: boolean;
isReachingEnd?: boolean; isReachingEnd?: boolean;
onScrollBottom: () => void; onScrollBottom: () => void;
} };
const ListView = ({ const ListView = ({
items, items,
@ -23,6 +26,7 @@ const ListView = ({
isLoading, isLoading,
onScrollBottom, onScrollBottom,
isReachingEnd, isReachingEnd,
plexItems,
}: ListViewProps) => { }: ListViewProps) => {
const intl = useIntl(); const intl = useIntl();
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd); useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
@ -34,6 +38,17 @@ const ListView = ({
</div> </div>
)} )}
<ul className="cards-vertical"> <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) => { {items?.map((title, index) => {
let titleCard: React.ReactNode; 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.', noRequests: 'No requests.',
upcoming: 'Upcoming Movies', upcoming: 'Upcoming Movies',
trending: 'Trending', trending: 'Trending',
plexwatchlist: 'Plex Watchlist',
}); });
const Discover = () => { const Discover = () => {
@ -45,9 +46,12 @@ const Discover = () => {
} }
); );
const { data: watchlistItems, error: watchlistError } = useSWR< const { data: watchlistItems, error: watchlistError } = useSWR<{
WatchlistItem[] page: number;
>('/api/v1/discover/watchlist', { totalPages: number;
totalResults: number;
results: WatchlistItem[];
}>('/api/v1/discover/watchlist', {
revalidateOnMount: true, revalidateOnMount: true,
}); });
@ -101,26 +105,31 @@ const Discover = () => {
emptyMessage={intl.formatMessage(messages.noRequests)} emptyMessage={intl.formatMessage(messages.noRequests)}
/> />
<div className="slider-header"> <div className="slider-header">
<div className="slider-title"> <Link href="/discover/watchlist">
<span>Plex Watchlist</span> <a className="slider-title">
</div> <span>{intl.formatMessage(messages.plexwatchlist)}</span>
<ArrowCircleRightIcon />
</a>
</Link>
</div> </div>
{!( {!(
!!watchlistItems && !!watchlistItems &&
!watchlistError && !watchlistError &&
watchlistItems.length === 0 watchlistItems.results.length === 0
) && ( ) && (
<Slider <Slider
sliderKey="watchlist" sliderKey="watchlist"
isLoading={!watchlistItems && !watchlistError} isLoading={!watchlistItems && !watchlistError}
isEmpty={ isEmpty={
!!watchlistItems && !watchlistError && watchlistItems.length === 0 !!watchlistItems &&
!watchlistError &&
watchlistItems.results.length === 0
} }
items={watchlistItems?.map((item) => ( items={watchlistItems?.results.map((item) => (
<TmdbTitleCard <TmdbTitleCard
key={`watchlist-slider-item-${item.ratingKey}`} key={`watchlist-slider-item-${item.ratingKey}`}
tmdbId={item.tmdbId} tmdbId={item.tmdbId}
type={item.type} type={item.mediaType}
/> />
))} ))}
/> />

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

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