Merge branch 'develop' into features/deleteMediaFile

This commit is contained in:
dd060606
2022-09-14 14:58:37 +02:00
committed by GitHub
483 changed files with 16584 additions and 8788 deletions

View File

@@ -1,16 +1,16 @@
import { Router } from 'express';
import { getRepository } from 'typeorm';
import JellyfinAPI from '../api/jellyfin';
import PlexTvAPI from '../api/plextv';
import { MediaServerType } from '../constants/server';
import { UserType } from '../constants/user';
import { User } from '../entity/User';
import { startJobs } from '../job/schedule';
import { Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import { isAuthenticated } from '../middleware/auth';
import JellyfinAPI from '@server/api/jellyfin';
import PlexTvAPI from '@server/api/plextv';
import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { startJobs } from '@server/job/schedule';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import * as EmailValidator from 'email-validator';
import { Router } from 'express';
const authRoutes = Router();
@@ -89,8 +89,8 @@ authRoutes.post('/plex', async (req, res, next) => {
await userRepository.save(user);
} else {
const mainUser = await userRepository.findOneOrFail({
select: ['id', 'plexToken', 'plexId'],
order: { id: 'ASC' },
select: { id: true, plexToken: true, plexId: true },
where: { id: 1 },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
@@ -424,8 +424,8 @@ authRoutes.post('/local', async (req, res, next) => {
}
const mainUser = await userRepository.findOneOrFail({
select: ['id', 'plexToken', 'plexId'],
order: { id: 'ASC' },
select: { id: true, plexToken: true, plexId: true },
where: { id: 1 },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');

View File

@@ -1,8 +1,8 @@
import TheMovieDb from '@server/api/themoviedb';
import Media from '@server/entity/Media';
import logger from '@server/logger';
import { mapCollection } from '@server/models/Collection';
import { Router } from 'express';
import TheMovieDb from '../api/themoviedb';
import Media from '../entity/Media';
import logger from '../logger';
import { mapCollection } from '../models/Collection';
const collectionRoutes = Router();

View File

@@ -1,16 +1,25 @@
import PlexTvAPI from '@server/api/plextv';
import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import type {
GenreSliderItem,
WatchlistResponse,
} from '@server/interfaces/api/discoverInterfaces';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { mapProductionCompany } from '@server/models/Movie';
import {
mapMovieResult,
mapPersonResult,
mapTvResult,
} from '@server/models/Search';
import { mapNetwork } from '@server/models/Tv';
import { isMovie, isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import { sortBy } from 'lodash';
import TheMovieDb from '../api/themoviedb';
import { MediaType } from '../constants/media';
import Media from '../entity/Media';
import { User } from '../entity/User';
import { GenreSliderItem } from '../interfaces/api/discoverInterfaces';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import { mapProductionCompany } from '../models/Movie';
import { mapMovieResult, mapPersonResult, mapTvResult } from '../models/Search';
import { mapNetwork } from '../models/Tv';
import { isMovie, isPerson } from '../utils/typeHelpers';
export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
const settings = getSettings();
@@ -704,4 +713,45 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
}
);
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'],
});
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 });
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;

View File

@@ -1,17 +1,22 @@
import GithubAPI from '@server/api/github';
import TheMovieDb from '@server/api/themoviedb';
import type {
TmdbMovieResult,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { checkUser, isAuthenticated } from '@server/middleware/auth';
import { mapProductionCompany } from '@server/models/Movie';
import { mapNetwork } from '@server/models/Tv';
import settingsRoutes from '@server/routes/settings';
import { appDataPath, appDataStatus } from '@server/utils/appDataVolume';
import { getAppVersion, getCommitTag } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag';
import { isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import GithubAPI from '../api/github';
import TheMovieDb from '../api/themoviedb';
import { TmdbMovieResult, TmdbTvResult } from '../api/themoviedb/interfaces';
import { StatusResponse } from '../interfaces/api/settingsInterfaces';
import { Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import { checkUser, isAuthenticated } from '../middleware/auth';
import { mapProductionCompany } from '../models/Movie';
import { mapNetwork } from '../models/Tv';
import { appDataPath, appDataStatus } from '../utils/appDataVolume';
import { getAppVersion, getCommitTag } from '../utils/appVersion';
import { isPerson } from '../utils/typeHelpers';
import authRoutes from './auth';
import collectionRoutes from './collection';
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
@@ -23,7 +28,6 @@ import personRoutes from './person';
import requestRoutes from './request';
import searchRoutes from './search';
import serviceRoutes from './service';
import settingsRoutes from './settings';
import tvRoutes from './tv';
import user from './user';
@@ -75,6 +79,7 @@ router.get<unknown, StatusResponse>('/status', async (req, res) => {
commitTag: getCommitTag(),
updateAvailable,
commitsBehind,
restartRequired: restartFlag.isSet(),
});
});
@@ -97,11 +102,7 @@ router.get('/settings/public', async (req, res) => {
return res.status(200).json(settings.fullPublicSettings);
}
});
router.use(
'/settings',
isAuthenticated(Permission.MANAGE_SETTINGS),
settingsRoutes
);
router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes);
router.use('/search', isAuthenticated(), searchRoutes);
router.use('/discover', isAuthenticated(), discoverRoutes);
router.use('/request', isAuthenticated(), requestRoutes);

View File

@@ -1,13 +1,13 @@
import { IssueStatus, IssueType } from '@server/constants/issue';
import { getRepository } from '@server/datasource';
import Issue from '@server/entity/Issue';
import IssueComment from '@server/entity/IssueComment';
import Media from '@server/entity/Media';
import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
import { getRepository } from 'typeorm';
import { IssueStatus, IssueType } from '../constants/issue';
import Issue from '../entity/Issue';
import IssueComment from '../entity/IssueComment';
import Media from '../entity/Media';
import { IssueResultsResponse } from '../interfaces/api/issueInterfaces';
import { Permission } from '../lib/permissions';
import logger from '../logger';
import { isAuthenticated } from '../middleware/auth';
const issueRoutes = Router();
@@ -365,7 +365,7 @@ issueRoutes.delete(
try {
const issue = await issueRepository.findOneOrFail({
where: { id: Number(req.params.issueId) },
relations: ['createdBy'],
relations: { createdBy: true },
});
if (

View File

@@ -1,9 +1,9 @@
import { getRepository } from '@server/datasource';
import IssueComment from '@server/entity/IssueComment';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
import { getRepository } from 'typeorm';
import IssueComment from '../entity/IssueComment';
import { Permission } from '../lib/permissions';
import logger from '../logger';
import { isAuthenticated } from '../middleware/auth';
const issueCommentRoutes = Router();

View File

@@ -1,20 +1,22 @@
import { Router } from 'express';
import { FindOneOptions, FindOperator, getRepository, In } from 'typeorm';
import RadarrAPI from '../api/servarr/radarr';
import SonarrAPI from '../api/servarr/sonarr';
import TautulliAPI from '../api/tautulli';
import TheMovieDb from '../api/themoviedb';
import { MediaStatus, MediaType } from '../constants/media';
import Media from '../entity/Media';
import { User } from '../entity/User';
import {
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import TautulliAPI from '@server/api/tautulli';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import type {
MediaResultsResponse,
MediaWatchDataResponse,
} from '../interfaces/api/mediaInterfaces';
import { Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import { isAuthenticated } from '../middleware/auth';
} from '@server/interfaces/api/mediaInterfaces';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
const mediaRoutes = Router();
@@ -24,8 +26,7 @@ mediaRoutes.get('/', async (req, res, next) => {
const pageSize = req.query.take ? Number(req.query.take) : 20;
const skip = req.query.skip ? Number(req.query.skip) : 0;
let statusFilter: MediaStatus | FindOperator<MediaStatus> | undefined =
undefined;
let statusFilter = undefined;
switch (req.query.filter) {
case 'available':
@@ -69,7 +70,7 @@ mediaRoutes.get('/', async (req, res, next) => {
try {
const [media, mediaCount] = await mediaRepository.findAndCount({
order: sortFilter,
where: {
where: statusFilter && {
status: statusFilter,
},
take: pageSize,
@@ -154,7 +155,7 @@ mediaRoutes.delete(
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOneOrFail({
where: { id: req.params.id },
where: { id: Number(req.params.id) },
});
await mediaRepository.remove(media);

View File

@@ -1,11 +1,11 @@
import RottenTomatoes from '@server/api/rottentomatoes';
import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media';
import Media from '@server/entity/Media';
import logger from '@server/logger';
import { mapMovieDetails } from '@server/models/Movie';
import { mapMovieResult } from '@server/models/Search';
import { Router } from 'express';
import RottenTomatoes from '../api/rottentomatoes';
import TheMovieDb from '../api/themoviedb';
import { MediaType } from '../constants/media';
import Media from '../entity/Media';
import logger from '../logger';
import { mapMovieDetails } from '../models/Movie';
import { mapMovieResult } from '../models/Search';
const movieRoutes = Router();

View File

@@ -1,12 +1,12 @@
import { Router } from 'express';
import TheMovieDb from '../api/themoviedb';
import Media from '../entity/Media';
import logger from '../logger';
import TheMovieDb from '@server/api/themoviedb';
import Media from '@server/entity/Media';
import logger from '@server/logger';
import {
mapCastCredits,
mapCrewCredits,
mapPersonDetails,
} from '../models/Person';
} from '@server/models/Person';
import { Router } from 'express';
const personRoutes = Router();

View File

@@ -1,15 +1,27 @@
import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import {
DuplicateMediaRequestError,
MediaRequest,
NoSeasonsAvailableError,
QuotaRestrictedError,
RequestPermissionError,
} from '@server/entity/MediaRequest';
import SeasonRequest from '@server/entity/SeasonRequest';
import { User } from '@server/entity/User';
import type {
MediaRequestBody,
RequestResultsResponse,
} from '@server/interfaces/api/requestInterfaces';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
import { getRepository } from 'typeorm';
import TheMovieDb from '../api/themoviedb';
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
import Media from '../entity/Media';
import { MediaRequest } from '../entity/MediaRequest';
import SeasonRequest from '../entity/SeasonRequest';
import { User } from '../entity/User';
import { RequestResultsResponse } from '../interfaces/api/requestInterfaces';
import { Permission } from '../lib/permissions';
import logger from '../logger';
import { isAuthenticated } from '../middleware/auth';
const requestRoutes = Router();
@@ -40,11 +52,15 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
MediaRequestStatus.APPROVED,
];
break;
case 'failed':
statusFilter = [MediaRequestStatus.FAILED];
break;
default:
statusFilter = [
MediaRequestStatus.PENDING,
MediaRequestStatus.APPROVED,
MediaRequestStatus.DECLINED,
MediaRequestStatus.FAILED,
];
}
@@ -142,302 +158,38 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
}
);
requestRoutes.post('/', async (req, res, next) => {
const tmdb = new TheMovieDb();
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User);
try {
let requestUser = req.user;
if (
req.body.userId &&
!req.user?.hasPermission([
Permission.MANAGE_USERS,
Permission.MANAGE_REQUESTS,
])
) {
return next({
status: 403,
message: 'You do not have permission to modify the request user.',
});
} else if (req.body.userId) {
requestUser = await userRepository.findOneOrFail({
where: { id: req.body.userId },
});
}
if (!requestUser) {
return next({
status: 500,
message: 'User missing from request context.',
});
}
if (
req.body.mediaType === MediaType.MOVIE &&
!req.user?.hasPermission(
req.body.is4k
? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE]
: [Permission.REQUEST, Permission.REQUEST_MOVIE],
{
type: 'or',
}
)
) {
return next({
status: 403,
message: `You do not have permission to make ${
req.body.is4k ? '4K ' : ''
}movie requests.`,
});
} else if (
req.body.mediaType === MediaType.TV &&
!req.user?.hasPermission(
req.body.is4k
? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV]
: [Permission.REQUEST, Permission.REQUEST_TV],
{
type: 'or',
}
)
) {
return next({
status: 403,
message: `You do not have permission to make ${
req.body.is4k ? '4K ' : ''
}series requests.`,
});
}
const quotas = await requestUser.getQuota();
if (req.body.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
return next({
status: 403,
message: 'Movie Quota Exceeded',
});
} else if (req.body.mediaType === MediaType.TV && quotas.tv.restricted) {
return next({
status: 403,
message: 'Series Quota Exceeded',
});
}
const tmdbMedia =
req.body.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: req.body.mediaId })
: await tmdb.getTvShow({ tvId: req.body.mediaId });
let media = await mediaRepository.findOne({
where: { tmdbId: req.body.mediaId, mediaType: req.body.mediaType },
relations: ['requests'],
});
if (!media) {
media = new Media({
tmdbId: tmdbMedia.id,
tvdbId: req.body.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
mediaType: req.body.mediaType,
});
} else {
if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) {
media.status = MediaStatus.PENDING;
}
if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) {
media.status4k = MediaStatus.PENDING;
}
}
if (req.body.mediaType === MediaType.MOVIE) {
const existing = await requestRepository
.createQueryBuilder('request')
.leftJoin('request.media', 'media')
.where('request.is4k = :is4k', { is4k: req.body.is4k })
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
.andWhere('media.mediaType = :mediaType', {
mediaType: MediaType.MOVIE,
})
.andWhere('request.status != :requestStatus', {
requestStatus: MediaRequestStatus.DECLINED,
})
.getOne();
if (existing) {
logger.warn('Duplicate request for media blocked', {
tmdbId: tmdbMedia.id,
mediaType: req.body.mediaType,
is4k: req.body.is4k,
label: 'Media Request',
});
requestRoutes.post<never, MediaRequest, MediaRequestBody>(
'/',
async (req, res, next) => {
try {
if (!req.user) {
return next({
status: 409,
message: 'Request for this media already exists.',
status: 401,
message: 'You must be logged in to request media.',
});
}
const request = await MediaRequest.request(req.body, req.user);
await mediaRepository.save(media);
const request = new MediaRequest({
type: MediaType.MOVIE,
media,
requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_MOVIE
: Permission.AUTO_APPROVE_MOVIE,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_MOVIE
: Permission.AUTO_APPROVE_MOVIE,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? req.user
: undefined,
is4k: req.body.is4k,
serverId: req.body.serverId,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
tags: req.body.tags,
});
await requestRepository.save(request);
return res.status(201).json(request);
} else if (req.body.mediaType === MediaType.TV) {
const requestedSeasons = req.body.seasons as number[];
let existingSeasons: number[] = [];
// We need to check existing requests on this title to make sure we don't double up on seasons that were
// already requested. In the case they were, we just throw out any duplicates but still approve the request.
// (Unless there are no seasons, in which case we abort)
if (media.requests) {
existingSeasons = media.requests
.filter(
(request) =>
request.is4k === req.body.is4k &&
request.status !== MediaRequestStatus.DECLINED
)
.reduce((seasons, request) => {
const combinedSeasons = request.seasons.map(
(season) => season.seasonNumber
);
return [...seasons, ...combinedSeasons];
}, [] as number[]);
} catch (error) {
if (!(error instanceof Error)) {
return;
}
const finalSeasons = requestedSeasons.filter(
(rs) => !existingSeasons.includes(rs)
);
if (finalSeasons.length === 0) {
return next({
status: 202,
message: 'No seasons available to request',
});
} else if (
quotas.tv.limit &&
finalSeasons.length > (quotas.tv.remaining ?? 0)
) {
return next({
status: 403,
message: 'Series Quota Exceeded',
});
switch (error.constructor) {
case RequestPermissionError:
case QuotaRestrictedError:
return next({ status: 403, message: error.message });
case DuplicateMediaRequestError:
return next({ status: 409, message: error.message });
case NoSeasonsAvailableError:
return next({ status: 202, message: error.message });
default:
return next({ status: 500, message: error.message });
}
await mediaRepository.save(media);
const request = new MediaRequest({
type: MediaType.TV,
media,
requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? req.user
: undefined,
is4k: req.body.is4k,
serverId: req.body.serverId,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
languageProfileId: req.body.languageProfileId,
tags: req.body.tags,
seasons: finalSeasons.map(
(sn) =>
new SeasonRequest({
seasonNumber: sn,
status: req.user?.hasPermission(
[
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE,
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
})
),
});
await requestRepository.save(request);
return res.status(201).json(request);
}
next({ status: 500, message: 'Invalid media type' });
} catch (e) {
next({ status: 500, message: e.message });
}
});
);
requestRoutes.get('/count', async (_req, res, next) => {
const requestRepository = getRepository(MediaRequest);
@@ -528,7 +280,7 @@ requestRoutes.get('/:requestId', async (req, res, next) => {
try {
const request = await requestRepository.findOneOrFail({
where: { id: Number(req.params.requestId) },
relations: ['requestedBy', 'modifiedBy'],
relations: { requestedBy: true, modifiedBy: true },
});
if (
@@ -560,9 +312,11 @@ requestRoutes.put<{ requestId: string }>(
const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User);
try {
const request = await requestRepository.findOne(
Number(req.params.requestId)
);
const request = await requestRepository.findOne({
where: {
id: Number(req.params.requestId),
},
});
if (!request) {
return next({ status: 404, message: 'Request not found.' });
@@ -628,7 +382,7 @@ requestRoutes.put<{ requestId: string }>(
// Get existing media so we can work with all the requests
const media = await mediaRepository.findOneOrFail({
where: { tmdbId: request.media.tmdbId, mediaType: MediaType.TV },
relations: ['requests'],
relations: { requests: true },
});
// Get all requested seasons that are not part of this request we are editing
@@ -698,7 +452,7 @@ requestRoutes.delete('/:requestId', async (req, res, next) => {
try {
const request = await requestRepository.findOneOrFail({
where: { id: Number(req.params.requestId) },
relations: ['requestedBy', 'modifiedBy'],
relations: { requestedBy: true, modifiedBy: true },
});
if (
@@ -735,7 +489,7 @@ requestRoutes.post<{
try {
const request = await requestRepository.findOneOrFail({
where: { id: Number(req.params.requestId) },
relations: ['requestedBy', 'modifiedBy'],
relations: { requestedBy: true, modifiedBy: true },
});
await request.updateParentStatus();
@@ -763,7 +517,7 @@ requestRoutes.post<{
try {
const request = await requestRepository.findOneOrFail({
where: { id: Number(req.params.requestId) },
relations: ['requestedBy', 'modifiedBy'],
relations: { requestedBy: true, modifiedBy: true },
});
let newStatus: MediaRequestStatus;

View File

@@ -1,10 +1,10 @@
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces';
import Media from '@server/entity/Media';
import { findSearchProvider } from '@server/lib/search';
import logger from '@server/logger';
import { mapSearchResults } from '@server/models/Search';
import { Router } from 'express';
import TheMovieDb from '../api/themoviedb';
import { TmdbSearchMultiResponse } from '../api/themoviedb/interfaces';
import Media from '../entity/Media';
import { findSearchProvider } from '../lib/search';
import logger from '../logger';
import { mapSearchResults } from '../models/Search';
const searchRoutes = Router();

View File

@@ -1,13 +1,13 @@
import { Router } from 'express';
import RadarrAPI from '../api/servarr/radarr';
import SonarrAPI from '../api/servarr/sonarr';
import TheMovieDb from '../api/themoviedb';
import {
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import type {
ServiceCommonServer,
ServiceCommonServerWithDetails,
} from '../interfaces/api/serviceInterfaces';
import { getSettings } from '../lib/settings';
import logger from '../logger';
} from '@server/interfaces/api/serviceInterfaces';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { Router } from 'express';
const serviceRoutes = Router();
@@ -191,7 +191,7 @@ serviceRoutes.get<{ tmdbId: string }>(
try {
const tv = await tmdb.getTvShow({
tvId: Number(req.params.tmdbId),
language: req.locale ?? (req.query.language as string),
language: 'en',
});
const response = await sonarr.getSeriesByTitle(tv.name);

View File

@@ -1,35 +1,37 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import fs from 'fs';
import { merge, omit, set, sortBy } from 'lodash';
import { rescheduleJob } from 'node-schedule';
import path from 'path';
import semver from 'semver';
import { getRepository } from 'typeorm';
import { URL } from 'url';
import JellyfinAPI from '../../api/jellyfin';
import PlexAPI from '../../api/plexapi';
import PlexTvAPI from '../../api/plextv';
import TautulliAPI from '../../api/tautulli';
import Media from '../../entity/Media';
import { MediaRequest } from '../../entity/MediaRequest';
import { User } from '../../entity/User';
import { PlexConnection } from '../../interfaces/api/plexInterfaces';
import {
import JellyfinAPI from '@server/api/jellyfin';
import PlexAPI from '@server/api/plexapi';
import PlexTvAPI from '@server/api/plextv';
import TautulliAPI from '@server/api/tautulli';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
import { User } from '@server/entity/User';
import type { PlexConnection } from '@server/interfaces/api/plexInterfaces';
import type {
LogMessage,
LogsResultsResponse,
SettingsAboutResponse,
} from '../../interfaces/api/settingsInterfaces';
import { jobJellyfinFullSync } from '../../job/jellyfinsync';
import { scheduledJobs } from '../../job/schedule';
import cacheManager, { AvailableCacheIds } from '../../lib/cache';
import { Permission } from '../../lib/permissions';
import { plexFullScanner } from '../../lib/scanners/plex';
import { getSettings, Library, MainSettings } from '../../lib/settings';
import logger from '../../logger';
import { isAuthenticated } from '../../middleware/auth';
import { appDataPath } from '../../utils/appDataVolume';
import { getAppVersion } from '../../utils/appVersion';
} from '@server/interfaces/api/settingsInterfaces';
import { jobJellyfinFullSync } from '@server/job/jellyfinsync';
import { scheduledJobs } from '@server/job/schedule';
import type { AvailableCacheIds } from '@server/lib/cache';
import cacheManager from '@server/lib/cache';
import { Permission } from '@server/lib/permissions';
import { plexFullScanner } from '@server/lib/scanners/plex';
import type { Library, MainSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { appDataPath } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion';
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import fs from 'fs';
import { escapeRegExp, merge, omit, set, sortBy } from 'lodash';
import { rescheduleJob } from 'node-schedule';
import path from 'path';
import semver from 'semver';
import { URL } from 'url';
import notificationRoutes from './notifications';
import radarrRoutes from './radarr';
import sonarrRoutes from './sonarr';
@@ -93,8 +95,8 @@ settingsRoutes.post('/plex', async (req, res, next) => {
const settings = getSettings();
try {
const admin = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
select: { id: true, plexToken: true },
where: { id: 1 },
});
Object.assign(settings.plex, req.body);
@@ -129,8 +131,8 @@ settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
const userRepository = getRepository(User);
try {
const admin = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
select: { id: true, plexToken: true },
where: { id: 1 },
});
const plexTvClient = admin.plexToken
? new PlexTvAPI(admin.plexToken)
@@ -208,8 +210,8 @@ settingsRoutes.get('/plex/library', async (req, res) => {
if (req.query.sync) {
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
select: { id: true, plexToken: true },
where: { id: 1 },
});
const plexapi = new PlexAPI({ plexToken: admin.plexToken });
@@ -262,6 +264,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => {
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
where: { id: 1 },
order: { id: 'ASC' },
});
const jellyfinClient = new JellyfinAPI(
@@ -312,6 +315,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
where: { id: 1 },
order: { id: 'ASC' },
});
const jellyfinClient = new JellyfinAPI(
@@ -390,8 +394,8 @@ settingsRoutes.get(
try {
const admin = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
select: { id: true, plexToken: true },
where: { id: 1 },
});
const plexApi = new PlexTvAPI(admin.plexToken ?? '');
const plexUsers = (await plexApi.getUsers()).MediaContainer.User.map(
@@ -450,6 +454,8 @@ settingsRoutes.get(
(req, res, next) => {
const pageSize = req.query.take ? Number(req.query.take) : 25;
const skip = req.query.skip ? Number(req.query.skip) : 0;
const search = (req.query.search as string) ?? '';
const searchRegexp = new RegExp(escapeRegExp(search), 'i');
let filter: string[] = [];
switch (req.query.filter) {
@@ -481,6 +487,22 @@ settingsRoutes.get(
'data',
];
const deepValueStrings = (obj: Record<string, unknown>): string[] => {
const values = [];
for (const val of Object.values(obj)) {
if (typeof val === 'string') {
values.push(val);
} else if (typeof val === 'number') {
values.push(val.toString());
} else if (val !== null && typeof val === 'object') {
values.push(...deepValueStrings(val as Record<string, unknown>));
}
}
return values;
};
try {
fs.readFileSync(logFile, 'utf-8')
.split('\n')
@@ -505,6 +527,19 @@ settingsRoutes.get(
});
}
if (req.query.search) {
if (
// label and data are sometimes undefined
!searchRegexp.test(logMessage.label ?? '') &&
!searchRegexp.test(logMessage.message) &&
!deepValueStrings(logMessage.data ?? {}).some((val) =>
searchRegexp.test(val)
)
) {
return;
}
}
logs.push(logMessage);
});
@@ -539,6 +574,7 @@ settingsRoutes.get('/jobs', (_req, res) => {
name: job.name,
type: job.type,
interval: job.interval,
cronSchedule: job.cronSchedule,
nextExecutionTime: job.job.nextInvocation(),
running: job.running ? job.running() : false,
}))
@@ -559,6 +595,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
name: scheduledJob.name,
type: scheduledJob.type,
interval: scheduledJob.interval,
cronSchedule: scheduledJob.cronSchedule,
nextExecutionTime: scheduledJob.job.nextInvocation(),
running: scheduledJob.running ? scheduledJob.running() : false,
});
@@ -584,6 +621,7 @@ settingsRoutes.post<{ jobId: string }>(
name: scheduledJob.name,
type: scheduledJob.type,
interval: scheduledJob.interval,
cronSchedule: scheduledJob.cronSchedule,
nextExecutionTime: scheduledJob.job.nextInvocation(),
running: scheduledJob.running ? scheduledJob.running() : false,
});
@@ -608,11 +646,14 @@ settingsRoutes.post<{ jobId: string }>(
settings.jobs[scheduledJob.id].schedule = req.body.schedule;
settings.save();
scheduledJob.cronSchedule = req.body.schedule;
return res.status(200).json({
id: scheduledJob.id,
name: scheduledJob.name,
type: scheduledJob.type,
interval: scheduledJob.interval,
cronSchedule: scheduledJob.cronSchedule,
nextExecutionTime: scheduledJob.job.nextInvocation(),
running: scheduledJob.running ? scheduledJob.running() : false,
});

View File

@@ -1,23 +1,24 @@
import type { User } from '@server/entity/User';
import { Notification } from '@server/lib/notifications';
import type { NotificationAgent } from '@server/lib/notifications/agents/agent';
import DiscordAgent from '@server/lib/notifications/agents/discord';
import EmailAgent from '@server/lib/notifications/agents/email';
import GotifyAgent from '@server/lib/notifications/agents/gotify';
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
import PushoverAgent from '@server/lib/notifications/agents/pushover';
import SlackAgent from '@server/lib/notifications/agents/slack';
import TelegramAgent from '@server/lib/notifications/agents/telegram';
import WebhookAgent from '@server/lib/notifications/agents/webhook';
import WebPushAgent from '@server/lib/notifications/agents/webpush';
import { getSettings } from '@server/lib/settings';
import { Router } from 'express';
import { User } from '../../entity/User';
import { Notification } from '../../lib/notifications';
import { NotificationAgent } from '../../lib/notifications/agents/agent';
import DiscordAgent from '../../lib/notifications/agents/discord';
import EmailAgent from '../../lib/notifications/agents/email';
import GotifyAgent from '../../lib/notifications/agents/gotify';
import LunaSeaAgent from '../../lib/notifications/agents/lunasea';
import PushbulletAgent from '../../lib/notifications/agents/pushbullet';
import PushoverAgent from '../../lib/notifications/agents/pushover';
import SlackAgent from '../../lib/notifications/agents/slack';
import TelegramAgent from '../../lib/notifications/agents/telegram';
import WebhookAgent from '../../lib/notifications/agents/webhook';
import WebPushAgent from '../../lib/notifications/agents/webpush';
import { getSettings } from '../../lib/settings';
const notificationRoutes = Router();
const sendTestNotification = async (agent: NotificationAgent, user: User) =>
await agent.send(Notification.TEST_NOTIFICATION, {
notifySystem: true,
notifyAdmin: false,
notifyUser: user,
subject: 'Test Notification',
@@ -247,7 +248,7 @@ notificationRoutes.post('/webpush/test', async (req, res, next) => {
if (!req.user) {
return next({
status: 500,
message: 'User information missing from request',
message: 'User information is missing from the request.',
});
}
@@ -363,7 +364,7 @@ notificationRoutes.post('/lunasea/test', async (req, res, next) => {
if (!req.user) {
return next({
status: 500,
message: 'User information missing from request',
message: 'User information is missing from the request.',
});
}
@@ -384,34 +385,26 @@ notificationRoutes.get('/gotify', (_req, res) => {
res.status(200).json(settings.notifications.agents.gotify);
});
notificationRoutes.post('/gotify', (req, rest) => {
notificationRoutes.post('/gotify', (req, res) => {
const settings = getSettings();
settings.notifications.agents.gotify = req.body;
settings.save();
rest.status(200).json(settings.notifications.agents.gotify);
res.status(200).json(settings.notifications.agents.gotify);
});
notificationRoutes.post('/gotify/test', async (req, rest, next) => {
notificationRoutes.post('/gotify/test', async (req, res, next) => {
if (!req.user) {
return next({
status: 500,
message: 'User information is missing from request',
message: 'User information is missing from the request.',
});
}
const gotifyAgent = new GotifyAgent(req.body);
if (
await gotifyAgent.send(Notification.TEST_NOTIFICATION, {
notifyAdmin: false,
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
return rest.status(204).send();
if (await sendTestNotification(gotifyAgent, req.user)) {
return res.status(204).send();
} else {
return next({
status: 500,

View File

@@ -1,7 +1,8 @@
import RadarrAPI from '@server/api/servarr/radarr';
import type { RadarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { Router } from 'express';
import RadarrAPI from '../../api/servarr/radarr';
import { getSettings, RadarrSettings } from '../../lib/settings';
import logger from '../../logger';
const radarrRoutes = Router();

View File

@@ -1,7 +1,8 @@
import SonarrAPI from '@server/api/servarr/sonarr';
import type { SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { Router } from 'express';
import SonarrAPI from '../../api/servarr/sonarr';
import { getSettings, SonarrSettings } from '../../lib/settings';
import logger from '../../logger';
const sonarrRoutes = Router();

View File

@@ -1,11 +1,11 @@
import RottenTomatoes from '@server/api/rottentomatoes';
import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media';
import Media from '@server/entity/Media';
import logger from '@server/logger';
import { mapTvResult } from '@server/models/Search';
import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv';
import { Router } from 'express';
import RottenTomatoes from '../api/rottentomatoes';
import TheMovieDb from '../api/themoviedb';
import { MediaType } from '../constants/media';
import Media from '../entity/Media';
import logger from '../logger';
import { mapTvResult } from '../models/Search';
import { mapSeasonWithEpisodes, mapTvDetails } from '../models/Tv';
const tvRoutes = Router();

View File

@@ -1,26 +1,28 @@
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import { findIndex, sortBy } from 'lodash';
import { getRepository, In, Not } from 'typeorm';
import JellyfinAPI from '../../api/jellyfin';
import PlexTvAPI from '../../api/plextv';
import TautulliAPI from '../../api/tautulli';
import { MediaType } from '../../constants/media';
import { UserType } from '../../constants/user';
import Media from '../../entity/Media';
import { MediaRequest } from '../../entity/MediaRequest';
import { User } from '../../entity/User';
import { UserPushSubscription } from '../../entity/UserPushSubscription';
import {
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 type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces';
import type {
QuotaResponse,
UserRequestsResponse,
UserResultsResponse,
UserWatchDataResponse,
} from '../../interfaces/api/userInterfaces';
import { hasPermission, Permission } from '../../lib/permissions';
import { getSettings } from '../../lib/settings';
import logger from '../../logger';
import { isAuthenticated } from '../../middleware/auth';
} 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 { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import { findIndex, sortBy } from 'lodash';
import { In } from 'typeorm';
import userSettingsRoutes from './usersettings';
const router = Router();
@@ -259,12 +261,7 @@ export const canMakePermissionsChange = (
user?: User
): boolean =>
// Only let the owner grant admin privileges
!(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1) ||
// Only let users with the manage settings permission, grant the same permission
!(
hasPermission(Permission.MANAGE_SETTINGS, permissions) &&
!hasPermission(Permission.MANAGE_SETTINGS, user?.permissions ?? 0)
);
!(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1);
router.put<
Record<string, never>,
@@ -283,8 +280,12 @@ router.put<
const userRepository = getRepository(User);
const users = await userRepository.findByIds(req.body.ids, {
...(!isOwner ? { id: Not(1) } : {}),
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(
@@ -351,7 +352,7 @@ router.delete<{ id: string }>(
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
relations: ['requests'],
relations: { requests: true },
});
if (!user) {
@@ -410,8 +411,8 @@ router.post(
// taken from auth.ts
const mainUser = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
select: { id: true, plexToken: true },
where: { id: 1 },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
@@ -477,6 +478,7 @@ router.post(
// taken from auth.ts
const admin = await userRepository.findOneOrFail({
where: { id: 1 },
select: [
'id',
'jellyfinAuthToken',
@@ -598,7 +600,7 @@ router.get<{ id: string }, UserWatchDataResponse>(
try {
const user = await getRepository(User).findOneOrFail({
where: { id: Number(req.params.id) },
select: ['id', 'plexId'],
select: { id: true, plexId: true },
});
const tautulli = new TautulliAPI(settings);
@@ -680,4 +682,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;

View File

@@ -1,16 +1,16 @@
import { Router } from 'express';
import { getRepository } from 'typeorm';
import { canMakePermissionsChange } from '.';
import { User } from '../../entity/User';
import { UserSettings } from '../../entity/UserSettings';
import {
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { UserSettings } from '@server/entity/UserSettings';
import type {
UserSettingsGeneralResponse,
UserSettingsNotificationsResponse,
} from '../../interfaces/api/userSettingsInterfaces';
import { Permission } from '../../lib/permissions';
import { getSettings } from '../../lib/settings';
import logger from '../../logger';
import { isAuthenticated } from '../../middleware/auth';
} from '@server/interfaces/api/userSettingsInterfaces';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
import { canMakePermissionsChange } from '.';
const isOwnProfileOrAdmin = (): Middleware => {
const authMiddleware: Middleware = (req, res, next) => {
@@ -64,6 +64,8 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
globalMovieQuotaLimit: defaultQuotas.movie.quotaLimit,
globalTvQuotaDays: defaultQuotas.tv.quotaDays,
globalTvQuotaLimit: defaultQuotas.tv.quotaLimit,
watchlistSyncMovies: user.settings?.watchlistSyncMovies,
watchlistSyncTv: user.settings?.watchlistSyncTv,
});
} catch (e) {
next({ status: 500, message: e.message });
@@ -115,12 +117,16 @@ userSettingsRoutes.post<
locale: req.body.locale,
region: req.body.region,
originalLanguage: req.body.originalLanguage,
watchlistSyncMovies: req.body.watchlistSyncMovies,
watchlistSyncTv: req.body.watchlistSyncTv,
});
} else {
user.settings.discordId = req.body.discordId;
user.settings.locale = req.body.locale;
user.settings.region = req.body.region;
user.settings.originalLanguage = req.body.originalLanguage;
user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies;
user.settings.watchlistSyncTv = req.body.watchlistSyncTv;
user.email = req.body.email ?? user.email;
}
@@ -132,6 +138,8 @@ userSettingsRoutes.post<
locale: user.settings.locale,
region: user.settings.region,
originalLanguage: user.settings.originalLanguage,
watchlistSyncMovies: user.settings.watchlistSyncMovies,
watchlistSyncTv: user.settings.watchlistSyncTv,
email: user.email,
});
} catch (e) {