feat: view other users' watchlists (#2959)

* feat: view other users' watchlists

* test: add cypress tests

* feat(lang): translation keys

* refactor: yarn format

* fix: manage requests perm is parent of view watchlist perm
pull/2963/head
TheCatLady 2 years ago committed by GitHub
parent 950b1712b7
commit 0839718806
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -173,9 +173,9 @@ describe('Discover', () => {
}); });
it('loads plex watchlist', () => { it('loads plex watchlist', () => {
cy.intercept('/api/v1/discover/watchlist', { fixture: 'watchlist' }).as( cy.intercept('/api/v1/discover/watchlist', {
'getWatchlist' fixture: 'watchlist.json',
); }).as('getWatchlist');
// Wait for one of the watchlist movies to resolve // Wait for one of the watchlist movies to resolve
cy.intercept('/api/v1/movie/361743').as('getTmdbMovie'); cy.intercept('/api/v1/movie/361743').as('getTmdbMovie');
@ -183,7 +183,7 @@ describe('Discover', () => {
cy.wait('@getWatchlist'); cy.wait('@getWatchlist');
const sliderHeader = cy.contains('.slider-header', 'Plex Watchlist'); const sliderHeader = cy.contains('.slider-header', 'Your Plex Watchlist');
sliderHeader.scrollIntoView(); sliderHeader.scrollIntoView();
@ -203,7 +203,6 @@ describe('Discover', () => {
.next('[data-testid=media-slider]') .next('[data-testid=media-slider]')
.find('[data-testid=title-card]') .find('[data-testid=title-card]')
.first() .first()
.click()
.click(); .click();
cy.get('[data-testid=media-title]').should('contain', text); cy.get('[data-testid=media-title]').should('contain', text);
}); });

@ -0,0 +1,50 @@
describe('User Profile', () => {
beforeEach(() => {
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
});
it('opens user profile page from the home page', () => {
cy.visit('/');
cy.get('[data-testid=user-menu]').click();
cy.get('[data-testid=user-menu-profile]').click();
cy.get('h1').should('contain', Cypress.env('ADMIN_EMAIL'));
});
it('loads plex watchlist', () => {
cy.intercept('/api/v1/user/[0-9]*/watchlist', {
fixture: 'watchlist.json',
}).as('getWatchlist');
// Wait for one of the watchlist movies to resolve
cy.intercept('/api/v1/movie/361743').as('getTmdbMovie');
cy.visit('/profile');
cy.wait('@getWatchlist');
const sliderHeader = cy.contains('.slider-header', 'Plex Watchlist');
sliderHeader.scrollIntoView();
cy.wait('@getTmdbMovie');
// Wait a little longer to make sure the movie component reloaded
cy.wait(500);
sliderHeader
.next('[data-testid=media-slider]')
.find('[data-testid=title-card]')
.first()
.trigger('mouseover')
.find('[data-testid=title-card-title]')
.invoke('text')
.then((text) => {
cy.contains('.slider-header', 'Plex Watchlist')
.next('[data-testid=media-slider]')
.find('[data-testid=title-card]')
.first()
.click();
cy.get('[data-testid=media-title]').should('contain', text);
});
});
});

@ -1,7 +1,7 @@
{ {
"page": 1, "page": 1,
"totalPages": 1, "totalPages": 1,
"totalResults": 20, "totalResults": 3,
"results": [ "results": [
{ {
"ratingKey": "5d776be17a53e9001e732ab9", "ratingKey": "5d776be17a53e9001e732ab9",

@ -3512,6 +3512,53 @@ paths:
restricted: restricted:
type: boolean type: boolean
example: false example: false
/user/{userId}/watchlist:
get:
summary: Get user by ID
description: |
Retrieves a user's Plex Watchlist in a JSON object.
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
- in: query
name: page
schema:
type: number
example: 1
default: 1
responses:
'200':
description: Watchlist data returned
content:
application/json:
schema:
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
/user/{userId}/settings/main: /user/{userId}/settings/main:
get: get:
summary: Get general settings for a user summary: Get general settings for a user

@ -10,3 +10,10 @@ export interface WatchlistItem {
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv';
title: string; title: string;
} }
export interface WatchlistResponse {
page: number;
totalPages: number;
totalResults: number;
results: WatchlistItem[];
}

@ -23,6 +23,7 @@ export interface QuotaResponse {
movie: QuotaStatus; movie: QuotaStatus;
tv: QuotaStatus; tv: QuotaStatus;
} }
export interface UserWatchDataResponse { export interface UserWatchDataResponse {
recentlyWatched: Media[]; recentlyWatched: Media[];
playCount: number; playCount: number;

@ -25,6 +25,7 @@ export enum Permission {
AUTO_REQUEST_MOVIE = 16777216, AUTO_REQUEST_MOVIE = 16777216,
AUTO_REQUEST_TV = 33554432, AUTO_REQUEST_TV = 33554432,
RECENT_VIEW = 67108864, RECENT_VIEW = 67108864,
WATCHLIST_VIEW = 134217728,
} }
export interface PermissionCheckOptions { export interface PermissionCheckOptions {

@ -6,7 +6,7 @@ import Media from '@server/entity/Media';
import { User } from '@server/entity/User'; import { User } from '@server/entity/User';
import type { import type {
GenreSliderItem, GenreSliderItem,
WatchlistItem, WatchlistResponse,
} from '@server/interfaces/api/discoverInterfaces'; } from '@server/interfaces/api/discoverInterfaces';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
@ -713,50 +713,45 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
} }
); );
discoverRoutes.get< discoverRoutes.get<{ page?: number }, WatchlistResponse>(
{ page?: number }, '/watchlist',
{ async (req, res) => {
page: number; const userRepository = getRepository(User);
totalPages: number; const itemsPerPage = 20;
totalResults: number; const page = req.params.page ?? 1;
results: WatchlistItem[]; const offset = (page - 1) * itemsPerPage;
}
>('/watchlist', async (req, res) => { const activeUser = await userRepository.findOne({
const userRepository = getRepository(User); where: { id: req.user?.id },
const itemsPerPage = 20; select: ['id', 'plexToken'],
const page = req.params.page ?? 1;
const offset = (page - 1) * itemsPerPage;
const activeUser = await userRepository.findOne({
where: { id: req.user?.id },
select: ['id', 'plexToken'],
});
if (!activeUser?.plexToken) {
// We will just return an empty array if the user has no plex token
return res.json({
page: 1,
totalPages: 1,
totalResults: 0,
results: [],
}); });
}
const plexTV = new PlexTvAPI(activeUser?.plexToken); if (!activeUser?.plexToken) {
// We will just return an empty array if the user has no Plex token
return res.json({
page: 1,
totalPages: 1,
totalResults: 0,
results: [],
});
}
const plexTV = new PlexTvAPI(activeUser.plexToken);
const watchlist = await plexTV.getWatchlist({ offset }); const watchlist = await plexTV.getWatchlist({ offset });
return res.json({ return res.json({
page, page,
totalPages: Math.ceil(watchlist.size / itemsPerPage), totalPages: Math.ceil(watchlist.size / itemsPerPage),
totalResults: watchlist.size, totalResults: watchlist.size,
results: watchlist.items.map((item) => ({ results: watchlist.items.map((item) => ({
ratingKey: item.ratingKey, ratingKey: item.ratingKey,
title: item.title, title: item.title,
mediaType: item.type === 'show' ? 'tv' : 'movie', mediaType: item.type === 'show' ? 'tv' : 'movie',
tmdbId: item.tmdbId, tmdbId: item.tmdbId,
})), })),
}); });
}); }
);
export default discoverRoutes; export default discoverRoutes;

@ -7,6 +7,7 @@ import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest'; import { MediaRequest } from '@server/entity/MediaRequest';
import { User } from '@server/entity/User'; import { User } from '@server/entity/User';
import { UserPushSubscription } from '@server/entity/UserPushSubscription'; import { UserPushSubscription } from '@server/entity/UserPushSubscription';
import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces';
import type { import type {
QuotaResponse, QuotaResponse,
UserRequestsResponse, UserRequestsResponse,
@ -606,4 +607,60 @@ router.get<{ id: string }, UserWatchDataResponse>(
} }
); );
router.get<{ id: string; page?: number }, WatchlistResponse>(
'/:id/watchlist',
async (req, res, next) => {
if (
Number(req.params.id) !== req.user?.id &&
!req.user?.hasPermission(
[Permission.MANAGE_REQUESTS, Permission.WATCHLIST_VIEW],
{
type: 'or',
}
)
) {
return next({
status: 403,
message:
"You do not have permission to view this user's Plex Watchlist.",
});
}
const itemsPerPage = 20;
const page = req.params.page ?? 1;
const offset = (page - 1) * itemsPerPage;
const user = await getRepository(User).findOneOrFail({
where: { id: Number(req.params.id) },
select: { id: true, plexToken: true },
});
if (!user?.plexToken) {
// We will just return an empty array if the user has no Plex token
return res.json({
page: 1,
totalPages: 1,
totalResults: 0,
results: [],
});
}
const plexTV = new PlexTvAPI(user.plexToken);
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 === 'show' ? 'tv' : 'movie',
tmdbId: item.tmdbId,
})),
});
}
);
export default router; export default router;

@ -2,6 +2,7 @@ import { UserType } from '@server/constants/user';
import dataSource, { getRepository } from '@server/datasource'; import dataSource, { getRepository } from '@server/datasource';
import { User } from '@server/entity/User'; import { User } from '@server/entity/User';
import { copyFileSync } from 'fs'; import { copyFileSync } from 'fs';
import gravatarUrl from 'gravatar-url';
import path from 'path'; import path from 'path';
const prepareDb = async () => { const prepareDb = async () => {
@ -27,9 +28,17 @@ const prepareDb = async () => {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: { id: true, plexId: true },
where: { id: 1 },
});
// Create the admin user // Create the admin user
const user = new User(); const user =
user.plexId = 1; (await userRepository.findOne({
where: { email: 'admin@seerr.dev' },
})) ?? new User();
user.plexId = admin?.plexId ?? 1;
user.plexToken = '1234'; user.plexToken = '1234';
user.plexUsername = 'admin'; user.plexUsername = 'admin';
user.username = 'admin'; user.username = 'admin';
@ -37,12 +46,15 @@ const prepareDb = async () => {
user.userType = UserType.PLEX; user.userType = UserType.PLEX;
await user.setPassword('test1234'); await user.setPassword('test1234');
user.permissions = 2; user.permissions = 2;
user.avatar = 'https://plex.tv/assets/images/avatar/default.png'; user.avatar = gravatarUrl('admin@seerr.dev', { default: 'mm', size: 200 });
await userRepository.save(user); await userRepository.save(user);
// Create the other user // Create the other user
const otherUser = new User(); const otherUser =
otherUser.plexId = 1; (await userRepository.findOne({
where: { email: 'friend@seerr.dev' },
})) ?? new User();
otherUser.plexId = admin?.plexId ?? 1;
otherUser.plexToken = '1234'; otherUser.plexToken = '1234';
otherUser.plexUsername = 'friend'; otherUser.plexUsername = 'friend';
otherUser.username = 'friend'; otherUser.username = 'friend';
@ -50,7 +62,10 @@ const prepareDb = async () => {
otherUser.userType = UserType.PLEX; otherUser.userType = UserType.PLEX;
await otherUser.setPassword('test1234'); await otherUser.setPassword('test1234');
otherUser.permissions = 32; otherUser.permissions = 32;
otherUser.avatar = 'https://plex.tv/assets/images/avatar/default.png'; otherUser.avatar = gravatarUrl('friend@seerr.dev', {
default: 'mm',
size: 200,
});
await userRepository.save(otherUser); await userRepository.save(otherUser);
}; };

@ -2,16 +2,25 @@ import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView'; import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle'; import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover'; import useDiscover from '@app/hooks/useDiscover';
import { useUser } from '@app/hooks/useUser';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({
discoverwatchlist: 'Your Plex Watchlist', discoverwatchlist: 'Your Plex Watchlist',
watchlist: 'Plex Watchlist',
}); });
const DiscoverWatchlist = () => { const DiscoverWatchlist = () => {
const intl = useIntl(); const intl = useIntl();
const router = useRouter();
const { user } = useUser({
id: Number(router.query.userId),
});
const { user: currentUser } = useUser();
const { const {
isLoadingInitialData, isLoadingInitialData,
@ -21,19 +30,43 @@ const DiscoverWatchlist = () => {
titles, titles,
fetchMore, fetchMore,
error, error,
} = useDiscover<WatchlistItem>('/api/v1/discover/watchlist'); } = useDiscover<WatchlistItem>(
`/api/v1/${
router.pathname.startsWith('/profile')
? `user/${currentUser?.id}`
: router.query.userId
? `user/${router.query.userId}`
: 'discover'
}/watchlist`
);
if (error) { if (error) {
return <Error statusCode={500} />; return <Error statusCode={500} />;
} }
const title = intl.formatMessage(messages.discoverwatchlist); const title = intl.formatMessage(
router.query.userId ? messages.watchlist : messages.discoverwatchlist
);
return ( return (
<> <>
<PageTitle title={title} /> <PageTitle
title={[title, router.query.userId ? user?.displayName : '']}
/>
<div className="mt-1 mb-5"> <div className="mt-1 mb-5">
<Header>{title}</Header> <Header
subtext={
router.query.userId ? (
<Link href={`/users/${user?.id}`}>
<a className="hover:underline">{user?.displayName}</a>
</Link>
) : (
''
)
}
>
{title}
</Header>
</div> </div>
<ListView <ListView
plexItems={titles} plexItems={titles}

@ -38,6 +38,7 @@ const UserDropdown = () => {
aria-label="User menu" aria-label="User menu"
aria-haspopup="true" aria-haspopup="true"
onClick={() => setDropdownOpen(true)} onClick={() => setDropdownOpen(true)}
data-testid="user-menu"
> >
<img <img
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10" className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
@ -76,6 +77,7 @@ const UserDropdown = () => {
} }
}} }}
onClick={() => setDropdownOpen(false)} onClick={() => setDropdownOpen(false)}
data-testid="user-menu-profile"
> >
<UserIcon className="mr-2 inline h-5 w-5" /> <UserIcon className="mr-2 inline h-5 w-5" />
<span>{intl.formatMessage(messages.myprofile)}</span> <span>{intl.formatMessage(messages.myprofile)}</span>
@ -92,6 +94,7 @@ const UserDropdown = () => {
} }
}} }}
onClick={() => setDropdownOpen(false)} onClick={() => setDropdownOpen(false)}
data-testid="user-menu-settings"
> >
<CogIcon className="mr-2 inline h-5 w-5" /> <CogIcon className="mr-2 inline h-5 w-5" />
<span>{intl.formatMessage(messages.settings)}</span> <span>{intl.formatMessage(messages.settings)}</span>

