diff --git a/cypress/e2e/discover.cy.ts b/cypress/e2e/discover.cy.ts index cf4f1d6b..a58eb4ae 100644 --- a/cypress/e2e/discover.cy.ts +++ b/cypress/e2e/discover.cy.ts @@ -173,9 +173,9 @@ describe('Discover', () => { }); it('loads plex watchlist', () => { - cy.intercept('/api/v1/discover/watchlist', { fixture: 'watchlist' }).as( - 'getWatchlist' - ); + cy.intercept('/api/v1/discover/watchlist', { + fixture: 'watchlist.json', + }).as('getWatchlist'); // Wait for one of the watchlist movies to resolve cy.intercept('/api/v1/movie/361743').as('getTmdbMovie'); @@ -183,7 +183,7 @@ describe('Discover', () => { cy.wait('@getWatchlist'); - const sliderHeader = cy.contains('.slider-header', 'Plex Watchlist'); + const sliderHeader = cy.contains('.slider-header', 'Your Plex Watchlist'); sliderHeader.scrollIntoView(); @@ -203,7 +203,6 @@ describe('Discover', () => { .next('[data-testid=media-slider]') .find('[data-testid=title-card]') .first() - .click() .click(); cy.get('[data-testid=media-title]').should('contain', text); }); diff --git a/cypress/e2e/user/profile.cy.ts b/cypress/e2e/user/profile.cy.ts new file mode 100644 index 00000000..1f531b41 --- /dev/null +++ b/cypress/e2e/user/profile.cy.ts @@ -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); + }); + }); +}); diff --git a/cypress/fixtures/watchlist.json b/cypress/fixtures/watchlist.json index 0f80b27b..896cef74 100644 --- a/cypress/fixtures/watchlist.json +++ b/cypress/fixtures/watchlist.json @@ -1,7 +1,7 @@ { "page": 1, "totalPages": 1, - "totalResults": 20, + "totalResults": 3, "results": [ { "ratingKey": "5d776be17a53e9001e732ab9", diff --git a/overseerr-api.yml b/overseerr-api.yml index fccc26bc..25a24667 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -3512,6 +3512,53 @@ paths: restricted: type: boolean 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: get: summary: Get general settings for a user diff --git a/server/interfaces/api/discoverInterfaces.ts b/server/interfaces/api/discoverInterfaces.ts index 20bdc494..89cb7426 100644 --- a/server/interfaces/api/discoverInterfaces.ts +++ b/server/interfaces/api/discoverInterfaces.ts @@ -10,3 +10,10 @@ export interface WatchlistItem { mediaType: 'movie' | 'tv'; title: string; } + +export interface WatchlistResponse { + page: number; + totalPages: number; + totalResults: number; + results: WatchlistItem[]; +} diff --git a/server/interfaces/api/userInterfaces.ts b/server/interfaces/api/userInterfaces.ts index 01fe1ab0..2ac75c5e 100644 --- a/server/interfaces/api/userInterfaces.ts +++ b/server/interfaces/api/userInterfaces.ts @@ -23,6 +23,7 @@ export interface QuotaResponse { movie: QuotaStatus; tv: QuotaStatus; } + export interface UserWatchDataResponse { recentlyWatched: Media[]; playCount: number; diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index 5c85c6c9..98c81a49 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -25,6 +25,7 @@ export enum Permission { AUTO_REQUEST_MOVIE = 16777216, AUTO_REQUEST_TV = 33554432, RECENT_VIEW = 67108864, + WATCHLIST_VIEW = 134217728, } export interface PermissionCheckOptions { diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 8fb009d2..b39a8332 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -6,7 +6,7 @@ import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; import type { GenreSliderItem, - WatchlistItem, + WatchlistResponse, } from '@server/interfaces/api/discoverInterfaces'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; @@ -713,50 +713,45 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( } ); -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 }, - 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: [], +discoverRoutes.get<{ page?: number }, WatchlistResponse>( + '/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 }, + select: ['id', 'plexToken'], }); - } - 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({ - 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, - })), - }); -}); + 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 discoverRoutes; diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 98974f3d..f77b7e51 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -7,6 +7,7 @@ import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; import { UserPushSubscription } from '@server/entity/UserPushSubscription'; +import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces'; import type { QuotaResponse, 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; diff --git a/server/scripts/prepareTestDb.ts b/server/scripts/prepareTestDb.ts index 46e16b3b..7caede41 100644 --- a/server/scripts/prepareTestDb.ts +++ b/server/scripts/prepareTestDb.ts @@ -2,6 +2,7 @@ import { UserType } from '@server/constants/user'; import dataSource, { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import { copyFileSync } from 'fs'; +import gravatarUrl from 'gravatar-url'; import path from 'path'; const prepareDb = async () => { @@ -27,9 +28,17 @@ const prepareDb = async () => { const userRepository = getRepository(User); + const admin = await userRepository.findOne({ + select: { id: true, plexId: true }, + where: { id: 1 }, + }); + // Create the admin user - const user = new User(); - user.plexId = 1; + const user = + (await userRepository.findOne({ + where: { email: 'admin@seerr.dev' }, + })) ?? new User(); + user.plexId = admin?.plexId ?? 1; user.plexToken = '1234'; user.plexUsername = 'admin'; user.username = 'admin'; @@ -37,12 +46,15 @@ const prepareDb = async () => { user.userType = UserType.PLEX; await user.setPassword('test1234'); 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); // Create the other user - const otherUser = new User(); - otherUser.plexId = 1; + const otherUser = + (await userRepository.findOne({ + where: { email: 'friend@seerr.dev' }, + })) ?? new User(); + otherUser.plexId = admin?.plexId ?? 1; otherUser.plexToken = '1234'; otherUser.plexUsername = 'friend'; otherUser.username = 'friend'; @@ -50,7 +62,10 @@ const prepareDb = async () => { otherUser.userType = UserType.PLEX; await otherUser.setPassword('test1234'); 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); }; diff --git a/src/components/Discover/DiscoverWatchlist/index.tsx b/src/components/Discover/DiscoverWatchlist/index.tsx index 00cfe780..fbbdff01 100644 --- a/src/components/Discover/DiscoverWatchlist/index.tsx +++ b/src/components/Discover/DiscoverWatchlist/index.tsx @@ -2,16 +2,25 @@ import Header from '@app/components/Common/Header'; import ListView from '@app/components/Common/ListView'; import PageTitle from '@app/components/Common/PageTitle'; import useDiscover from '@app/hooks/useDiscover'; +import { useUser } from '@app/hooks/useUser'; import Error from '@app/pages/_error'; import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ discoverwatchlist: 'Your Plex Watchlist', + watchlist: 'Plex Watchlist', }); const DiscoverWatchlist = () => { const intl = useIntl(); + const router = useRouter(); + const { user } = useUser({ + id: Number(router.query.userId), + }); + const { user: currentUser } = useUser(); const { isLoadingInitialData, @@ -21,19 +30,43 @@ const DiscoverWatchlist = () => { titles, fetchMore, error, - } = useDiscover('/api/v1/discover/watchlist'); + } = useDiscover( + `/api/v1/${ + router.pathname.startsWith('/profile') + ? `user/${currentUser?.id}` + : router.query.userId + ? `user/${router.query.userId}` + : 'discover' + }/watchlist` + ); if (error) { return ; } - const title = intl.formatMessage(messages.discoverwatchlist); + const title = intl.formatMessage( + router.query.userId ? messages.watchlist : messages.discoverwatchlist + ); return ( <> - +
-
{title}
+
+ {user?.displayName} + + ) : ( + '' + ) + } + > + {title} +
{ aria-label="User menu" aria-haspopup="true" onClick={() => setDropdownOpen(true)} + data-testid="user-menu" > { } }} onClick={() => setDropdownOpen(false)} + data-testid="user-menu-profile" > {intl.formatMessage(messages.myprofile)} @@ -92,6 +94,7 @@ const UserDropdown = () => { } }} onClick={() => setDropdownOpen(false)} + data-testid="user-menu-settings" > {intl.formatMessage(messages.settings)} diff --git a/src/components/PermissionEdit/index.tsx b/src/components/PermissionEdit/index.tsx index 69fe6037..768b9281 100644 --- a/src/components/PermissionEdit/index.tsx +++ b/src/components/PermissionEdit/index.tsx @@ -72,6 +72,9 @@ export const messages = defineMessages({ viewrecent: 'View Recently Added', viewrecentDescription: '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 { @@ -126,6 +129,12 @@ export const PermissionEdit = ({ description: intl.formatMessage(messages.viewrecentDescription), permission: Permission.RECENT_VIEW, }, + { + id: 'viewwatchlists', + name: intl.formatMessage(messages.viewwatchlists), + description: intl.formatMessage(messages.viewwatchlistsDescription), + permission: Permission.WATCHLIST_VIEW, + }, ], }, { diff --git a/src/components/UserProfile/index.tsx b/src/components/UserProfile/index.tsx index 0b521922..43b62fd5 100644 --- a/src/components/UserProfile/index.tsx +++ b/src/components/UserProfile/index.tsx @@ -9,6 +9,7 @@ import ProfileHeader from '@app/components/UserProfile/ProfileHeader'; import { Permission, UserType, useUser } from '@app/hooks/useUser'; import Error from '@app/pages/_error'; import { ArrowCircleRightIcon } from '@heroicons/react/outline'; +import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces'; import type { QuotaResponse, UserRequestsResponse, @@ -33,6 +34,7 @@ const messages = defineMessages({ movierequests: 'Movie Requests', seriesrequest: 'Series Requests', recentlywatched: 'Recently Watched', + plexwatchlist: 'Plex Watchlist', }); type MediaTitle = MovieDetails | TvDetails; @@ -74,6 +76,21 @@ const UserProfile = () => { ? `/api/v1/user/${user.id}/watch_data` : null ); + const { data: watchlistItems, error: watchlistError } = + useSWR( + 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( (requestId: number, mediaTitle: MediaTitle) => { @@ -277,6 +294,36 @@ const UserProfile = () => { /> )} + {(!watchlistItems || !!watchlistItems.results.length) && !watchlistError && ( + <> + + ( + + ))} + /> + + )} {(user.id === currentUser?.id || currentHasPermission(Permission.ADMIN)) && !!watchData?.recentlyWatched.length && ( diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index b9affda9..165da857 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -11,6 +11,7 @@ "components.Discover.DiscoverTvGenre.genreSeries": "{genre} Series", "components.Discover.DiscoverTvLanguage.languageSeries": "{language} Series", "components.Discover.DiscoverWatchlist.discoverwatchlist": "Your Plex Watchlist", + "components.Discover.DiscoverWatchlist.watchlist": "Plex Watchlist", "components.Discover.MovieGenreList.moviegenres": "Movie Genres", "components.Discover.MovieGenreSlider.moviegenres": "Movie Genres", "components.Discover.NetworkSlider.networks": "Networks", @@ -268,6 +269,8 @@ "components.PermissionEdit.viewrecentDescription": "Grant permission to view the list of recently added media.", "components.PermissionEdit.viewrequests": "View Requests", "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.appearsin": "Appearances", "components.PersonDetails.ascharacter": "as {character}", @@ -1015,6 +1018,7 @@ "components.UserProfile.movierequests": "Movie Requests", "components.UserProfile.norequests": "No requests.", "components.UserProfile.pastdays": "{type} (past {days} days)", + "components.UserProfile.plexwatchlist": "Plex Watchlist", "components.UserProfile.recentlywatched": "Recently Watched", "components.UserProfile.recentrequests": "Recent Requests", "components.UserProfile.requestsperdays": "{limit} remaining", diff --git a/src/pages/profile/watchlist.tsx b/src/pages/profile/watchlist.tsx new file mode 100644 index 00000000..286578ab --- /dev/null +++ b/src/pages/profile/watchlist.tsx @@ -0,0 +1,8 @@ +import DiscoverWatchlist from '@app/components/Discover/DiscoverWatchlist'; +import type { NextPage } from 'next'; + +const UserWatchlistPage: NextPage = () => { + return ; +}; + +export default UserWatchlistPage; diff --git a/src/pages/users/[userId]/watchlist.tsx b/src/pages/users/[userId]/watchlist.tsx new file mode 100644 index 00000000..3298bf61 --- /dev/null +++ b/src/pages/users/[userId]/watchlist.tsx @@ -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 ; +}; + +export default UserRequestsPage;