* fix: rewrite avatarproxy and CachedImage Avatar proxy was allowing every request to be proxied, no matter the original ressource's origin or filetype. This PR fixes it be allowing only relevant resources to be cached, i.e. Jellyfin/Emby images and TMDB images. fix #1012, #1013 * fix: resolve CodeQL error * fix: resolve CodeQL error * fix: resolve review comments * fix: resolve review comment * fix: resolve CodeQL error * fix: update imageproxy path
782 lines
22 KiB
TypeScript
782 lines
22 KiB
TypeScript
import JellyfinAPI from '@server/api/jellyfin';
|
|
import PlexTvAPI from '@server/api/plextv';
|
|
import TautulliAPI from '@server/api/tautulli';
|
|
import { MediaType } from '@server/constants/media';
|
|
import { MediaServerType } from '@server/constants/server';
|
|
import { UserType } from '@server/constants/user';
|
|
import { getRepository } from '@server/datasource';
|
|
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 { Watchlist } from '@server/entity/Watchlist';
|
|
import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces';
|
|
import type {
|
|
QuotaResponse,
|
|
UserRequestsResponse,
|
|
UserResultsResponse,
|
|
UserWatchDataResponse,
|
|
} from '@server/interfaces/api/userInterfaces';
|
|
import { hasPermission, Permission } from '@server/lib/permissions';
|
|
import { getSettings } from '@server/lib/settings';
|
|
import logger from '@server/logger';
|
|
import { isAuthenticated } from '@server/middleware/auth';
|
|
import { getHostname } from '@server/utils/getHostname';
|
|
import { Router } from 'express';
|
|
import gravatarUrl from 'gravatar-url';
|
|
import { findIndex, sortBy } from 'lodash';
|
|
import { In } from 'typeorm';
|
|
import userSettingsRoutes from './usersettings';
|
|
|
|
const router = Router();
|
|
|
|
router.get('/', async (req, res, next) => {
|
|
try {
|
|
const pageSize = req.query.take ? Number(req.query.take) : 10;
|
|
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
|
let query = getRepository(User).createQueryBuilder('user');
|
|
|
|
switch (req.query.sort) {
|
|
case 'updated':
|
|
query = query.orderBy('user.updatedAt', 'DESC');
|
|
break;
|
|
case 'displayname':
|
|
query = query.orderBy(
|
|
`CASE WHEN (user.username IS NULL OR user.username = '') THEN (
|
|
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
|
|
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
|
|
user.email
|
|
ELSE
|
|
LOWER(user.jellyfinUsername)
|
|
END)
|
|
ELSE
|
|
LOWER(user.jellyfinUsername)
|
|
END)
|
|
ELSE
|
|
LOWER(user.username)
|
|
END`,
|
|
'ASC'
|
|
);
|
|
break;
|
|
case 'requests':
|
|
query = query
|
|
.addSelect((subQuery) => {
|
|
return subQuery
|
|
.select('COUNT(request.id)', 'requestCount')
|
|
.from(MediaRequest, 'request')
|
|
.where('request.requestedBy.id = user.id');
|
|
}, 'requestCount')
|
|
.orderBy('requestCount', 'DESC');
|
|
break;
|
|
default:
|
|
query = query.orderBy('user.id', 'ASC');
|
|
break;
|
|
}
|
|
|
|
const [users, userCount] = await query
|
|
.take(pageSize)
|
|
.skip(skip)
|
|
.getManyAndCount();
|
|
|
|
return res.status(200).json({
|
|
pageInfo: {
|
|
pages: Math.ceil(userCount / pageSize),
|
|
pageSize,
|
|
results: userCount,
|
|
page: Math.ceil(skip / pageSize) + 1,
|
|
},
|
|
results: User.filterMany(
|
|
users,
|
|
req.user?.hasPermission(Permission.MANAGE_USERS)
|
|
),
|
|
} as UserResultsResponse);
|
|
} catch (e) {
|
|
next({ status: 500, message: e.message });
|
|
}
|
|
});
|
|
|
|
router.post(
|
|
'/',
|
|
isAuthenticated(Permission.MANAGE_USERS),
|
|
async (req, res, next) => {
|
|
try {
|
|
const settings = getSettings();
|
|
|
|
const body = req.body;
|
|
const email = body.email || body.username;
|
|
const userRepository = getRepository(User);
|
|
|
|
const existingUser = await userRepository
|
|
.createQueryBuilder('user')
|
|
.where('user.email = :email', {
|
|
email: email.toLowerCase(),
|
|
})
|
|
.getOne();
|
|
|
|
if (existingUser) {
|
|
return next({
|
|
status: 409,
|
|
message: 'User already exists with submitted email.',
|
|
errors: ['USER_EXISTS'],
|
|
});
|
|
}
|
|
|
|
const passedExplicitPassword = body.password && body.password.length > 0;
|
|
const avatar = gravatarUrl(email, { default: 'mm', size: 200 });
|
|
|
|
if (
|
|
!passedExplicitPassword &&
|
|
!settings.notifications.agents.email.enabled
|
|
) {
|
|
throw new Error('Email notifications must be enabled');
|
|
}
|
|
|
|
const user = new User({
|
|
email,
|
|
avatar: body.avatar ?? avatar,
|
|
username: body.username,
|
|
password: body.password,
|
|
permissions: settings.main.defaultPermissions,
|
|
plexToken: '',
|
|
userType: UserType.LOCAL,
|
|
});
|
|
|
|
if (passedExplicitPassword) {
|
|
await user?.setPassword(body.password);
|
|
} else {
|
|
await user?.generatePassword();
|
|
}
|
|
|
|
await userRepository.save(user);
|
|
return res.status(201).json(user.filter());
|
|
} catch (e) {
|
|
next({ status: 500, message: e.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
router.post<
|
|
never,
|
|
unknown,
|
|
{
|
|
endpoint: string;
|
|
p256dh: string;
|
|
auth: string;
|
|
}
|
|
>('/registerPushSubscription', async (req, res, next) => {
|
|
try {
|
|
const userPushSubRepository = getRepository(UserPushSubscription);
|
|
|
|
const existingSubs = await userPushSubRepository.find({
|
|
where: { auth: req.body.auth },
|
|
});
|
|
|
|
if (existingSubs.length > 0) {
|
|
logger.debug(
|
|
'User push subscription already exists. Skipping registration.',
|
|
{ label: 'API' }
|
|
);
|
|
return res.status(204).send();
|
|
}
|
|
|
|
const userPushSubscription = new UserPushSubscription({
|
|
auth: req.body.auth,
|
|
endpoint: req.body.endpoint,
|
|
p256dh: req.body.p256dh,
|
|
user: req.user,
|
|
});
|
|
|
|
userPushSubRepository.save(userPushSubscription);
|
|
|
|
return res.status(204).send();
|
|
} catch (e) {
|
|
logger.error('Failed to register user push subscription', {
|
|
label: 'API',
|
|
});
|
|
next({ status: 500, message: 'Failed to register subscription.' });
|
|
}
|
|
});
|
|
|
|
router.get<{ id: string }>('/:id', async (req, res, next) => {
|
|
try {
|
|
const userRepository = getRepository(User);
|
|
|
|
const user = await userRepository.findOneOrFail({
|
|
where: { id: Number(req.params.id) },
|
|
});
|
|
|
|
return res
|
|
.status(200)
|
|
.json(user.filter(req.user?.hasPermission(Permission.MANAGE_USERS)));
|
|
} catch (e) {
|
|
next({ status: 404, message: 'User not found.' });
|
|
}
|
|
});
|
|
|
|
router.use('/:id/settings', userSettingsRoutes);
|
|
|
|
router.get<{ id: string }, UserRequestsResponse>(
|
|
'/:id/requests',
|
|
async (req, res, next) => {
|
|
const pageSize = req.query.take ? Number(req.query.take) : 20;
|
|
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
|
|
|
try {
|
|
const user = await getRepository(User).findOne({
|
|
where: { id: Number(req.params.id) },
|
|
});
|
|
|
|
if (!user) {
|
|
return next({ status: 404, message: 'User not found.' });
|
|
}
|
|
|
|
if (
|
|
user.id !== req.user?.id &&
|
|
!req.user?.hasPermission(
|
|
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
|
{ type: 'or' }
|
|
)
|
|
) {
|
|
return next({
|
|
status: 403,
|
|
message: "You do not have permission to view this user's requests.",
|
|
});
|
|
}
|
|
|
|
const [requests, requestCount] = await getRepository(MediaRequest)
|
|
.createQueryBuilder('request')
|
|
.leftJoinAndSelect('request.media', 'media')
|
|
.leftJoinAndSelect('request.seasons', 'seasons')
|
|
.leftJoinAndSelect('request.modifiedBy', 'modifiedBy')
|
|
.leftJoinAndSelect('request.requestedBy', 'requestedBy')
|
|
.andWhere('requestedBy.id = :id', {
|
|
id: user.id,
|
|
})
|
|
.orderBy('request.id', 'DESC')
|
|
.take(pageSize)
|
|
.skip(skip)
|
|
.getManyAndCount();
|
|
|
|
return res.status(200).json({
|
|
pageInfo: {
|
|
pages: Math.ceil(requestCount / pageSize),
|
|
pageSize,
|
|
results: requestCount,
|
|
page: Math.ceil(skip / pageSize) + 1,
|
|
},
|
|
results: requests,
|
|
});
|
|
} catch (e) {
|
|
next({ status: 500, message: e.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
export const canMakePermissionsChange = (
|
|
permissions: number,
|
|
user?: User
|
|
): boolean =>
|
|
// Only let the owner grant admin privileges
|
|
!(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1);
|
|
|
|
router.put<
|
|
Record<string, never>,
|
|
Partial<User>[],
|
|
{ ids: string[]; permissions: number }
|
|
>('/', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => {
|
|
try {
|
|
const isOwner = req.user?.id === 1;
|
|
|
|
if (!canMakePermissionsChange(req.body.permissions, req.user)) {
|
|
return next({
|
|
status: 403,
|
|
message: 'You do not have permission to grant this level of access',
|
|
});
|
|
}
|
|
|
|
const userRepository = getRepository(User);
|
|
|
|
const users: User[] = await userRepository.find({
|
|
where: {
|
|
id: In(
|
|
isOwner ? req.body.ids : req.body.ids.filter((id) => Number(id) !== 1)
|
|
),
|
|
},
|
|
});
|
|
|
|
const updatedUsers = await Promise.all(
|
|
users.map(async (user) => {
|
|
return userRepository.save(<User>{
|
|
...user,
|
|
...{ permissions: req.body.permissions },
|
|
});
|
|
})
|
|
);
|
|
|
|
return res.status(200).json(updatedUsers);
|
|
} catch (e) {
|
|
next({ status: 500, message: e.message });
|
|
}
|
|
});
|
|
|
|
router.put<{ id: string }>(
|
|
'/:id',
|
|
isAuthenticated(Permission.MANAGE_USERS),
|
|
async (req, res, next) => {
|
|
try {
|
|
const userRepository = getRepository(User);
|
|
|
|
const user = await userRepository.findOneOrFail({
|
|
where: { id: Number(req.params.id) },
|
|
});
|
|
|
|
// Only let the owner user modify themselves
|
|
if (user.id === 1 && req.user?.id !== 1) {
|
|
return next({
|
|
status: 403,
|
|
message: 'You do not have permission to modify this user',
|
|
});
|
|
}
|
|
|
|
if (!canMakePermissionsChange(req.body.permissions, req.user)) {
|
|
return next({
|
|
status: 403,
|
|
message: 'You do not have permission to grant this level of access',
|
|
});
|
|
}
|
|
|
|
Object.assign(user, {
|
|
username: req.body.username,
|
|
permissions: req.body.permissions,
|
|
});
|
|
|
|
await userRepository.save(user);
|
|
|
|
return res.status(200).json(user.filter());
|
|
} catch (e) {
|
|
next({ status: 404, message: 'User not found.' });
|
|
}
|
|
}
|
|
);
|
|
|
|
router.delete<{ id: string }>(
|
|
'/:id',
|
|
isAuthenticated(Permission.MANAGE_USERS),
|
|
async (req, res, next) => {
|
|
try {
|
|
const userRepository = getRepository(User);
|
|
|
|
const user = await userRepository.findOne({
|
|
where: { id: Number(req.params.id) },
|
|
relations: { requests: true },
|
|
});
|
|
|
|
if (!user) {
|
|
return next({ status: 404, message: 'User not found.' });
|
|
}
|
|
|
|
if (user.id === 1) {
|
|
return next({
|
|
status: 405,
|
|
message: 'This account cannot be deleted.',
|
|
});
|
|
}
|
|
|
|
if (user.hasPermission(Permission.ADMIN) && req.user?.id !== 1) {
|
|
return next({
|
|
status: 405,
|
|
message: 'You cannot delete users with administrative privileges.',
|
|
});
|
|
}
|
|
|
|
const requestRepository = getRepository(MediaRequest);
|
|
|
|
/**
|
|
* Requests are usually deleted through a cascade constraint. Those however, do
|
|
* not trigger the removal event so listeners to not run and the parent Media
|
|
* will not be updated back to unknown for titles that were still pending. So
|
|
* we manually remove all requests from the user here so the parent media's
|
|
* properly reflect the change.
|
|
*/
|
|
await requestRepository.remove(user.requests, {
|
|
/**
|
|
* Break-up into groups of 1000 requests to be removed at a time.
|
|
* Necessary for users with >1000 requests, else an SQLite 'Expression tree is too large' error occurs.
|
|
* https://typeorm.io/repository-api#additional-options
|
|
*/
|
|
chunk: user.requests.length / 1000,
|
|
});
|
|
|
|
await userRepository.delete(user.id);
|
|
return res.status(200).json(user.filter());
|
|
} catch (e) {
|
|
logger.error('Something went wrong deleting a user', {
|
|
label: 'API',
|
|
userId: req.params.id,
|
|
errorMessage: e.message,
|
|
});
|
|
return next({
|
|
status: 500,
|
|
message: 'Something went wrong deleting the user',
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/import-from-plex',
|
|
isAuthenticated(Permission.MANAGE_USERS),
|
|
async (req, res, next) => {
|
|
try {
|
|
const settings = getSettings();
|
|
const userRepository = getRepository(User);
|
|
const body = req.body as { plexIds: string[] } | undefined;
|
|
|
|
// taken from auth.ts
|
|
const mainUser = await userRepository.findOneOrFail({
|
|
select: { id: true, plexToken: true },
|
|
where: { id: 1 },
|
|
});
|
|
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
|
|
|
|
const plexUsersResponse = await mainPlexTv.getUsers();
|
|
const createdUsers: User[] = [];
|
|
for (const rawUser of plexUsersResponse.MediaContainer.User) {
|
|
const account = rawUser.$;
|
|
|
|
if (account.email) {
|
|
const user = await userRepository
|
|
.createQueryBuilder('user')
|
|
.where('user.plexId = :id', { id: account.id })
|
|
.orWhere('user.email = :email', {
|
|
email: account.email.toLowerCase(),
|
|
})
|
|
.getOne();
|
|
|
|
if (user) {
|
|
// Update the user's avatar with their Plex thumbnail, in case it changed
|
|
user.avatar = account.thumb;
|
|
user.email = account.email;
|
|
user.plexUsername = account.username;
|
|
|
|
// In case the user was previously a local account
|
|
if (user.userType === UserType.LOCAL) {
|
|
user.userType = UserType.PLEX;
|
|
user.plexId = parseInt(account.id);
|
|
}
|
|
await userRepository.save(user);
|
|
} else if (!body || body.plexIds.includes(account.id)) {
|
|
if (await mainPlexTv.checkUserAccess(parseInt(account.id))) {
|
|
const newUser = new User({
|
|
plexUsername: account.username,
|
|
email: account.email,
|
|
permissions: settings.main.defaultPermissions,
|
|
plexId: parseInt(account.id),
|
|
plexToken: '',
|
|
avatar: account.thumb,
|
|
userType: UserType.PLEX,
|
|
});
|
|
await userRepository.save(newUser);
|
|
createdUsers.push(newUser);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return res.status(201).json(User.filterMany(createdUsers));
|
|
} catch (e) {
|
|
next({ status: 500, message: e.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/import-from-jellyfin',
|
|
isAuthenticated(Permission.MANAGE_USERS),
|
|
async (req, res, next) => {
|
|
try {
|
|
const settings = getSettings();
|
|
const userRepository = getRepository(User);
|
|
const body = req.body as { jellyfinUserIds: string[] };
|
|
|
|
// taken from auth.ts
|
|
const admin = await userRepository.findOneOrFail({
|
|
where: { id: 1 },
|
|
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
|
|
order: { id: 'ASC' },
|
|
});
|
|
|
|
const hostname = getHostname();
|
|
const jellyfinClient = new JellyfinAPI(
|
|
hostname,
|
|
settings.jellyfin.apiKey,
|
|
admin.jellyfinDeviceId ?? ''
|
|
);
|
|
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
|
|
|
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
|
const createdUsers: User[] = [];
|
|
|
|
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
|
const jellyfinUsers = await jellyfinClient.getUsers();
|
|
|
|
for (const jellyfinUserId of body.jellyfinUserIds) {
|
|
const jellyfinUser = jellyfinUsers.users.find(
|
|
(user) => user.Id === jellyfinUserId
|
|
);
|
|
|
|
const user = await userRepository.findOne({
|
|
select: ['id', 'jellyfinUserId'],
|
|
where: { jellyfinUserId: jellyfinUserId },
|
|
});
|
|
|
|
if (!user) {
|
|
const newUser = new User({
|
|
jellyfinUsername: jellyfinUser?.Name,
|
|
jellyfinUserId: jellyfinUser?.Id,
|
|
jellyfinDeviceId: Buffer.from(
|
|
`BOT_jellyseerr_${jellyfinUser?.Name ?? ''}`
|
|
).toString('base64'),
|
|
email: jellyfinUser?.Name,
|
|
permissions: settings.main.defaultPermissions,
|
|
avatar: jellyfinUser?.PrimaryImageTag
|
|
? `/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
|
|
: gravatarUrl(jellyfinUser?.Name ?? '', {
|
|
default: 'mm',
|
|
size: 200,
|
|
}),
|
|
userType:
|
|
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
|
? UserType.JELLYFIN
|
|
: UserType.EMBY,
|
|
});
|
|
|
|
await userRepository.save(newUser);
|
|
createdUsers.push(newUser);
|
|
}
|
|
}
|
|
return res.status(201).json(User.filterMany(createdUsers));
|
|
} catch (e) {
|
|
next({ status: 500, message: e.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
router.get<{ id: string }, QuotaResponse>(
|
|
'/:id/quota',
|
|
async (req, res, next) => {
|
|
try {
|
|
const userRepository = getRepository(User);
|
|
|
|
if (
|
|
Number(req.params.id) !== req.user?.id &&
|
|
!req.user?.hasPermission(
|
|
[Permission.MANAGE_USERS, Permission.MANAGE_REQUESTS],
|
|
{ type: 'and' }
|
|
)
|
|
) {
|
|
return next({
|
|
status: 403,
|
|
message:
|
|
"You do not have permission to view this user's request limits.",
|
|
});
|
|
}
|
|
|
|
const user = await userRepository.findOneOrFail({
|
|
where: { id: Number(req.params.id) },
|
|
});
|
|
|
|
const quotas = await user.getQuota();
|
|
|
|
return res.status(200).json(quotas);
|
|
} catch (e) {
|
|
next({ status: 404, message: e.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
router.get<{ id: string }, UserWatchDataResponse>(
|
|
'/:id/watch_data',
|
|
async (req, res, next) => {
|
|
if (
|
|
Number(req.params.id) !== req.user?.id &&
|
|
!req.user?.hasPermission(Permission.ADMIN)
|
|
) {
|
|
return next({
|
|
status: 403,
|
|
message:
|
|
"You do not have permission to view this user's recently watched media.",
|
|
});
|
|
}
|
|
|
|
const settings = getSettings().tautulli;
|
|
|
|
if (!settings.hostname || !settings.port || !settings.apiKey) {
|
|
return next({
|
|
status: 404,
|
|
message: 'Tautulli API not configured.',
|
|
});
|
|
}
|
|
|
|
try {
|
|
const user = await getRepository(User).findOneOrFail({
|
|
where: { id: Number(req.params.id) },
|
|
select: { id: true, plexId: true },
|
|
});
|
|
|
|
const tautulli = new TautulliAPI(settings);
|
|
|
|
const watchStats = await tautulli.getUserWatchStats(user);
|
|
const watchHistory = await tautulli.getUserWatchHistory(user);
|
|
|
|
const recentlyWatched = sortBy(
|
|
await getRepository(Media).find({
|
|
where: [
|
|
{
|
|
mediaType: MediaType.MOVIE,
|
|
ratingKey: In(
|
|
watchHistory
|
|
.filter((record) => record.media_type === 'movie')
|
|
.map((record) => record.rating_key)
|
|
),
|
|
},
|
|
{
|
|
mediaType: MediaType.MOVIE,
|
|
ratingKey4k: In(
|
|
watchHistory
|
|
.filter((record) => record.media_type === 'movie')
|
|
.map((record) => record.rating_key)
|
|
),
|
|
},
|
|
{
|
|
mediaType: MediaType.TV,
|
|
ratingKey: In(
|
|
watchHistory
|
|
.filter((record) => record.media_type === 'episode')
|
|
.map((record) => record.grandparent_rating_key)
|
|
),
|
|
},
|
|
{
|
|
mediaType: MediaType.TV,
|
|
ratingKey4k: In(
|
|
watchHistory
|
|
.filter((record) => record.media_type === 'episode')
|
|
.map((record) => record.grandparent_rating_key)
|
|
),
|
|
},
|
|
],
|
|
}),
|
|
[
|
|
(media) =>
|
|
findIndex(
|
|
watchHistory,
|
|
(record) =>
|
|
(!!media.ratingKey &&
|
|
parseInt(media.ratingKey) ===
|
|
(record.media_type === 'movie'
|
|
? record.rating_key
|
|
: record.grandparent_rating_key)) ||
|
|
(!!media.ratingKey4k &&
|
|
parseInt(media.ratingKey4k) ===
|
|
(record.media_type === 'movie'
|
|
? record.rating_key
|
|
: record.grandparent_rating_key))
|
|
),
|
|
]
|
|
);
|
|
|
|
return res.status(200).json({
|
|
recentlyWatched,
|
|
playCount: watchStats.total_plays,
|
|
});
|
|
} catch (e) {
|
|
logger.error('Something went wrong fetching user watch data', {
|
|
label: 'API',
|
|
errorMessage: e.message,
|
|
userId: req.params.id,
|
|
});
|
|
next({
|
|
status: 500,
|
|
message: 'Failed to fetch user watch data.',
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
router.get<{ id: string }, 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 Watchlist.",
|
|
});
|
|
}
|
|
|
|
const itemsPerPage = 20;
|
|
const page = Number(req.query.page) ?? 1;
|
|
const offset = (page - 1) * itemsPerPage;
|
|
|
|
const user = await getRepository(User).findOneOrFail({
|
|
where: { id: Number(req.params.id) },
|
|
select: ['id', 'plexToken'],
|
|
});
|
|
|
|
if (user) {
|
|
const [result, total] = await getRepository(Watchlist).findAndCount({
|
|
where: { requestedBy: { id: user?.id } },
|
|
relations: {
|
|
/*requestedBy: true,media:true*/
|
|
},
|
|
// loadRelationIds: true,
|
|
take: itemsPerPage,
|
|
skip: offset,
|
|
});
|
|
if (total) {
|
|
return res.json({
|
|
page: page,
|
|
totalPages: Math.ceil(total / itemsPerPage),
|
|
totalResults: total,
|
|
results: result,
|
|
});
|
|
}
|
|
}
|
|
|
|
// We will just return an empty array if the user has no Plex token
|
|
if (!user.plexToken) {
|
|
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.totalSize / itemsPerPage),
|
|
totalResults: watchlist.totalSize,
|
|
results: watchlist.items.map((item) => ({
|
|
ratingKey: item.ratingKey,
|
|
title: item.title,
|
|
mediaType: item.type === 'show' ? 'tv' : 'movie',
|
|
tmdbId: item.tmdbId,
|
|
})),
|
|
});
|
|
}
|
|
);
|
|
|
|
export default router;
|