feat: plex watchlist sync integration (#2885)

This commit is contained in:
Ryan Cohen
2022-08-21 16:33:49 +09:00
committed by GitHub
parent 7943e0c339
commit 301f2bf7ab
35 changed files with 1325 additions and 320 deletions

View File

@@ -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,18 @@ const ListView = ({
</div>
)}
<ul className="cards-vertical">
{plexItems?.map((title, index) => {
return (
<li key={`${title.ratingKey}-${index}`}>
<TmdbTitleCard
id={title.tmdbId}
tmdbId={title.tmdbId}
type={title.mediaType}
canExpand
/>
</li>
);
})}
{items?.map((title, index) => {
let titleCard: React.ReactNode;

View File

@@ -143,7 +143,7 @@ const SettingsTabs = ({
</div>
) : (
<div className="hide-scrollbar hidden overflow-x-scroll border-b border-gray-600 sm:block">
<nav className="flex">
<nav className="flex" data-testid="settings-nav-desktop">
{settingsRoutes
.filter(
(route) =>

View File

@@ -0,0 +1,51 @@
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: 'Your 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;

View File

@@ -2,9 +2,10 @@ import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import Link from 'next/link';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { WatchlistItem } from '../../../server/interfaces/api/discoverInterfaces';
import type { MediaResultsResponse } from '../../../server/interfaces/api/mediaInterfaces';
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
import { Permission, useUser } from '../../hooks/useUser';
import { Permission, UserType, useUser } from '../../hooks/useUser';
import PageTitle from '../Common/PageTitle';
import MediaSlider from '../MediaSlider';
import RequestCard from '../RequestCard';
@@ -25,11 +26,12 @@ const messages = defineMessages({
noRequests: 'No requests.',
upcoming: 'Upcoming Movies',
trending: 'Trending',
plexwatchlist: 'Your Plex Watchlist',
});
const Discover = () => {
const intl = useIntl();
const { hasPermission } = useUser();
const { user, hasPermission } = useUser();
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
@@ -44,6 +46,15 @@ const Discover = () => {
}
);
const { data: watchlistItems, error: watchlistError } = useSWR<{
page: number;
totalPages: number;
totalResults: number;
results: WatchlistItem[];
}>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, {
revalidateOnMount: true,
});
return (
<>
<PageTitle title={intl.formatMessage(messages.discover)} />
@@ -93,6 +104,30 @@ const Discover = () => {
placeholder={<RequestCard.Placeholder />}
emptyMessage={intl.formatMessage(messages.noRequests)}
/>
{(!watchlistItems || !!watchlistItems.results.length) && !watchlistError && (
<>
<div className="slider-header">
<Link href="/discover/watchlist">
<a className="slider-title">
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
<ArrowCircleRightIcon />
</a>
</Link>
</div>
<Slider
sliderKey="watchlist"
isLoading={!watchlistItems && !watchlistError}
items={watchlistItems?.results.map((item) => (
<TmdbTitleCard
id={item.tmdbId}
key={`watchlist-slider-item-${item.ratingKey}`}
tmdbId={item.tmdbId}
type={item.mediaType}
/>
))}
/>
</>
)}
<MediaSlider
sliderKey="trending"
title={intl.formatMessage(messages.trending)}

View File

@@ -71,9 +71,13 @@ interface ManageSlideOverTvProps extends ManageSlideOverProps {
data: TvDetails;
}
const ManageSlideOver: React.FC<
ManageSlideOverMovieProps | ManageSlideOverTvProps
> = ({ show, mediaType, onClose, data, revalidate }) => {
const ManageSlideOver = ({
show,
mediaType,
onClose,
data,
revalidate,
}: ManageSlideOverMovieProps | ManageSlideOverTvProps) => {
const { user: currentUser, hasPermission } = useUser();
const intl = useIntl();
const settings = useSettings();

View File

@@ -50,6 +50,15 @@ export const messages = defineMessages({
advancedrequest: 'Advanced Requests',
advancedrequestDescription:
'Grant permission to modify advanced media request options.',
autorequest: 'Auto-Request',
autorequestDescription:
'Grant permission to automatically submit requests for non-4K media via Plex Watchlist.',
autorequestMovies: 'Auto-Request Movies',
autorequestMoviesDescription:
'Grant permission to automatically submit requests for non-4K movies via Plex Watchlist.',
autorequestSeries: 'Auto-Request Series',
autorequestSeriesDescription:
'Grant permission to automatically submit requests for non-4K series via Plex Watchlist.',
viewrequests: 'View Requests',
viewrequestsDescription:
'Grant permission to view media requests submitted by other users.',
@@ -176,6 +185,43 @@ export const PermissionEdit = ({
},
],
},
{
id: 'autorequest',
name: intl.formatMessage(messages.autorequest),
description: intl.formatMessage(messages.autorequestDescription),
permission: Permission.AUTO_REQUEST,
requires: [{ permissions: [Permission.REQUEST] }],
children: [
{
id: 'autorequestmovies',
name: intl.formatMessage(messages.autorequestMovies),
description: intl.formatMessage(
messages.autorequestMoviesDescription
),
permission: Permission.AUTO_REQUEST_MOVIE,
requires: [
{
permissions: [Permission.REQUEST, Permission.REQUEST_MOVIE],
type: 'or',
},
],
},
{
id: 'autorequesttv',
name: intl.formatMessage(messages.autorequestSeries),
description: intl.formatMessage(
messages.autorequestSeriesDescription
),
permission: Permission.AUTO_REQUEST_TV,
requires: [
{
permissions: [Permission.REQUEST, Permission.REQUEST_TV],
type: 'or',
},
],
},
],
},
{
id: 'request4k',
name: intl.formatMessage(messages.request4k),

View File

@@ -47,6 +47,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
unknownJob: 'Unknown Job',
'plex-recently-added-scan': 'Plex Recently Added Scan',
'plex-full-scan': 'Plex Full Library Scan',
'plex-watchlist-sync': 'Plex Watchlist Sync',
'radarr-scan': 'Radarr Scan',
'sonarr-scan': 'Sonarr Scan',
'download-sync': 'Download Sync',

View File

@@ -10,7 +10,7 @@ interface SliderProps {
sliderKey: string;
items?: JSX.Element[];
isLoading: boolean;
isEmpty: boolean;
isEmpty?: boolean;
emptyMessage?: string;
placeholder?: React.ReactNode;
}
@@ -24,7 +24,7 @@ const Slider = ({
sliderKey,
items,
isLoading,
isEmpty,
isEmpty = false,
emptyMessage,
placeholder = <TitleCard.Placeholder />,
}: SliderProps) => {

View File

@@ -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}
/>
);
};

View File

@@ -588,7 +588,10 @@ const UserList = () => {
</Link>
<div className="ml-4">
<Link href={`/users/${user.id}`}>
<a className="text-base font-bold leading-5 transition duration-300 hover:underline">
<a
className="text-base font-bold leading-5 transition duration-300 hover:underline"
data-testid="user-list-username-link"
>
{user.displayName}
</a>
</Link>

View File

@@ -49,6 +49,12 @@ const messages = defineMessages({
discordIdTip:
'The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your Discord user account',
validationDiscordId: 'You must provide a valid Discord user ID',
plexwatchlistsyncmovies: 'Auto-Request Movies',
plexwatchlistsyncmoviestip:
'Automatically request movies on your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink>',
plexwatchlistsyncseries: 'Auto-Request Series',
plexwatchlistsyncseriestip:
'Automatically request series on your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink>',
});
const UserGeneralSettings = () => {
@@ -122,6 +128,8 @@ const UserGeneralSettings = () => {
movieQuotaDays: data?.movieQuotaDays,
tvQuotaLimit: data?.tvQuotaLimit,
tvQuotaDays: data?.tvQuotaDays,
watchlistSyncMovies: data?.watchlistSyncMovies,
watchlistSyncTv: data?.watchlistSyncTv,
}}
validationSchema={UserGeneralSettingsSchema}
enableReinitialize
@@ -139,6 +147,8 @@ const UserGeneralSettings = () => {
movieQuotaDays: movieQuotaEnabled ? values.movieQuotaDays : null,
tvQuotaLimit: tvQuotaEnabled ? values.tvQuotaLimit : null,
tvQuotaDays: tvQuotaEnabled ? values.tvQuotaDays : null,
watchlistSyncMovies: values.watchlistSyncMovies,
watchlistSyncTv: values.watchlistSyncTv,
});
if (currentUser?.id === user?.id && setLocale) {
@@ -409,6 +419,99 @@ const UserGeneralSettings = () => {
</div>
</>
)}
{hasPermission(
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE],
{ type: 'or' }
) &&
user?.userType === UserType.PLEX && (
<div className="form-row">
<label
htmlFor="watchlistSyncMovies"
className="checkbox-label"
>
<span>
{intl.formatMessage(messages.plexwatchlistsyncmovies)}
</span>
<span className="label-tip">
{intl.formatMessage(
messages.plexwatchlistsyncmoviestip,
{
PlexWatchlistSupportLink: (
msg: React.ReactNode
) => (
<a
href="https://support.plex.tv/articles/universal-watchlist/"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
),
}
)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="watchlistSyncMovies"
name="watchlistSyncMovies"
onChange={() => {
setFieldValue(
'watchlistSyncMovies',
!values.watchlistSyncMovies
);
}}
/>
</div>
</div>
)}
{hasPermission(
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_TV],
{ type: 'or' }
) &&
user?.userType === UserType.PLEX && (
<div className="form-row">
<label htmlFor="watchlistSyncTv" className="checkbox-label">
<span>
{intl.formatMessage(messages.plexwatchlistsyncseries)}
</span>
<span className="label-tip">
{intl.formatMessage(
messages.plexwatchlistsyncseriestip,
{
PlexWatchlistSupportLink: (
msg: React.ReactNode
) => (
<a
href="https://support.plex.tv/articles/universal-watchlist/"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
),
}
)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="watchlistSyncTv"
name="watchlistSyncTv"
onChange={() => {
setFieldValue(
'watchlistSyncTv',
!values.watchlistSyncTv
);
}}
/>
</div>
</div>
)}
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">

View File

@@ -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": "Your 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": "Your Plex Watchlist",
"components.Discover.popularmovies": "Popular Movies",
"components.Discover.populartv": "Popular Series",
"components.Discover.recentlyAdded": "Recently Added",
@@ -230,6 +232,12 @@
"components.PermissionEdit.autoapproveMoviesDescription": "Grant automatic approval for non-4K movie requests.",
"components.PermissionEdit.autoapproveSeries": "Auto-Approve Series",
"components.PermissionEdit.autoapproveSeriesDescription": "Grant automatic approval for non-4K series requests.",
"components.PermissionEdit.autorequest": "Auto-Request",
"components.PermissionEdit.autorequestDescription": "Grant permission to automatically submit requests for non-4K media via Plex Watchlist.",
"components.PermissionEdit.autorequestMovies": "Auto-Request Movies",
"components.PermissionEdit.autorequestMoviesDescription": "Grant permission to automatically submit requests for non-4K movies via Plex Watchlist.",
"components.PermissionEdit.autorequestSeries": "Auto-Request Series",
"components.PermissionEdit.autorequestSeriesDescription": "Grant permission to automatically submit requests for non-4K series via Plex Watchlist.",
"components.PermissionEdit.createissues": "Report Issues",
"components.PermissionEdit.createissuesDescription": "Grant permission to report media issues.",
"components.PermissionEdit.manageissues": "Manage Issues",
@@ -618,6 +626,7 @@
"components.Settings.SettingsJobsCache.nextexecution": "Next Execution",
"components.Settings.SettingsJobsCache.plex-full-scan": "Plex Full Library Scan",
"components.Settings.SettingsJobsCache.plex-recently-added-scan": "Plex Recently Added Scan",
"components.Settings.SettingsJobsCache.plex-watchlist-sync": "Plex Watchlist Sync",
"components.Settings.SettingsJobsCache.process": "Process",
"components.Settings.SettingsJobsCache.radarr-scan": "Radarr Scan",
"components.Settings.SettingsJobsCache.runnow": "Run Now",
@@ -920,6 +929,10 @@
"components.UserProfile.UserSettings.UserGeneralSettings.originallanguageTip": "Filter content by original language",
"components.UserProfile.UserSettings.UserGeneralSettings.owner": "Owner",
"components.UserProfile.UserSettings.UserGeneralSettings.plexuser": "Plex User",
"components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncmovies": "Auto-Request Movies",
"components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncmoviestip": "Automatically request movies on your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink>",
"components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncseries": "Auto-Request Series",
"components.UserProfile.UserSettings.UserGeneralSettings.plexwatchlistsyncseriestip": "Automatically request series on your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink>",
"components.UserProfile.UserSettings.UserGeneralSettings.region": "Discover Region",
"components.UserProfile.UserSettings.UserGeneralSettings.regionTip": "Filter content by regional availability",
"components.UserProfile.UserSettings.UserGeneralSettings.role": "Role",

View File

@@ -0,0 +1,8 @@
import type { NextPage } from 'next';
import DiscoverWatchlist from '../../components/Discover/DiscoverWatchlist';
const WatchlistPage: NextPage = () => {
return <DiscoverWatchlist />;
};
export default WatchlistPage;