@ -72,6 +72,9 @@ export const messages = defineMessages({
viewrecent: 'View Recently Added', viewrecent: 'View Recently Added',
viewrecentDescription: viewrecentDescription:
'Grant permission to view the list of recently added media.', 'Grant permission to view the list of recently added media.',
viewwatchlists: 'View Plex Watchlists',
viewwatchlistsDescription:
"Grant permission to view other users' Plex Watchlists.",
}); });
interface PermissionEditProps { interface PermissionEditProps {
@ -126,6 +129,12 @@ export const PermissionEdit = ({
description: intl.formatMessage(messages.viewrecentDescription), description: intl.formatMessage(messages.viewrecentDescription),
permission: Permission.RECENT_VIEW, permission: Permission.RECENT_VIEW,
}, },
{
id: 'viewwatchlists',
name: intl.formatMessage(messages.viewwatchlists),
description: intl.formatMessage(messages.viewwatchlistsDescription),
permission: Permission.WATCHLIST_VIEW,
},
], ],
}, },
{ {

@ -9,6 +9,7 @@ import ProfileHeader from '@app/components/UserProfile/ProfileHeader';
import { Permission, UserType, useUser } from '@app/hooks/useUser'; import { Permission, UserType, useUser } from '@app/hooks/useUser';
import Error from '@app/pages/_error'; import Error from '@app/pages/_error';
import { ArrowCircleRightIcon } from '@heroicons/react/outline'; import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces';
import type { import type {
QuotaResponse, QuotaResponse,
UserRequestsResponse, UserRequestsResponse,
@ -33,6 +34,7 @@ const messages = defineMessages({
movierequests: 'Movie Requests', movierequests: 'Movie Requests',
seriesrequest: 'Series Requests', seriesrequest: 'Series Requests',
recentlywatched: 'Recently Watched', recentlywatched: 'Recently Watched',
plexwatchlist: 'Plex Watchlist',
}); });
type MediaTitle = MovieDetails | TvDetails; type MediaTitle = MovieDetails | TvDetails;
@ -74,6 +76,21 @@ const UserProfile = () => {
? `/api/v1/user/${user.id}/watch_data` ? `/api/v1/user/${user.id}/watch_data`
: null : null
); );
const { data: watchlistItems, error: watchlistError } =
useSWR<WatchlistResponse>(
user?.id === currentUser?.id ||
currentHasPermission(
[Permission.MANAGE_REQUESTS, Permission.WATCHLIST_VIEW],
{
type: 'or',
}
)
? `/api/v1/user/${user?.id}/watchlist`
: null,
{
revalidateOnMount: true,
}
);
const updateAvailableTitles = useCallback( const updateAvailableTitles = useCallback(
(requestId: number, mediaTitle: MediaTitle) => { (requestId: number, mediaTitle: MediaTitle) => {
@ -277,6 +294,36 @@ const UserProfile = () => {
/> />
</> </>
)} )}
{(!watchlistItems || !!watchlistItems.results.length) && !watchlistError && (
<>
<div className="slider-header">
<Link
href={
user.id === currentUser?.id
? '/profile/watchlist'
: `/users/${user?.id}/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}
/>
))}
/>
</>
)}
{(user.id === currentUser?.id || {(user.id === currentUser?.id ||
currentHasPermission(Permission.ADMIN)) && currentHasPermission(Permission.ADMIN)) &&
!!watchData?.recentlyWatched.length && ( !!watchData?.recentlyWatched.length && (

@ -11,6 +11,7 @@
"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": "Your Plex Watchlist", "components.Discover.DiscoverWatchlist.discoverwatchlist": "Your Plex Watchlist",
"components.Discover.DiscoverWatchlist.watchlist": "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",
@ -268,6 +269,8 @@
"components.PermissionEdit.viewrecentDescription": "Grant permission to view the list of recently added media.", "components.PermissionEdit.viewrecentDescription": "Grant permission to view the list of recently added media.",
"components.PermissionEdit.viewrequests": "View Requests", "components.PermissionEdit.viewrequests": "View Requests",
"components.PermissionEdit.viewrequestsDescription": "Grant permission to view media requests submitted by other users.", "components.PermissionEdit.viewrequestsDescription": "Grant permission to view media requests submitted by other users.",
"components.PermissionEdit.viewwatchlists": "View Plex Watchlists",
"components.PermissionEdit.viewwatchlistsDescription": "Grant permission to view other users' Plex Watchlists.",
"components.PersonDetails.alsoknownas": "Also Known As: {names}", "components.PersonDetails.alsoknownas": "Also Known As: {names}",
"components.PersonDetails.appearsin": "Appearances", "components.PersonDetails.appearsin": "Appearances",
"components.PersonDetails.ascharacter": "as {character}", "components.PersonDetails.ascharacter": "as {character}",
@ -1015,6 +1018,7 @@
"components.UserProfile.movierequests": "Movie Requests", "components.UserProfile.movierequests": "Movie Requests",
"components.UserProfile.norequests": "No requests.", "components.UserProfile.norequests": "No requests.",
"components.UserProfile.pastdays": "{type} (past {days} days)", "components.UserProfile.pastdays": "{type} (past {days} days)",
"components.UserProfile.plexwatchlist": "Plex Watchlist",
"components.UserProfile.recentlywatched": "Recently Watched", "components.UserProfile.recentlywatched": "Recently Watched",
"components.UserProfile.recentrequests": "Recent Requests", "components.UserProfile.recentrequests": "Recent Requests",
"components.UserProfile.requestsperdays": "{limit} remaining", "components.UserProfile.requestsperdays": "{limit} remaining",

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

@ -0,0 +1,13 @@
import DiscoverWatchlist from '@app/components/Discover/DiscoverWatchlist';
import useRouteGuard from '@app/hooks/useRouteGuard';
import { Permission } from '@app/hooks/useUser';
import type { NextPage } from 'next';
const UserRequestsPage: NextPage = () => {
useRouteGuard([Permission.MANAGE_REQUESTS, Permission.WATCHLIST_VIEW], {
type: 'or',
});
return <DiscoverWatchlist />;
};
export default UserRequestsPage;
Loading…
Cancel
Save