Skip to content

Commit

Permalink
feat: view other users' watchlists (#2959)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
TheCatLady committed Aug 22, 2022
1 parent 950b171 commit 0839718
Show file tree
Hide file tree
Showing 17 changed files with 347 additions and 58 deletions.
9 changes: 4 additions & 5 deletions cypress/e2e/discover.cy.ts
Expand Up @@ -173,17 +173,17 @@ 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');

cy.visit('/');

cy.wait('@getWatchlist');

const sliderHeader = cy.contains('.slider-header', 'Plex Watchlist');
const sliderHeader = cy.contains('.slider-header', 'Your Plex Watchlist');

sliderHeader.scrollIntoView();

Expand All @@ -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);
});
Expand Down
50 changes: 50 additions & 0 deletions 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);
});
});
});
2 changes: 1 addition & 1 deletion cypress/fixtures/watchlist.json
@@ -1,7 +1,7 @@
{
"page": 1,
"totalPages": 1,
"totalResults": 20,
"totalResults": 3,
"results": [
{
"ratingKey": "5d776be17a53e9001e732ab9",
Expand Down
47 changes: 47 additions & 0 deletions overseerr-api.yml
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions server/interfaces/api/discoverInterfaces.ts
Expand Up @@ -10,3 +10,10 @@ export interface WatchlistItem {
mediaType: 'movie' | 'tv';
title: string;
}

export interface WatchlistResponse {
page: number;
totalPages: number;
totalResults: number;
results: WatchlistItem[];
}
1 change: 1 addition & 0 deletions server/interfaces/api/userInterfaces.ts
Expand Up @@ -23,6 +23,7 @@ export interface QuotaResponse {
movie: QuotaStatus;
tv: QuotaStatus;
}

export interface UserWatchDataResponse {
recentlyWatched: Media[];
playCount: number;
Expand Down
1 change: 1 addition & 0 deletions server/lib/permissions.ts
Expand Up @@ -25,6 +25,7 @@ export enum Permission {
AUTO_REQUEST_MOVIE = 16777216,
AUTO_REQUEST_TV = 33554432,
RECENT_VIEW = 67108864,
WATCHLIST_VIEW = 134217728,
}

export interface PermissionCheckOptions {
Expand Down
79 changes: 37 additions & 42 deletions server/routes/discover.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
57 changes: 57 additions & 0 deletions server/routes/user/index.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
27 changes: 21 additions & 6 deletions server/scripts/prepareTestDb.ts
Expand Up @@ -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 () => {
Expand All @@ -27,30 +28,44 @@ 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';
user.email = 'admin@seerr.dev';
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';
otherUser.email = 'friend@seerr.dev';
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);
};

Expand Down

0 comments on commit 0839718

Please sign in to comment.