Merge branch 'develop' of https://github.com/sct/overseerr into jellyfin-support

This commit is contained in:
Juan D. Jara
2021-09-27 02:24:30 +02:00
411 changed files with 35232 additions and 20531 deletions

View File

@@ -1,14 +1,14 @@
import { Router } from 'express';
import { getRepository } from 'typeorm';
import { User } from '../entity/User';
import PlexTvAPI from '../api/plextv';
import JellyfinAPI from '../api/jellyfin';
import { isAuthenticated } from '../middleware/auth';
import { Permission } from '../lib/permissions';
import logger from '../logger';
import { getSettings } from '../lib/settings';
import { UserType } from '../constants/user';
import PlexTvAPI from '../api/plextv';
import { MediaServerType } from '../constants/server';
import { UserType } from '../constants/user';
import { User } from '../entity/User';
import { Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import { isAuthenticated } from '../middleware/auth';
const authRoutes = Router();
@@ -51,9 +51,13 @@ authRoutes.post('/plex', async (req, res, next) => {
const account = await plextv.getUser();
// Next let's see if the user already exists
let user = await userRepository.findOne({
where: { plexId: account.id },
});
let user = await userRepository
.createQueryBuilder('user')
.where('user.plexId = :id', { id: account.id })
.orWhere('user.email = :email', {
email: account.email.toLowerCase(),
})
.getOne();
if (user) {
// Let's check if their Plex token is up-to-date
@@ -66,9 +70,12 @@ authRoutes.post('/plex', async (req, res, next) => {
user.email = account.email;
user.plexUsername = account.username;
if (user.username === account.username) {
user.username = '';
// In case the user was previously a local account
if (user.userType === UserType.LOCAL) {
user.userType = UserType.PLEX;
user.plexId = account.id;
}
await userRepository.save(user);
} else {
// Here we check if it's the first user. If it is, we create the user with no check
@@ -93,6 +100,24 @@ authRoutes.post('/plex', async (req, res, next) => {
// Double check that we didn't create the first admin user before running this
if (!user) {
if (!settings.main.newPlexLogin) {
logger.info(
'Failed sign-in attempt from user who has not been imported to Overseerr.',
{
label: 'Auth',
account: {
...account,
authentication_token: '__REDACTED__',
authToken: '__REDACTED__',
},
}
);
return next({
status: 403,
message: 'Access denied.',
});
}
// If we get to this point, the user does not already exist so we need to create the
// user _assuming_ they have access to the Plex server
const mainUser = await userRepository.findOneOrFail({
@@ -126,7 +151,7 @@ authRoutes.post('/plex', async (req, res, next) => {
);
return next({
status: 403,
message: 'You do not have access to this Plex server.',
message: 'Access denied.',
});
}
}
@@ -142,7 +167,7 @@ authRoutes.post('/plex', async (req, res, next) => {
logger.error(e.message, { label: 'Auth' });
return next({
status: 500,
message: 'Something went wrong. Is your auth token valid?',
message: 'Something went wrong.',
});
}
});
@@ -322,10 +347,11 @@ authRoutes.post('/local', async (req, res, next) => {
});
}
try {
const user = await userRepository.findOne({
select: ['id', 'password'],
where: { email: body.email },
});
const user = await userRepository
.createQueryBuilder('user')
.select(['user.id', 'user.password'])
.where('user.email = :email', { email: body.email.toLowerCase() })
.getOne();
const isCorrectCredentials = await user?.passwordMatch(body.password);
@@ -389,9 +415,10 @@ authRoutes.post('/reset-password', async (req, res) => {
.json({ error: 'You must provide an email address.' });
}
const user = await userRepository.findOne({
where: { email: body.email },
});
const user = await userRepository
.createQueryBuilder('user')
.where('user.email = :email', { email: body.email.toLowerCase() })
.getOne();
if (user) {
await user.resetPassword();

View File

@@ -11,7 +11,7 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
try {
const collection = await tmdb.getCollection({
collectionId: Number(req.params.id),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(

View File

@@ -1,11 +1,16 @@
import { Router } from 'express';
import { sortBy } from 'lodash';
import TheMovieDb from '../api/themoviedb';
import { mapMovieResult, mapTvResult, mapPersonResult } from '../models/Search';
import Media from '../entity/Media';
import { isMovie, isPerson } from '../utils/typeHelpers';
import { MediaType } from '../constants/media';
import { getSettings } from '../lib/settings';
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';
const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => {
const settings = getSettings();
@@ -37,7 +42,9 @@ discoverRoutes.get('/movies', async (req, res) => {
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
genre: req.query.genre ? Number(req.query.genre) : undefined,
studio: req.query.studio ? Number(req.query.studio) : undefined,
});
const media = await Media.getRelatedMedia(
@@ -59,6 +66,133 @@ discoverRoutes.get('/movies', async (req, res) => {
});
});
discoverRoutes.get<{ language: string }>(
'/movies/language/:language',
async (req, res, next) => {
const tmdb = createTmdbWithRegionLanaguage(req.user);
const languages = await tmdb.getLanguages();
const language = languages.find(
(lang) => lang.iso_639_1 === req.params.language
);
if (!language) {
return next({ status: 404, message: 'Unable to retrieve language' });
}
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
originalLanguage: req.params.language,
});
const media = await Media.getRelatedMedia(
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
language,
results: data.results.map((result) =>
mapMovieResult(
result,
media.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
)
)
),
});
}
);
discoverRoutes.get<{ genreId: string }>(
'/movies/genre/:genreId',
async (req, res, next) => {
const tmdb = createTmdbWithRegionLanaguage(req.user);
const genres = await tmdb.getMovieGenres({
language: req.locale ?? (req.query.language as string),
});
const genre = genres.find(
(genre) => genre.id === Number(req.params.genreId)
);
if (!genre) {
return next({ status: 404, message: 'Unable to retrieve genre' });
}
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
genre: Number(req.params.genreId),
});
const media = await Media.getRelatedMedia(
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
genre,
results: data.results.map((result) =>
mapMovieResult(
result,
media.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
)
)
),
});
}
);
discoverRoutes.get<{ studioId: string }>(
'/movies/studio/:studioId',
async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const studio = await tmdb.getStudio(Number(req.params.studioId));
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
studio: Number(req.params.studioId),
});
const media = await Media.getRelatedMedia(
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
studio: mapProductionCompany(studio),
results: data.results.map((result) =>
mapMovieResult(
result,
media.find(
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
)
)
),
});
} catch (e) {
return next({ status: 404, message: 'Unable to retrieve studio' });
}
}
);
discoverRoutes.get('/movies/upcoming', async (req, res) => {
const tmdb = createTmdbWithRegionLanaguage(req.user);
@@ -70,7 +204,7 @@ discoverRoutes.get('/movies/upcoming', async (req, res) => {
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
primaryReleaseDateGte: date,
});
@@ -98,7 +232,9 @@ discoverRoutes.get('/tv', async (req, res) => {
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
genre: req.query.genre ? Number(req.query.genre) : undefined,
network: req.query.network ? Number(req.query.network) : undefined,
});
const media = await Media.getRelatedMedia(
@@ -120,6 +256,131 @@ discoverRoutes.get('/tv', async (req, res) => {
});
});
discoverRoutes.get<{ language: string }>(
'/tv/language/:language',
async (req, res, next) => {
const tmdb = createTmdbWithRegionLanaguage(req.user);
const languages = await tmdb.getLanguages();
const language = languages.find(
(lang) => lang.iso_639_1 === req.params.language
);
if (!language) {
return next({ status: 404, message: 'Unable to retrieve language' });
}
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
originalLanguage: req.params.language,
});
const media = await Media.getRelatedMedia(
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
language,
results: data.results.map((result) =>
mapTvResult(
result,
media.find(
(med) => med.tmdbId === result.id && med.mediaType === MediaType.TV
)
)
),
});
}
);
discoverRoutes.get<{ genreId: string }>(
'/tv/genre/:genreId',
async (req, res, next) => {
const tmdb = createTmdbWithRegionLanaguage(req.user);
const genres = await tmdb.getTvGenres({
language: req.locale ?? (req.query.language as string),
});
const genre = genres.find(
(genre) => genre.id === Number(req.params.genreId)
);
if (!genre) {
return next({ status: 404, message: 'Unable to retrieve genre' });
}
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
genre: Number(req.params.genreId),
});
const media = await Media.getRelatedMedia(
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
genre,
results: data.results.map((result) =>
mapTvResult(
result,
media.find(
(med) => med.tmdbId === result.id && med.mediaType === MediaType.TV
)
)
),
});
}
);
discoverRoutes.get<{ networkId: string }>(
'/tv/network/:networkId',
async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const network = await tmdb.getNetwork(Number(req.params.networkId));
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
network: Number(req.params.networkId),
});
const media = await Media.getRelatedMedia(
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
network: mapNetwork(network),
results: data.results.map((result) =>
mapTvResult(
result,
media.find(
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.TV
)
)
),
});
} catch (e) {
return next({ status: 404, message: 'Unable to retrieve network' });
}
}
);
discoverRoutes.get('/tv/upcoming', async (req, res) => {
const tmdb = createTmdbWithRegionLanaguage(req.user);
@@ -131,7 +392,7 @@ discoverRoutes.get('/tv/upcoming', async (req, res) => {
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
firstAirDateGte: date,
});
@@ -159,7 +420,7 @@ discoverRoutes.get('/trending', async (req, res) => {
const data = await tmdb.getAllTrending({
page: Number(req.query.page),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(
@@ -175,15 +436,18 @@ discoverRoutes.get('/trending', async (req, res) => {
? mapMovieResult(
result,
media.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
)
)
: isPerson(result)
? mapPersonResult(result)
: mapTvResult(
result,
media.find((req) => req.tmdbId === result.id && MediaType.TV)
media.find(
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.TV
)
)
),
});
@@ -197,7 +461,7 @@ discoverRoutes.get<{ keywordId: string }>(
const data = await tmdb.getMoviesByKeyword({
keywordId: Number(req.params.keywordId),
page: Number(req.query.page),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(
@@ -212,8 +476,8 @@ discoverRoutes.get<{ keywordId: string }>(
mapMovieResult(
result,
media.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
)
)
),
@@ -221,4 +485,86 @@ discoverRoutes.get<{ keywordId: string }>(
}
);
discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
'/genreslider/movie',
async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const mappedGenres: GenreSliderItem[] = [];
const genres = await tmdb.getMovieGenres({
language: req.locale ?? (req.query.language as string),
});
await Promise.all(
genres.map(async (genre) => {
const genreData = await tmdb.getDiscoverMovies({ genre: genre.id });
mappedGenres.push({
id: genre.id,
name: genre.name,
backdrops: genreData.results
.filter((title) => !!title.backdrop_path)
.map((title) => title.backdrop_path) as string[],
});
})
);
const sortedData = sortBy(mappedGenres, 'name');
return res.status(200).json(sortedData);
} catch (e) {
logger.error('Something went wrong retrieving the movie genre slider', {
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve movie genre slider.',
});
}
}
);
discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
'/genreslider/tv',
async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const mappedGenres: GenreSliderItem[] = [];
const genres = await tmdb.getTvGenres({
language: req.locale ?? (req.query.language as string),
});
await Promise.all(
genres.map(async (genre) => {
const genreData = await tmdb.getDiscoverTv({ genre: genre.id });
mappedGenres.push({
id: genre.id,
name: genre.name,
backdrops: genreData.results
.filter((title) => !!title.backdrop_path)
.map((title) => title.backdrop_path) as string[],
});
})
);
const sortedData = sortBy(mappedGenres, 'name');
return res.status(200).json(sortedData);
} catch (e) {
logger.error('Something went wrong retrieving the tv genre slider', {
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve tv genre slider.',
});
}
}
);
export default discoverRoutes;

View File

@@ -1,31 +1,75 @@
import { Router } from 'express';
import user from './user';
import authRoutes from './auth';
import { checkUser, isAuthenticated } from '../middleware/auth';
import settingsRoutes from './settings';
import GithubAPI from '../api/github';
import TheMovieDb from '../api/themoviedb';
import { StatusResponse } from '../interfaces/api/settingsInterfaces';
import { Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings';
import searchRoutes from './search';
import discoverRoutes from './discover';
import requestRoutes from './request';
import movieRoutes from './movie';
import tvRoutes from './tv';
import mediaRoutes from './media';
import personRoutes from './person';
import collectionRoutes from './collection';
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 authRoutes from './auth';
import collectionRoutes from './collection';
import discoverRoutes from './discover';
import mediaRoutes from './media';
import movieRoutes from './movie';
import personRoutes from './person';
import requestRoutes from './request';
import searchRoutes from './search';
import serviceRoutes from './service';
import { appDataStatus, appDataPath } from '../utils/appDataVolume';
import TheMovieDb from '../api/themoviedb';
import settingsRoutes from './settings';
import tvRoutes from './tv';
import user from './user';
const router = Router();
router.use(checkUser);
router.get('/status', (req, res) => {
router.get<unknown, StatusResponse>('/status', async (req, res) => {
const githubApi = new GithubAPI();
const currentVersion = getAppVersion();
const commitTag = getCommitTag();
let updateAvailable = false;
let commitsBehind = 0;
if (currentVersion.startsWith('develop-') && commitTag !== 'local') {
const commits = await githubApi.getOverseerrCommits();
if (commits.length) {
const filteredCommits = commits.filter(
(commit) => !commit.commit.message.includes('[skip ci]')
);
if (filteredCommits[0].sha !== commitTag) {
updateAvailable = true;
}
const commitIndex = filteredCommits.findIndex(
(commit) => commit.sha === commitTag
);
if (updateAvailable) {
commitsBehind = commitIndex;
}
}
} else if (commitTag !== 'local') {
const releases = await githubApi.getOverseerrReleases();
if (releases.length) {
const latestVersion = releases[0];
if (!latestVersion.name.includes(currentVersion)) {
updateAvailable = true;
}
}
}
return res.status(200).json({
version: getAppVersion(),
commitTag: getCommitTag(),
updateAvailable,
commitsBehind,
});
});
@@ -37,10 +81,16 @@ router.get('/status/appdata', (_req, res) => {
});
router.use('/user', isAuthenticated(), user);
router.get('/settings/public', (_req, res) => {
router.get('/settings/public', async (req, res) => {
const settings = getSettings();
return res.status(200).json(settings.fullPublicSettings);
if (!(req.user?.settings?.notificationTypes.webpush ?? true)) {
return res
.status(200)
.json({ ...settings.fullPublicSettings, enablePushRegistration: false });
} else {
return res.status(200).json(settings.fullPublicSettings);
}
});
router.use(
'/settings',
@@ -74,6 +124,42 @@ router.get('/languages', isAuthenticated(), async (req, res) => {
return res.status(200).json(languages);
});
router.get<{ id: string }>('/studio/:id', async (req, res) => {
const tmdb = new TheMovieDb();
const studio = await tmdb.getStudio(Number(req.params.id));
return res.status(200).json(mapProductionCompany(studio));
});
router.get<{ id: string }>('/network/:id', async (req, res) => {
const tmdb = new TheMovieDb();
const network = await tmdb.getNetwork(Number(req.params.id));
return res.status(200).json(mapNetwork(network));
});
router.get('/genres/movie', isAuthenticated(), async (req, res) => {
const tmdb = new TheMovieDb();
const genres = await tmdb.getMovieGenres({
language: req.locale ?? (req.query.language as string),
});
return res.status(200).json(genres);
});
router.get('/genres/tv', isAuthenticated(), async (req, res) => {
const tmdb = new TheMovieDb();
const genres = await tmdb.getTvGenres({
language: req.locale ?? (req.query.language as string),
});
return res.status(200).json(genres);
});
router.get('/', (_req, res) => {
return res.status(200).json({
api: 'Overseerr API',

View File

@@ -1,11 +1,11 @@
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';
import Media from '../entity/Media';
import RottenTomatoes from '../api/rottentomatoes';
import logger from '../logger';
import { MediaType } from '../constants/media';
const movieRoutes = Router();
@@ -15,7 +15,7 @@ movieRoutes.get('/:id', async (req, res, next) => {
try {
const tmdbMovie = await tmdb.getMovie({
movieId: Number(req.params.id),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE);
@@ -36,7 +36,7 @@ movieRoutes.get('/:id/recommendations', async (req, res) => {
const results = await tmdb.getMovieRecommendations({
movieId: Number(req.params.id),
page: Number(req.query.page),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(
@@ -64,7 +64,7 @@ movieRoutes.get('/:id/similar', async (req, res) => {
const results = await tmdb.getMovieSimilar({
movieId: Number(req.params.id),
page: Number(req.query.page),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(

View File

@@ -16,7 +16,7 @@ personRoutes.get('/:id', async (req, res, next) => {
try {
const person = await tmdb.getPerson({
personId: Number(req.params.id),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
});
return res.status(200).json(mapPersonDetails(person));
} catch (e) {
@@ -30,7 +30,7 @@ personRoutes.get('/:id/combined_credits', async (req, res) => {
const combinedCredits = await tmdb.getPersonCombinedCredits({
personId: Number(req.params.id),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
});
const castMedia = await Media.getRelatedMedia(

View File

@@ -1,15 +1,15 @@
import { Router } from 'express';
import { isAuthenticated } from '../middleware/auth';
import { Permission } from '../lib/permissions';
import { getRepository } from 'typeorm';
import { MediaRequest } from '../entity/MediaRequest';
import TheMovieDb from '../api/themoviedb';
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
import Media from '../entity/Media';
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
import { MediaRequest } from '../entity/MediaRequest';
import SeasonRequest from '../entity/SeasonRequest';
import logger from '../logger';
import { RequestResultsResponse } from '../interfaces/api/requestInterfaces';
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();
@@ -17,6 +17,9 @@ requestRoutes.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;
const requestedBy = req.query.requestedBy
? Number(req.query.requestedBy)
: null;
let statusFilter: MediaRequestStatus[];
@@ -100,9 +103,20 @@ requestRoutes.get('/', async (req, res, next) => {
{ type: 'or' }
)
) {
if (requestedBy && requestedBy !== req.user?.id) {
return next({
status: 403,
message: "You do not have permission to view this user's requests.",
});
}
query = query.andWhere('requestedBy.id = :id', {
id: req.user?.id,
});
} else if (requestedBy) {
query = query.andWhere('requestedBy.id = :id', {
id: requestedBy,
});
}
const [requests, requestCount] = await query
@@ -125,229 +139,299 @@ requestRoutes.get('/', async (req, res, next) => {
}
});
requestRoutes.post(
'/',
isAuthenticated(Permission.REQUEST),
async (req, res, next) => {
const tmdb = new TheMovieDb();
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User);
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;
try {
let requestUser = req.user;
if (
req.body.userId &&
!req.user?.hasPermission([
Permission.MANAGE_USERS,
Permission.MANAGE_REQUESTS,
])
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('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',
});
return next({
status: 409,
message: 'Request for this media already exists.',
});
}
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[]);
}
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: 'You do not have permission to modify the request user.',
});
} else if (req.body.userId) {
requestUser = await userRepository.findOneOrFail({
where: { id: req.body.userId },
message: 'Series Quota Exceeded',
});
}
const tmdbMedia =
req.body.mediaType === 'movie'
? await tmdb.getMovie({ movieId: req.body.mediaId })
: await tmdb.getTvShow({ tvId: req.body.mediaId });
await mediaRepository.save(media);
let media = await mediaRepository.findOne({
where: { tmdbId: req.body.mediaId, mediaType: req.body.mediaType },
relations: ['requests'],
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,
})
),
});
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 === 'movie') {
const existing = await requestRepository.findOne({
where: {
media: {
tmdbId: tmdbMedia.id,
},
requestedBy: req.user,
is4k: req.body.is4k,
},
});
if (existing) {
logger.warn('Duplicate request for media blocked', {
tmdbId: tmdbMedia.id,
mediaType: req.body.mediaType,
});
return next({
status: 409,
message: 'Request for this media already exists.',
});
}
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.user?.hasPermission(
req.body.is4k
? Permission.AUTO_APPROVE_4K_MOVIE
: Permission.AUTO_APPROVE_MOVIE
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy:
req.user?.hasPermission(
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE
) ||
req.user?.hasPermission(
req.body.is4k
? Permission.AUTO_APPROVE_4K_MOVIE
: Permission.AUTO_APPROVE_MOVIE
)
? req.user
: undefined,
is4k: req.body.is4k,
serverId: req.body.serverId,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
});
await requestRepository.save(request);
return res.status(201).json(request);
} else if (req.body.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[]);
}
const finalSeasons = requestedSeasons.filter(
(rs) => !existingSeasons.includes(rs)
);
if (finalSeasons.length === 0) {
return next({
status: 202,
message: 'No seasons available to request',
});
}
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.user?.hasPermission(
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy:
req.user?.hasPermission(
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE
) ||
req.user?.hasPermission(
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV
)
? req.user
: undefined,
is4k: req.body.is4k,
serverId: req.body.serverId,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
languageProfileId: req.body.languageProfileId,
seasons: finalSeasons.map(
(sn) =>
new SeasonRequest({
seasonNumber: sn,
status:
req.user?.hasPermission(
req.body.is4k
? Permission.AUTO_APPROVE_4K
: Permission.AUTO_APPROVE
) ||
req.user?.hasPermission(
req.body.is4k
? Permission.AUTO_APPROVE_4K_TV
: Permission.AUTO_APPROVE_TV
)
? 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 });
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);
@@ -421,7 +505,6 @@ requestRoutes.get('/:requestId', async (req, res, next) => {
requestRoutes.put<{ requestId: string }>(
'/:requestId',
isAuthenticated(Permission.MANAGE_REQUESTS),
async (req, res, next) => {
const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User);
@@ -431,17 +514,30 @@ requestRoutes.put<{ requestId: string }>(
);
if (!request) {
return next({ status: 404, message: 'Request not found' });
return next({ status: 404, message: 'Request not found.' });
}
if (
(request.requestedBy.id !== req.user?.id ||
(req.body.mediaType !== 'tv' &&
!req.user?.hasPermission(Permission.REQUEST_ADVANCED))) &&
!req.user?.hasPermission(Permission.MANAGE_REQUESTS)
) {
return next({
status: 403,
message: 'You do not have permission to modify this request.',
});
}
let requestUser = req.user;
if (
req.body.userId &&
!(
req.user?.hasPermission(Permission.MANAGE_USERS) &&
req.user?.hasPermission(Permission.MANAGE_REQUESTS)
)
req.body.userId !== req.user?.id &&
!req.user?.hasPermission([
Permission.MANAGE_USERS,
Permission.MANAGE_REQUESTS,
])
) {
return next({
status: 403,
@@ -453,25 +549,28 @@ requestRoutes.put<{ requestId: string }>(
});
}
if (req.body.mediaType === 'movie') {
if (req.body.mediaType === MediaType.MOVIE) {
request.serverId = req.body.serverId;
request.profileId = req.body.profileId;
request.rootFolder = req.body.rootFolder;
request.tags = req.body.tags;
request.requestedBy = requestUser as User;
requestRepository.save(request);
} else if (req.body.mediaType === 'tv') {
} else if (req.body.mediaType === MediaType.TV) {
const mediaRepository = getRepository(Media);
request.serverId = req.body.serverId;
request.profileId = req.body.profileId;
request.rootFolder = req.body.rootFolder;
request.languageProfileId = req.body.languageProfileId;
request.tags = req.body.tags;
request.requestedBy = requestUser as User;
const requestedSeasons = req.body.seasons as number[] | undefined;
if (!requestedSeasons || requestedSeasons.length === 0) {
throw new Error(
'Missing seasons. If you want to cancel a tv request, use the DELETE method.'
'Missing seasons. If you want to cancel a series request, use the DELETE method.'
);
}
@@ -558,7 +657,7 @@ requestRoutes.delete('/:requestId', async (req, res, next) => {
) {
return next({
status: 401,
message: 'You do not have permission to remove this request',
message: 'You do not have permission to delete this request.',
});
}
@@ -567,7 +666,7 @@ requestRoutes.delete('/:requestId', async (req, res, next) => {
return res.status(204).send();
} catch (e) {
logger.error(e.message);
next({ status: 404, message: 'Request not found' });
next({ status: 404, message: 'Request not found.' });
}
});
@@ -593,7 +692,7 @@ requestRoutes.post<{
label: 'Media Request',
message: e.message,
});
next({ status: 404, message: 'Request not found' });
next({ status: 404, message: 'Request not found.' });
}
}
);
@@ -637,7 +736,7 @@ requestRoutes.post<{
label: 'Media Request',
message: e.message,
});
next({ status: 404, message: 'Request not found' });
next({ status: 404, message: 'Request not found.' });
}
}
);

View File

@@ -1,7 +1,7 @@
import { Router } from 'express';
import TheMovieDb from '../api/themoviedb';
import { mapSearchResults } from '../models/Search';
import Media from '../entity/Media';
import { mapSearchResults } from '../models/Search';
const searchRoutes = Router();
@@ -11,7 +11,7 @@ searchRoutes.get('/', async (req, res) => {
const results = await tmdb.searchMulti({
query: req.query.query as string,
page: Number(req.query.page),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(

View File

@@ -1,12 +1,12 @@
import { Router } from 'express';
import RadarrAPI from '../api/radarr';
import SonarrAPI from '../api/sonarr';
import RadarrAPI from '../api/servarr/radarr';
import SonarrAPI from '../api/servarr/sonarr';
import TheMovieDb from '../api/themoviedb';
import {
ServiceCommonServer,
ServiceCommonServerWithDetails,
} from '../interfaces/api/serviceInterfaces';
import { getSettings } from '../lib/settings';
import TheMovieDb from '../api/themoviedb';
import logger from '../logger';
const serviceRoutes = Router();
@@ -22,6 +22,7 @@ serviceRoutes.get('/radarr', async (req, res) => {
isDefault: radarr.isDefault,
activeDirectory: radarr.activeDirectory,
activeProfileId: radarr.activeProfileId,
activeTags: radarr.tags ?? [],
})
);
@@ -46,11 +47,12 @@ serviceRoutes.get<{ radarrId: string }>(
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
});
const profiles = await radarr.getProfiles();
const rootFolders = await radarr.getRootFolders();
const tags = await radarr.getTags();
return res.status(200).json({
server: {
@@ -60,6 +62,7 @@ serviceRoutes.get<{ radarrId: string }>(
isDefault: radarrSettings.isDefault,
activeDirectory: radarrSettings.activeDirectory,
activeProfileId: radarrSettings.activeProfileId,
activeTags: radarrSettings.tags,
},
profiles: profiles.map((profile) => ({
id: profile.id,
@@ -71,6 +74,7 @@ serviceRoutes.get<{ radarrId: string }>(
path: folder.path,
totalSpace: folder.totalSpace,
})),
tags,
} as ServiceCommonServerWithDetails);
}
);
@@ -90,6 +94,7 @@ serviceRoutes.get('/sonarr', async (req, res) => {
activeAnimeDirectory: sonarr.activeAnimeDirectory,
activeLanguageProfileId: sonarr.activeLanguageProfileId,
activeAnimeLanguageProfileId: sonarr.activeAnimeLanguageProfileId,
activeTags: [],
})
);
@@ -114,13 +119,14 @@ serviceRoutes.get<{ sonarrId: string }>(
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'),
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
});
try {
const profiles = await sonarr.getProfiles();
const rootFolders = await sonarr.getRootFolders();
const languageProfiles = await sonarr.getLanguageProfiles();
const tags = await sonarr.getTags();
return res.status(200).json({
server: {
@@ -135,6 +141,8 @@ serviceRoutes.get<{ sonarrId: string }>(
activeLanguageProfileId: sonarrSettings.activeLanguageProfileId,
activeAnimeLanguageProfileId:
sonarrSettings.activeAnimeLanguageProfileId,
activeTags: sonarrSettings.tags,
activeAnimeTags: sonarrSettings.animeTags,
},
profiles: profiles.map((profile) => ({
id: profile.id,
@@ -147,6 +155,7 @@ serviceRoutes.get<{ sonarrId: string }>(
totalSpace: folder.totalSpace,
})),
languageProfiles: languageProfiles,
tags,
} as ServiceCommonServerWithDetails);
} catch (e) {
next({ status: 500, message: e.message });
@@ -182,7 +191,7 @@ serviceRoutes.get<{ tmdbId: string }>(
try {
const tv = await tmdb.getTvShow({
tvId: Number(req.params.tmdbId),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
});
const response = await sonarr.getSeriesByTitle(tv.name);

View File

@@ -1,24 +1,34 @@
import { Router } from 'express';
import { getSettings, Library, MainSettings } from '../../lib/settings';
import rateLimit from 'express-rate-limit';
import fs from 'fs';
import { merge, omit } from 'lodash';
import path from 'path';
import { getRepository } from 'typeorm';
import { User } from '../../entity/User';
import { URL } from 'url';
import JellyfinAPI from '../../api/jellyfin';
import PlexAPI from '../../api/plexapi';
import PlexTvAPI from '../../api/plextv';
import JellyfinAPI from '../../api/jellyfin';
import { jobPlexFullSync } from '../../job/plexsync';
import { jobJellyfinFullSync } from '../../job/jellyfinsync';
import { scheduledJobs } from '../../job/schedule';
import { Permission } from '../../lib/permissions';
import { isAuthenticated } from '../../middleware/auth';
import { merge, omit } from 'lodash';
import Media from '../../entity/Media';
import { MediaRequest } from '../../entity/MediaRequest';
import { getAppVersion } from '../../utils/appVersion';
import { SettingsAboutResponse } from '../../interfaces/api/settingsInterfaces';
import notificationRoutes from './notifications';
import sonarrRoutes from './sonarr';
import radarrRoutes from './radarr';
import { User } from '../../entity/User';
import { PlexConnection } from '../../interfaces/api/plexInterfaces';
import {
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, MainSettings } from '../../lib/settings';
import logger from '../../logger';
import { isAuthenticated } from '../../middleware/auth';
import { getAppVersion } from '../../utils/appVersion';
import notificationRoutes from './notifications';
import radarrRoutes from './radarr';
import sonarrRoutes from './sonarr';
const settingsRoutes = Router();
@@ -107,7 +117,6 @@ settingsRoutes.post('/plex', async (req, res, next) => {
settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
const userRepository = getRepository(User);
const regexp = /(http(s?):\/\/)(.*)(:[0-9]*)/;
try {
const admin = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
@@ -120,40 +129,51 @@ settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
return device.provides.includes('server') && device.owned;
});
const settings = getSettings();
if (devices) {
await Promise.all(
devices.map(async (device) => {
const plexDirectConnections: PlexConnection[] = [];
device.connection.forEach((connection) => {
const url = new URL(connection.uri);
if (url.hostname !== connection.address) {
const plexDirectConnection = { ...connection };
plexDirectConnection.address = url.hostname;
plexDirectConnections.push(plexDirectConnection);
// Connect to IP addresses over HTTP
connection.protocol = 'http';
}
});
plexDirectConnections.forEach((plexDirectConnection) => {
device.connection.push(plexDirectConnection);
});
await Promise.all(
device.connection.map(async (connection) => {
connection.host = connection.uri.replace(regexp, '$3');
let msg:
| { status: number; message: string }
| undefined = undefined;
const plexDeviceSettings = {
...settings.plex,
ip: connection.host,
ip: connection.address,
port: connection.port,
useSsl: connection.protocol === 'https' ? true : false,
useSsl: connection.protocol === 'https',
};
const plexClient = new PlexAPI({
plexToken: admin.plexToken,
plexSettings: plexDeviceSettings,
timeout: 5000,
});
try {
await plexClient.getStatus();
msg = {
status: 200,
message: 'OK',
};
connection.status = 200;
connection.message = 'OK';
} catch (e) {
msg = {
status: 500,
message: e.message,
};
connection.status = 500;
connection.message = e.message.split(':')[0];
}
connection.status = msg?.status;
connection.message = msg?.message;
})
);
})
@@ -179,26 +199,7 @@ settingsRoutes.get('/plex/library', async (req, res) => {
});
const plexapi = new PlexAPI({ plexToken: admin.plexToken });
const libraries = await plexapi.getLibraries();
const newLibraries: Library[] = libraries
// Remove libraries that are not movie or show
.filter((library) => library.type === 'movie' || library.type === 'show')
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
.filter((library) => library.agent !== 'com.plexapp.agents.none')
.map((library) => {
const existing = settings.plex.libraries.find(
(l) => l.id === library.key && l.name === library.title
);
return {
id: library.key,
name: library.title,
enabled: existing?.enabled ?? false,
};
});
settings.plex.libraries = newLibraries;
await plexapi.syncLibraries();
}
const enabledLibraries = req.query.enable
@@ -213,16 +214,16 @@ settingsRoutes.get('/plex/library', async (req, res) => {
});
settingsRoutes.get('/plex/sync', (_req, res) => {
return res.status(200).json(jobPlexFullSync.status());
return res.status(200).json(plexFullScanner.status());
});
settingsRoutes.post('/plex/sync', (req, res) => {
if (req.body.cancel) {
jobPlexFullSync.cancel();
plexFullScanner.cancel();
} else if (req.body.start) {
jobPlexFullSync.run();
plexFullScanner.run();
}
return res.status(200).json(jobPlexFullSync.status());
return res.status(200).json(plexFullScanner.status());
});
settingsRoutes.get('/jellyfin', (_req, res) => {
@@ -297,6 +298,85 @@ settingsRoutes.post('/jellyfin/sync', (req, res) => {
}
return res.status(200).json(jobJellyfinFullSync.status());
});
settingsRoutes.get(
'/logs',
rateLimit({ windowMs: 60 * 1000, max: 50 }),
(req, res, next) => {
const pageSize = req.query.take ? Number(req.query.take) : 25;
const skip = req.query.skip ? Number(req.query.skip) : 0;
let filter: string[] = [];
switch (req.query.filter) {
case 'debug':
filter.push('debug');
// falls through
case 'info':
filter.push('info');
// falls through
case 'warn':
filter.push('warn');
// falls through
case 'error':
filter.push('error');
break;
default:
filter = ['debug', 'info', 'warn', 'error'];
}
const logFile = process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/logs/overseerr.log`
: path.join(__dirname, '../../../config/logs/overseerr.log');
const logs: LogMessage[] = [];
try {
fs.readFileSync(logFile)
.toString()
.split('\n')
.forEach((line) => {
if (!line.length) return;
const timestamp = line.match(new RegExp(/^.{24}/)) || [];
const level = line.match(new RegExp(/\s\[\w+\]/)) || [];
const label = line.match(new RegExp(/\]\[.+?\]/)) || [];
const message = line.match(new RegExp(/:\s([^{}]+)({.*})?/)) || [];
if (level.length && filter.includes(level[0].slice(2, -1))) {
logs.push({
timestamp: timestamp[0],
level: level.length ? level[0].slice(2, -1) : '',
label: label.length ? label[0].slice(2, -1) : '',
message: message.length && message[1] ? message[1] : '',
data:
message.length && message[2]
? JSON.parse(message[2])
: undefined,
});
}
});
const displayedLogs = logs.reverse().slice(skip, skip + pageSize);
return res.status(200).json({
pageInfo: {
pages: Math.ceil(logs.length / pageSize),
pageSize,
results: logs.length,
page: Math.ceil(skip / pageSize) + 1,
},
results: displayedLogs,
} as LogsResultsResponse);
} catch (error) {
logger.error('Something went wrong while fetching the logs', {
label: 'Logs',
errorMessage: error.message,
});
return next({
status: 500,
message: 'Something went wrong while fetching the logs',
});
}
}
);
settingsRoutes.get('/jobs', (_req, res) => {
return res.status(200).json(

View File

@@ -1,39 +1,18 @@
import { Router } from 'express';
import { getSettings } from '../../lib/settings';
import { Notification } from '../../lib/notifications';
import DiscordAgent from '../../lib/notifications/agents/discord';
import EmailAgent from '../../lib/notifications/agents/email';
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 PushoverAgent from '../../lib/notifications/agents/pushover';
import WebhookAgent from '../../lib/notifications/agents/webhook';
import PushbulletAgent from '../../lib/notifications/agents/pushbullet';
import WebPushAgent from '../../lib/notifications/agents/webpush';
import { getSettings } from '../../lib/settings';
const notificationRoutes = Router();
notificationRoutes.get('/', (_req, res) => {
const settings = getSettings().notifications;
return res.status(200).json({
enabled: settings.enabled,
autoapprovalEnabled: settings.autoapprovalEnabled,
});
});
notificationRoutes.post('/', (req, res) => {
const settings = getSettings();
Object.assign(settings.notifications, {
enabled: req.body.enabled,
autoapprovalEnabled: req.body.autoapprovalEnabled,
});
settings.save();
return res.status(200).json({
enabled: settings.notifications.enabled,
autoapprovalEnabled: settings.notifications.autoapprovalEnabled,
});
});
notificationRoutes.get('/discord', (_req, res) => {
const settings = getSettings();
@@ -49,23 +28,30 @@ notificationRoutes.post('/discord', (req, res) => {
res.status(200).json(settings.notifications.agents.discord);
});
notificationRoutes.post('/discord/test', (req, res, next) => {
notificationRoutes.post('/discord/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.',
});
}
const discordAgent = new DiscordAgent(req.body);
discordAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
});
return res.status(204).send();
if (
await discordAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
return res.status(204).send();
} else {
return next({
status: 500,
message: 'Failed to send Discord notification.',
});
}
});
notificationRoutes.get('/slack', (_req, res) => {
@@ -83,23 +69,30 @@ notificationRoutes.post('/slack', (req, res) => {
res.status(200).json(settings.notifications.agents.slack);
});
notificationRoutes.post('/slack/test', (req, res, next) => {
notificationRoutes.post('/slack/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.',
});
}
const slackAgent = new SlackAgent(req.body);
slackAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
});
return res.status(204).send();
if (
await slackAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
return res.status(204).send();
} else {
return next({
status: 500,
message: 'Failed to send Slack notification.',
});
}
});
notificationRoutes.get('/telegram', (_req, res) => {
@@ -117,23 +110,30 @@ notificationRoutes.post('/telegram', (req, res) => {
res.status(200).json(settings.notifications.agents.telegram);
});
notificationRoutes.post('/telegram/test', (req, res, next) => {
notificationRoutes.post('/telegram/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.',
});
}
const telegramAgent = new TelegramAgent(req.body);
telegramAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
});
return res.status(204).send();
if (
await telegramAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
return res.status(204).send();
} else {
return next({
status: 500,
message: 'Failed to send Telegram notification.',
});
}
});
notificationRoutes.get('/pushbullet', (_req, res) => {
@@ -151,23 +151,30 @@ notificationRoutes.post('/pushbullet', (req, res) => {
res.status(200).json(settings.notifications.agents.pushbullet);
});
notificationRoutes.post('/pushbullet/test', (req, res, next) => {
notificationRoutes.post('/pushbullet/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.',
});
}
const pushbulletAgent = new PushbulletAgent(req.body);
pushbulletAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
});
return res.status(204).send();
if (
await pushbulletAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
return res.status(204).send();
} else {
return next({
status: 500,
message: 'Failed to send Pushbullet notification.',
});
}
});
notificationRoutes.get('/pushover', (_req, res) => {
@@ -185,23 +192,30 @@ notificationRoutes.post('/pushover', (req, res) => {
res.status(200).json(settings.notifications.agents.pushover);
});
notificationRoutes.post('/pushover/test', (req, res, next) => {
notificationRoutes.post('/pushover/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.',
});
}
const pushoverAgent = new PushoverAgent(req.body);
pushoverAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
});
return res.status(204).send();
if (
await pushoverAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
return res.status(204).send();
} else {
return next({
status: 500,
message: 'Failed to send Pushover notification.',
});
}
});
notificationRoutes.get('/email', (_req, res) => {
@@ -219,7 +233,48 @@ notificationRoutes.post('/email', (req, res) => {
res.status(200).json(settings.notifications.agents.email);
});
notificationRoutes.post('/email/test', (req, res, next) => {
notificationRoutes.post('/email/test', async (req, res, next) => {
if (!req.user) {
return next({
status: 500,
message: 'User information is missing from the request.',
});
}
const emailAgent = new EmailAgent(req.body);
if (
await emailAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
return res.status(204).send();
} else {
return next({
status: 500,
message: 'Failed to send email notification.',
});
}
});
notificationRoutes.get('/webpush', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.notifications.agents.webpush);
});
notificationRoutes.post('/webpush', (req, res) => {
const settings = getSettings();
settings.notifications.agents.webpush = req.body;
settings.save();
res.status(200).json(settings.notifications.agents.webpush);
});
notificationRoutes.post('/webpush/test', async (req, res, next) => {
if (!req.user) {
return next({
status: 500,
@@ -227,15 +282,22 @@ notificationRoutes.post('/email/test', (req, res, next) => {
});
}
const emailAgent = new EmailAgent(req.body);
emailAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
});
return res.status(204).send();
const webpushAgent = new WebPushAgent(req.body);
if (
await webpushAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
return res.status(204).send();
} else {
return next({
status: 500,
message: 'Failed to send web push notification.',
});
}
});
notificationRoutes.get('/webhook', (_req, res) => {
@@ -283,11 +345,11 @@ notificationRoutes.post('/webhook', (req, res, next) => {
}
});
notificationRoutes.post('/webhook/test', (req, res, next) => {
notificationRoutes.post('/webhook/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.',
});
}
@@ -307,17 +369,65 @@ notificationRoutes.post('/webhook/test', (req, res, next) => {
};
const webhookAgent = new WebhookAgent(testBody);
webhookAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
});
return res.status(204).send();
if (
await webhookAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
return res.status(204).send();
} else {
return next({
status: 500,
message: 'Failed to send webhook notification.',
});
}
} catch (e) {
next({ status: 500, message: e.message });
}
});
notificationRoutes.get('/lunasea', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.notifications.agents.lunasea);
});
notificationRoutes.post('/lunasea', (req, res) => {
const settings = getSettings();
settings.notifications.agents.lunasea = req.body;
settings.save();
res.status(200).json(settings.notifications.agents.lunasea);
});
notificationRoutes.post('/lunasea/test', async (req, res, next) => {
if (!req.user) {
return next({
status: 500,
message: 'User information missing from request',
});
}
const lunaseaAgent = new LunaSeaAgent(req.body);
if (
await lunaseaAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
return res.status(204).send();
} else {
return next({
status: 500,
message: 'Failed to send web push notification.',
});
}
});
export default notificationRoutes;

View File

@@ -1,5 +1,5 @@
import { Router } from 'express';
import RadarrAPI from '../../api/radarr';
import RadarrAPI from '../../api/servarr/radarr';
import { getSettings, RadarrSettings } from '../../lib/settings';
import logger from '../../logger';
@@ -35,15 +35,20 @@ radarrRoutes.post('/', (req, res) => {
return res.status(201).json(newRadarr);
});
radarrRoutes.post('/test', async (req, res, next) => {
radarrRoutes.post<
undefined,
Record<string, unknown>,
RadarrSettings & { tagLabel?: string }
>('/test', async (req, res, next) => {
try {
const radarr = new RadarrAPI({
apiKey: req.body.apiKey,
url: RadarrAPI.buildRadarrUrl(req.body, '/api/v3'),
url: RadarrAPI.buildUrl(req.body, '/api/v3'),
});
const profiles = await radarr.getProfiles();
const folders = await radarr.getRootFolders();
const tags = await radarr.getTags();
return res.status(200).json({
profiles,
@@ -51,6 +56,7 @@ radarrRoutes.post('/test', async (req, res, next) => {
id: folder.id,
path: folder.path,
})),
tags,
});
} catch (e) {
logger.error('Failed to test Radarr', {
@@ -62,40 +68,41 @@ radarrRoutes.post('/test', async (req, res, next) => {
}
});
radarrRoutes.put<{ id: string }>('/:id', (req, res) => {
const settings = getSettings();
radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>(
'/:id',
(req, res, next) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
(r) => r.id === Number(req.params.id)
);
const radarrIndex = settings.radarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (radarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
if (radarrIndex === -1) {
return next({ status: '404', message: 'Settings instance not found' });
}
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.radarr
.filter((radarrInstance) => radarrInstance.is4k === req.body.is4k)
.forEach((radarrInstance) => {
radarrInstance.isDefault = false;
});
}
settings.radarr[radarrIndex] = {
...req.body,
id: Number(req.params.id),
} as RadarrSettings;
settings.save();
return res.status(200).json(settings.radarr[radarrIndex]);
}
);
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.radarr
.filter((radarrInstance) => radarrInstance.is4k === req.body.is4k)
.forEach((radarrInstance) => {
radarrInstance.isDefault = false;
});
}
settings.radarr[radarrIndex] = {
...req.body,
id: Number(req.params.id),
} as RadarrSettings;
settings.save();
return res.status(200).json(settings.radarr[radarrIndex]);
});
radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => {
const settings = getSettings();
const radarrSettings = settings.radarr.find(
@@ -103,14 +110,12 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
);
if (!radarrSettings) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
return next({ status: '404', message: 'Settings instance not found' });
}
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
});
const profiles = await radarr.getProfiles();
@@ -123,7 +128,7 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
);
});
radarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
@@ -131,9 +136,7 @@ radarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
);
if (radarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
return next({ status: '404', message: 'Settings instance not found' });
}
const removed = settings.radarr.splice(radarrIndex, 1);

View File

@@ -1,5 +1,5 @@
import { Router } from 'express';
import SonarrAPI from '../../api/sonarr';
import SonarrAPI from '../../api/servarr/sonarr';
import { getSettings, SonarrSettings } from '../../lib/settings';
import logger from '../../logger';
@@ -39,12 +39,13 @@ sonarrRoutes.post('/test', async (req, res, next) => {
try {
const sonarr = new SonarrAPI({
apiKey: req.body.apiKey,
url: SonarrAPI.buildSonarrUrl(req.body, '/api/v3'),
url: SonarrAPI.buildUrl(req.body, '/api/v3'),
});
const profiles = await sonarr.getProfiles();
const folders = await sonarr.getRootFolders();
const languageProfiles = await sonarr.getLanguageProfiles();
const tags = await sonarr.getTags();
return res.status(200).json({
profiles,
@@ -53,6 +54,7 @@ sonarrRoutes.post('/test', async (req, res, next) => {
path: folder.path,
})),
languageProfiles,
tags,
});
} catch (e) {
logger.error('Failed to test Sonarr', {

View File

@@ -1,11 +1,11 @@
import { Router } from 'express';
import TheMovieDb from '../api/themoviedb';
import { mapTvDetails, mapSeasonWithEpisodes } from '../models/Tv';
import { mapTvResult } from '../models/Search';
import Media from '../entity/Media';
import RottenTomatoes from '../api/rottentomatoes';
import logger from '../logger';
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();
@@ -14,7 +14,7 @@ tvRoutes.get('/:id', async (req, res, next) => {
try {
const tv = await tmdb.getTvShow({
tvId: Number(req.params.id),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getMedia(tv.id, MediaType.TV);
@@ -35,7 +35,7 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res) => {
const season = await tmdb.getTvSeason({
tvId: Number(req.params.id),
seasonNumber: Number(req.params.seasonNumber),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
});
return res.status(200).json(mapSeasonWithEpisodes(season));
@@ -47,7 +47,7 @@ tvRoutes.get('/:id/recommendations', async (req, res) => {
const results = await tmdb.getTvRecommendations({
tvId: Number(req.params.id),
page: Number(req.query.page),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(
@@ -75,7 +75,7 @@ tvRoutes.get('/:id/similar', async (req, res) => {
const results = await tmdb.getTvSimilar({
tvId: Number(req.params.id),
page: Number(req.query.page),
language: req.query.language as string,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(

View File

@@ -1,16 +1,20 @@
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import { getRepository, Not } from 'typeorm';
import PlexTvAPI from '../../api/plextv';
import { UserType } from '../../constants/user';
import { MediaRequest } from '../../entity/MediaRequest';
import { User } from '../../entity/User';
import { UserPushSubscription } from '../../entity/UserPushSubscription';
import {
QuotaResponse,
UserRequestsResponse,
UserResultsResponse,
} from '../../interfaces/api/userInterfaces';
import { hasPermission, Permission } from '../../lib/permissions';
import { getSettings } from '../../lib/settings';
import logger from '../../logger';
import gravatarUrl from 'gravatar-url';
import { UserType } from '../../constants/user';
import { isAuthenticated } from '../../middleware/auth';
import { UserResultsResponse } from '../../interfaces/api/userInterfaces';
import { UserRequestsResponse } from '../../interfaces/api/userInterfaces';
import userSettingsRoutes from './usersettings';
const router = Router();
@@ -27,7 +31,7 @@ router.get('/', async (req, res, next) => {
break;
case 'displayname':
query = query.orderBy(
'(CASE WHEN user.username IS NULL THEN user.plexUsername ELSE user.username END)',
"(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;
@@ -78,10 +82,28 @@ router.post(
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) {
if (
!passedExplicitPassword &&
!settings.notifications.agents.email.enabled
) {
throw new Error('Email notifications must be enabled');
}
@@ -109,6 +131,48 @@ router.post(
}
);
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);
@@ -167,7 +231,10 @@ router.get<{ id: string }, UserRequestsResponse>(
}
);
const canMakePermissionsChange = (permissions: number, user?: User) =>
export const canMakePermissionsChange = (
permissions: number,
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
@@ -275,7 +342,7 @@ router.delete<{ id: string }>(
});
}
if (user.hasPermission(Permission.ADMIN)) {
if (user.hasPermission(Permission.ADMIN) && req.user?.id !== 1) {
return next({
status: 405,
message: 'You cannot delete users with administrative privileges.',
@@ -329,47 +396,45 @@ router.post(
for (const rawUser of plexUsersResponse.MediaContainer.User) {
const account = rawUser.$;
const user = await userRepository.findOne({
where: [{ plexId: account.id }, { email: account.email }],
});
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 users avatar with their plex thumbnail (incase it changed)
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
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);
if (user.username === account.username) {
user.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 (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);
}
}
await userRepository.save(user);
} else {
// Check to make sure it's a real account
if (
account.email &&
account.username &&
(await mainPlexTv.checkUserAccess(Number(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 });
@@ -377,4 +442,36 @@ router.post(
}
);
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 access this endpoint.',
});
}
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 });
}
}
);
export default router;

View File

@@ -1,5 +1,6 @@
import { Router } from 'express';
import { getRepository } from 'typeorm';
import { canMakePermissionsChange } from '.';
import { User } from '../../entity/User';
import { UserSettings } from '../../entity/UserSettings';
import {
@@ -7,6 +8,7 @@ import {
UserSettingsNotificationsResponse,
} from '../../interfaces/api/userSettingsInterfaces';
import { Permission } from '../../lib/permissions';
import { getSettings } from '../../lib/settings';
import logger from '../../logger';
import { isAuthenticated } from '../../middleware/auth';
@@ -21,6 +23,7 @@ const isOwnProfileOrAdmin = (): Middleware => {
message: "You do not have permission to view this user's settings.",
});
}
next();
};
return authMiddleware;
@@ -32,6 +35,9 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
'/main',
isOwnProfileOrAdmin(),
async (req, res, next) => {
const {
main: { defaultQuotas },
} = getSettings();
const userRepository = getRepository(User);
try {
@@ -45,8 +51,17 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
return res.status(200).json({
username: user.username,
locale: user.settings?.locale,
region: user.settings?.region,
originalLanguage: user.settings?.originalLanguage,
movieQuotaLimit: user.movieQuotaLimit,
movieQuotaDays: user.movieQuotaDays,
tvQuotaLimit: user.tvQuotaLimit,
tvQuotaDays: user.tvQuotaDays,
globalMovieQuotaDays: defaultQuotas.movie.quotaDays,
globalMovieQuotaLimit: defaultQuotas.movie.quotaLimit,
globalTvQuotaDays: defaultQuotas.tv.quotaDays,
globalTvQuotaLimit: defaultQuotas.tv.quotaLimit,
});
} catch (e) {
next({ status: 500, message: e.message });
@@ -70,21 +85,48 @@ userSettingsRoutes.post<
return next({ status: 404, message: 'User not found.' });
}
// "Owner" user settings cannot be modified by other users
if (user.id === 1 && req.user?.id !== 1) {
return next({
status: 403,
message: "You do not have permission to modify this user's settings.",
});
}
user.username = req.body.username;
// Update quota values only if the user has the correct permissions
if (
!user.hasPermission(Permission.MANAGE_USERS) &&
req.user?.id !== user.id
) {
user.movieQuotaDays = req.body.movieQuotaDays;
user.movieQuotaLimit = req.body.movieQuotaLimit;
user.tvQuotaDays = req.body.tvQuotaDays;
user.tvQuotaLimit = req.body.tvQuotaLimit;
}
if (!user.settings) {
user.settings = new UserSettings({
user: req.user,
locale: req.body.locale,
region: req.body.region,
originalLanguage: req.body.originalLanguage,
});
} else {
user.settings.locale = req.body.locale;
user.settings.region = req.body.region;
user.settings.originalLanguage = req.body.originalLanguage;
}
await userRepository.save(user);
return res.status(200).json({ username: user.username });
return res.status(200).json({
username: user.username,
region: user.settings.region,
locale: user.settings.locale,
originalLanguage: user.settings.originalLanguage,
});
} catch (e) {
next({ status: 500, message: e.message });
}
@@ -137,7 +179,19 @@ userSettingsRoutes.post<
if (req.body.newPassword.length < 8) {
return next({
status: 400,
message: 'Password must be at least 8 characters',
message: 'Password must be at least 8 characters.',
});
}
if (
(user.id === 1 && req.user?.id !== 1) ||
(user.hasPermission(Permission.ADMIN) &&
user.id !== req.user?.id &&
req.user?.id !== 1)
) {
return next({
status: 403,
message: "You do not have permission to modify this user's password.",
});
}
@@ -184,6 +238,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
isOwnProfileOrAdmin(),
async (req, res, next) => {
const userRepository = getRepository(User);
const settings = getSettings()?.notifications.agents;
try {
const user = await userRepository.findOne({
@@ -195,8 +250,19 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
}
return res.status(200).json({
enableNotifications: user.settings?.enableNotifications ?? true,
emailEnabled: settings?.email.enabled,
pgpKey: user.settings?.pgpKey,
discordEnabled: settings?.discord.enabled,
discordEnabledTypes: settings?.discord.enabled
? settings?.discord.types
: 0,
discordId: user.settings?.discordId,
telegramEnabled: settings?.telegram.enabled,
telegramBotUsername: settings?.telegram.options.botUsername,
telegramChatId: user.settings?.telegramChatId,
telegramSendSilently: user?.settings?.telegramSendSilently,
webPushEnabled: settings?.webpush.enabled,
notificationTypes: user.settings?.notificationTypes ?? {},
});
} catch (e) {
next({ status: 500, message: e.message });
@@ -204,43 +270,64 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
}
);
userSettingsRoutes.post<
{ id: string },
UserSettingsNotificationsResponse,
UserSettingsNotificationsResponse
>('/notifications', isOwnProfileOrAdmin(), async (req, res, next) => {
const userRepository = getRepository(User);
userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
'/notifications',
isOwnProfileOrAdmin(),
async (req, res, next) => {
const userRepository = getRepository(User);
try {
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
});
if (!user) {
return next({ status: 404, message: 'User not found.' });
}
if (!user.settings) {
user.settings = new UserSettings({
user: req.user,
enableNotifications: req.body.enableNotifications,
discordId: req.body.discordId,
try {
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
});
} else {
user.settings.enableNotifications = req.body.enableNotifications;
user.settings.discordId = req.body.discordId;
if (!user) {
return next({ status: 404, message: 'User not found.' });
}
// "Owner" user settings cannot be modified by other users
if (user.id === 1 && req.user?.id !== 1) {
return next({
status: 403,
message: "You do not have permission to modify this user's settings.",
});
}
if (!user.settings) {
user.settings = new UserSettings({
user: req.user,
pgpKey: req.body.pgpKey,
discordId: req.body.discordId,
telegramChatId: req.body.telegramChatId,
telegramSendSilently: req.body.telegramSendSilently,
notificationTypes: req.body.notificationTypes,
});
} else {
user.settings.pgpKey = req.body.pgpKey;
user.settings.discordId = req.body.discordId;
user.settings.telegramChatId = req.body.telegramChatId;
user.settings.telegramSendSilently = req.body.telegramSendSilently;
user.settings.notificationTypes = Object.assign(
{},
user.settings.notificationTypes,
req.body.notificationTypes
);
}
userRepository.save(user);
return res.status(200).json({
pgpKey: user.settings?.pgpKey,
discordId: user.settings?.discordId,
telegramChatId: user.settings?.telegramChatId,
telegramSendSilently: user?.settings?.telegramSendSilently,
notificationTypes: user.settings.notificationTypes,
});
} catch (e) {
next({ status: 500, message: e.message });
}
userRepository.save(user);
return res.status(200).json({
enableNotifications: user.settings.enableNotifications,
discordId: user.settings.discordId,
});
} catch (e) {
next({ status: 500, message: e.message });
}
});
);
userSettingsRoutes.get<{ id: string }, { permissions?: number }>(
'/permissions',
@@ -283,13 +370,20 @@ userSettingsRoutes.post<
return next({ status: 404, message: 'User not found.' });
}
if (user.id === 1) {
// "Owner" user permissions cannot be modified, and users cannot set their own permissions
if (user.id === 1 || req.user?.id === user.id) {
return next({
status: 500,
message: 'Permissions for user with ID 1 cannot be modified',
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',
});
}
user.permissions = req.body.permissions;
await userRepository.save(user);