mirror of
https://github.com/sct/overseerr.git
synced 2025-12-30 09:41:37 +01:00
* refactor(jellyfinsettings): abstract jellyfin hostname, updated ui to reflect it, better validation This PR refactors and abstracts jellyfin hostname into, jellyfin ip, jellyfin port, jellyfin useSsl, and jellyfin urlBase. This makes it more consistent with how plex settings are stored as well. In addition, this improves validation as validation can be applied seperately to them instead of as one whole regex doing the work to validate the url. UI was updated to reflect this. BREAKING CHANGE: Jellyfin settings now does not include a hostname. Instead it abstracted it to ip, port, useSsl, and urlBase. However, migration of old settings to new settings should work automatically. * refactor: remove console logs and use getHostname and ApiErrorCodes * fix: store req.body jellyfin settings temporarily and store only if valid This should fix the issue where settings are saved even if the url was invalid. Now the settings will only be saved if the url is valid. Sort of like a test connection. * refactor: clean up commented out code * refactor(i18n): extract translation keys * fix(auth): auth failing with jellyfin login is disabled * fix(settings): jellyfin migrations replacing the rest of the settings * fix(settings): jellyfin hostname should be carried out if hostname exists * fix(settings): merging the wrong settings source * refactor(settings): use migrator for dynamic settings migrations * refactor(settingsmigrator): settings migration handler and the migrations * test(cypress): fix cypress tests failing cypress settings were lacking some of the jobs so when the startJobs() is called when the app starts, it was failing to schedule the jobs where their cron timings were not specified in the cypress settings. Therefore, this commit adds those jobs back. In addition, other setting options were added to keep cypress settings consistent with a normal user. * chore(prettierignore): ignore cypress/config/settings.cypress.json as it does not need prettier * chore(prettier): ran formatter on cypress config to fix format check error format check locally passes on this file. However, it fails during the github actions format check. Therefore, json language features formatter was run instead of prettier to see if that fixes the issue. * test(cypress): add only missing jobs to the cypress settings * ci: attempt at trying to get formatter to pass on cypress config json file * refactor: revert the changes brought to try and fix formatter added back the rest of the cypress settings and removed cypress settings from .prettierignore * refactor(settings): better erorr logging when jellyfin connection test fails in settings page
774 lines
22 KiB
TypeScript
774 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 { 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 user.email ELSE LOWER(user.plexUsername) 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 userRepository = getRepository(User);
|
|
|
|
const existingUser = await userRepository
|
|
.createQueryBuilder('user')
|
|
.where('user.email = :email', {
|
|
email: body.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(body.email, { default: 'mm', size: 200 });
|
|
|
|
if (
|
|
!passedExplicitPassword &&
|
|
!settings.notifications.agents.email.enabled
|
|
) {
|
|
throw new Error('Email notifications must be enabled');
|
|
}
|
|
|
|
const user = new User({
|
|
avatar: body.avatar ?? avatar,
|
|
username: body.username,
|
|
email: body.email,
|
|
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',
|
|
'jellyfinAuthToken',
|
|
'jellyfinDeviceId',
|
|
'jellyfinUserId',
|
|
],
|
|
order: { id: 'ASC' },
|
|
});
|
|
const jellyfinClient = new JellyfinAPI(
|
|
admin.jellyfinAuthToken ?? '',
|
|
admin.jellyfinDeviceId ?? ''
|
|
);
|
|
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
|
|
|
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
|
const createdUsers: User[] = [];
|
|
const { externalHostname } = getSettings().jellyfin;
|
|
const hostname = getHostname();
|
|
|
|
const jellyfinHost =
|
|
externalHostname && externalHostname.length > 0
|
|
? externalHostname
|
|
: hostname;
|
|
|
|
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
|
|
? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
|
|
: gravatarUrl(jellyfinUser?.Name ?? '', {
|
|
default: 'mm',
|
|
size: 200,
|
|
}),
|
|
userType: UserType.JELLYFIN,
|
|
});
|
|
|
|
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;
|