Merge branch 'develop' into features/deleteMediaFile

This commit is contained in:
dd060606
2023-02-13 08:58:53 +01:00
committed by GitHub
216 changed files with 16662 additions and 7796 deletions

View File

@@ -89,13 +89,28 @@ authRoutes.post('/plex', async (req, res, next) => {
await userRepository.save(user);
} else {
const mainUser = await userRepository.findOneOrFail({
select: { id: true, plexToken: true, plexId: true },
select: { id: true, plexToken: true, plexId: true, email: true },
where: { id: 1 },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
if (!account.id) {
logger.error('Plex ID was missing from Plex.tv response', {
label: 'API',
ip: req.ip,
email: account.email,
plexUsername: account.username,
});
return next({
status: 500,
message: 'Something went wrong. Try again.',
});
}
if (
account.id === mainUser.plexId ||
(account.email === mainUser.email && !mainUser.plexId) ||
(await mainPlexTv.checkUserAccess(account.id))
) {
if (user) {
@@ -226,7 +241,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
const hostname =
settings.jellyfin.hostname !== ''
? settings.jellyfin.hostname
: body.hostname;
: body.hostname ?? '';
const { externalHostname } = getSettings().jellyfin;
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
@@ -244,11 +259,15 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
}
// First we need to attempt to log the user in to jellyfin
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
const jellyfinHost =
let jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
const account = await jellyfinserver.login(body.username, body.password);
// Next let's see if the user already exists
user = await userRepository.findOne({

View File

@@ -1,5 +1,7 @@
import PlexTvAPI from '@server/api/plextv';
import type { SortOptions } from '@server/api/themoviedb';
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
@@ -20,6 +22,7 @@ import { mapNetwork } from '@server/models/Tv';
import { isMovie, isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import { sortBy } from 'lodash';
import { z } from 'zod';
export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
const settings = getSettings();
@@ -46,25 +49,76 @@ export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
const discoverRoutes = Router();
const QueryFilterOptions = z.object({
page: z.coerce.string().optional(),
sortBy: z.coerce.string().optional(),
primaryReleaseDateGte: z.coerce.string().optional(),
primaryReleaseDateLte: z.coerce.string().optional(),
firstAirDateGte: z.coerce.string().optional(),
firstAirDateLte: z.coerce.string().optional(),
studio: z.coerce.string().optional(),
genre: z.coerce.string().optional(),
keywords: z.coerce.string().optional(),
language: z.coerce.string().optional(),
withRuntimeGte: z.coerce.string().optional(),
withRuntimeLte: z.coerce.string().optional(),
voteAverageGte: z.coerce.string().optional(),
voteAverageLte: z.coerce.string().optional(),
network: z.coerce.string().optional(),
watchProviders: z.coerce.string().optional(),
watchRegion: z.coerce.string().optional(),
});
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
discoverRoutes.get('/movies', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
try {
const query = QueryFilterOptions.parse(req.query);
const keywords = query.keywords;
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
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,
page: Number(query.page),
sortBy: query.sortBy as SortOptions,
language: req.locale ?? query.language,
originalLanguage: query.language,
genre: query.genre,
studio: query.studio,
primaryReleaseDateLte: query.primaryReleaseDateLte
? new Date(query.primaryReleaseDateLte).toISOString().split('T')[0]
: undefined,
primaryReleaseDateGte: query.primaryReleaseDateGte
? new Date(query.primaryReleaseDateGte).toISOString().split('T')[0]
: undefined,
keywords,
withRuntimeGte: query.withRuntimeGte,
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
});
const media = await Media.getRelatedMedia(
data.results.map((result) => result.id)
);
let keywordData: TmdbKeyword[] = [];
if (keywords) {
const splitKeywords = keywords.split(',');
keywordData = await Promise.all(
splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
})
);
}
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
keywords: keywordData,
results: data.results.map((result) =>
mapMovieResult(
result,
@@ -163,7 +217,7 @@ discoverRoutes.get<{ genreId: string }>(
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
genre: Number(req.params.genreId),
genre: req.params.genreId as string,
});
const media = await Media.getRelatedMedia(
@@ -210,7 +264,7 @@ discoverRoutes.get<{ studioId: string }>(
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
studio: Number(req.params.studioId),
studio: req.params.studioId as string,
});
const media = await Media.getRelatedMedia(
@@ -296,21 +350,50 @@ discoverRoutes.get('/tv', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
try {
const query = QueryFilterOptions.parse(req.query);
const keywords = query.keywords;
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
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,
page: Number(query.page),
sortBy: query.sortBy as SortOptions,
language: req.locale ?? query.language,
genre: query.genre,
network: query.network ? Number(query.network) : undefined,
firstAirDateLte: query.firstAirDateLte
? new Date(query.firstAirDateLte).toISOString().split('T')[0]
: undefined,
firstAirDateGte: query.firstAirDateGte
? new Date(query.firstAirDateGte).toISOString().split('T')[0]
: undefined,
originalLanguage: query.language,
keywords,
withRuntimeGte: query.withRuntimeGte,
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
});
const media = await Media.getRelatedMedia(
data.results.map((result) => result.id)
);
let keywordData: TmdbKeyword[] = [];
if (keywords) {
const splitKeywords = keywords.split(',');
keywordData = await Promise.all(
splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
})
);
}
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
keywords: keywordData,
results: data.results.map((result) =>
mapTvResult(
result,
@@ -408,7 +491,7 @@ discoverRoutes.get<{ genreId: string }>(
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
genre: Number(req.params.genreId),
genre: req.params.genreId,
});
const media = await Media.getRelatedMedia(
@@ -643,7 +726,9 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
await Promise.all(
genres.map(async (genre) => {
const genreData = await tmdb.getDiscoverMovies({ genre: genre.id });
const genreData = await tmdb.getDiscoverMovies({
genre: genre.id.toString(),
});
mappedGenres.push({
id: genre.id,
@@ -685,7 +770,9 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
await Promise.all(
genres.map(async (genre) => {
const genreData = await tmdb.getDiscoverTv({ genre: genre.id });
const genreData = await tmdb.getDiscoverTv({
genre: genre.id.toString(),
});
mappedGenres.push({
id: genre.id,

View File

@@ -0,0 +1,39 @@
import ImageProxy from '@server/lib/imageproxy';
import logger from '@server/logger';
import { Router } from 'express';
const router = Router();
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});
/**
* Image Proxy
*/
router.get('/*', async (req, res) => {
const imagePath = req.path.replace('/image', '');
try {
const imageData = await tmdbImageProxy.getImage(imagePath);
res.writeHead(200, {
'Content-Type': `image/${imageData.meta.extension}`,
'Content-Length': imageData.imageBuffer.length,
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
'OS-Cache-Key': imageData.meta.cacheKey,
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
});
res.end(imageData.imageBuffer);
} catch (e) {
logger.error('Failed to proxy image', {
imagePath,
errorMessage: e.message,
});
res.status(500).send();
}
});
export default router;

View File

@@ -4,11 +4,14 @@ import type {
TmdbMovieResult,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
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 { mapWatchProviderDetails } from '@server/models/common';
import { mapProductionCompany } from '@server/models/Movie';
import { mapNetwork } from '@server/models/Tv';
import settingsRoutes from '@server/routes/settings';
@@ -102,6 +105,13 @@ router.get('/settings/public', async (req, res) => {
return res.status(200).json(settings.fullPublicSettings);
}
});
router.get('/settings/discover', isAuthenticated(), async (_req, res) => {
const sliderRepository = getRepository(DiscoverSlider);
const sliders = await sliderRepository.find({ order: { order: 'ASC' } });
return res.json(sliders);
});
router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes);
router.use('/search', isAuthenticated(), searchRoutes);
router.use('/discover', isAuthenticated(), discoverRoutes);
@@ -269,6 +279,87 @@ router.get('/backdrops', async (req, res, next) => {
}
});
router.get('/keyword/:keywordId', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getKeywordDetails({
keywordId: Number(req.params.keywordId),
});
return res.status(200).json(result);
} catch (e) {
logger.debug('Something went wrong retrieving keyword data', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve keyword data.',
});
}
});
router.get('/watchproviders/regions', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getAvailableWatchProviderRegions({});
return res.status(200).json(result);
} catch (e) {
logger.debug('Something went wrong retrieving watch provider regions', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve watch provider regions.',
});
}
});
router.get('/watchproviders/movies', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getMovieWatchProviders({
watchRegion: req.query.watchRegion as string,
});
return res.status(200).json(mapWatchProviderDetails(result));
} catch (e) {
logger.debug('Something went wrong retrieving movie watch providers', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve movie watch providers.',
});
}
});
router.get('/watchproviders/tv', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getTvWatchProviders({
watchRegion: req.query.watchRegion as string,
});
return res.status(200).json(mapWatchProviderDetails(result));
} catch (e) {
logger.debug('Something went wrong retrieving tv watch providers', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve tv watch providers.',
});
}
});
router.get('/', (_req, res) => {
return res.status(200).json({
api: 'Overseerr API',

View File

@@ -308,7 +308,9 @@ issueRoutes.post<{ issueId: string }, Issue, { message: string }>(
issueRoutes.post<{ issueId: string; status: string }, Issue>(
'/:issueId/:status',
isAuthenticated(Permission.MANAGE_ISSUES),
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
type: 'or',
}),
async (req, res, next) => {
const issueRepository = getRepository(Issue);
// Satisfy typescript here. User is set, we assure you!
@@ -321,6 +323,16 @@ issueRoutes.post<{ issueId: string; status: string }, Issue>(
where: { id: Number(req.params.issueId) },
});
if (
!req.user?.hasPermission(Permission.MANAGE_ISSUES) &&
issue.createdBy.id !== req.user?.id
) {
return next({
status: 401,
message: 'You do not have permission to modify this issue.',
});
}
let newStatus: IssueStatus | undefined;
switch (req.params.status) {

View File

@@ -492,8 +492,10 @@ requestRoutes.post<{
relations: { requestedBy: true, modifiedBy: true },
});
await request.updateParentStatus();
await request.sendMedia();
// this also triggers updating the parent media's status & sending to *arr
request.status = MediaRequestStatus.APPROVED;
await requestRepository.save(request);
return res.status(200).json(request);
} catch (e) {
logger.error('Error processing request retry', {

View File

@@ -56,4 +56,50 @@ searchRoutes.get('/', async (req, res, next) => {
}
});
searchRoutes.get('/keyword', async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const results = await tmdb.searchKeyword({
query: req.query.query as string,
page: Number(req.query.page),
});
return res.status(200).json(results);
} catch (e) {
logger.debug('Something went wrong retrieving keyword search results', {
label: 'API',
errorMessage: e.message,
query: req.query.query,
});
return next({
status: 500,
message: 'Unable to retrieve keyword search results.',
});
}
});
searchRoutes.get('/company', async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const results = await tmdb.searchCompany({
query: req.query.query as string,
page: Number(req.query.page),
});
return res.status(200).json(results);
} catch (e) {
logger.debug('Something went wrong retrieving company search results', {
label: 'API',
errorMessage: e.message,
query: req.query.query,
});
return next({
status: 500,
message: 'Unable to retrieve company search results.',
});
}
});
export default searchRoutes;

View File

@@ -0,0 +1,131 @@
import { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import logger from '@server/logger';
import { Router } from 'express';
const discoverSettingRoutes = Router();
discoverSettingRoutes.post('/', async (req, res) => {
const sliderRepository = getRepository(DiscoverSlider);
const sliders = req.body as DiscoverSlider[];
if (!Array.isArray(sliders)) {
return res.status(400).json({ message: 'Invalid request body.' });
}
for (let x = 0; x < sliders.length; x++) {
const slider = sliders[x];
const existingSlider = await sliderRepository.findOne({
where: {
id: slider.id,
},
});
if (existingSlider && slider.id) {
existingSlider.enabled = slider.enabled;
existingSlider.order = x;
// Only allow changes to the following when the slider is not built in
if (!existingSlider.isBuiltIn) {
existingSlider.title = slider.title;
existingSlider.data = slider.data;
existingSlider.type = slider.type;
}
await sliderRepository.save(existingSlider);
} else {
const newSlider = new DiscoverSlider({
isBuiltIn: false,
data: slider.data,
title: slider.title,
enabled: slider.enabled,
order: x,
type: slider.type,
});
await sliderRepository.save(newSlider);
}
}
return res.json(sliders);
});
discoverSettingRoutes.post('/add', async (req, res) => {
const sliderRepository = getRepository(DiscoverSlider);
const slider = req.body as DiscoverSlider;
const newSlider = new DiscoverSlider({
isBuiltIn: false,
data: slider.data,
title: slider.title,
enabled: false,
order: -1,
type: slider.type,
});
await sliderRepository.save(newSlider);
return res.json(newSlider);
});
discoverSettingRoutes.get('/reset', async (_req, res) => {
const sliderRepository = getRepository(DiscoverSlider);
await sliderRepository.clear();
await DiscoverSlider.bootstrapSliders();
return res.status(204).send();
});
discoverSettingRoutes.put('/:sliderId', async (req, res, next) => {
const sliderRepository = getRepository(DiscoverSlider);
const slider = req.body as DiscoverSlider;
try {
const existingSlider = await sliderRepository.findOneOrFail({
where: {
id: Number(req.params.sliderId),
},
});
// Only allow changes to the following when the slider is not built in
if (!existingSlider.isBuiltIn) {
existingSlider.title = slider.title;
existingSlider.data = slider.data;
existingSlider.type = slider.type;
}
await sliderRepository.save(existingSlider);
return res.status(200).json(existingSlider);
} catch (e) {
logger.error('Something went wrong updating a slider.', {
label: 'API',
errorMessage: e.message,
});
next({ status: 404, message: 'Slider not found or cannot be updated.' });
}
});
discoverSettingRoutes.delete('/:sliderId', async (req, res, next) => {
const sliderRepository = getRepository(DiscoverSlider);
try {
const slider = await sliderRepository.findOneOrFail({
where: { id: Number(req.params.sliderId), isBuiltIn: false },
});
await sliderRepository.remove(slider);
return res.status(204).send();
} catch (e) {
logger.error('Something went wrong deleting a slider.', {
label: 'API',
errorMessage: e.message,
});
next({ status: 404, message: 'Slider not found or cannot be deleted.' });
}
});
export default discoverSettingRoutes;

View File

@@ -16,12 +16,14 @@ 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 ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions';
import { plexFullScanner } from '@server/lib/scanners/plex';
import type { Library, MainSettings } from '@server/lib/settings';
import type { JobId, Library, MainSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import discoverSettingRoutes from '@server/routes/settings/discover';
import { appDataPath } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion';
import { Router } from 'express';
@@ -41,6 +43,7 @@ const settingsRoutes = Router();
settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes);
settingsRoutes.use('/discover', discoverSettingRoutes);
const filteredMainSettings = (
user: User,
@@ -307,11 +310,14 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => {
settingsRoutes.get('/jellyfin/users', async (req, res) => {
const settings = getSettings();
const { hostname, externalHostname } = getSettings().jellyfin;
const jellyfinHost =
let jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
@@ -601,7 +607,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
});
});
settingsRoutes.post<{ jobId: string }>(
settingsRoutes.post<{ jobId: JobId }>(
'/jobs/:jobId/cancel',
(req, res, next) => {
const scheduledJob = scheduledJobs.find(
@@ -628,7 +634,7 @@ settingsRoutes.post<{ jobId: string }>(
}
);
settingsRoutes.post<{ jobId: string }>(
settingsRoutes.post<{ jobId: JobId }>(
'/jobs/:jobId/schedule',
(req, res, next) => {
const scheduledJob = scheduledJobs.find(
@@ -663,16 +669,23 @@ settingsRoutes.post<{ jobId: string }>(
}
);
settingsRoutes.get('/cache', (req, res) => {
const caches = cacheManager.getAllCaches();
settingsRoutes.get('/cache', async (_req, res) => {
const cacheManagerCaches = cacheManager.getAllCaches();
return res.status(200).json(
Object.values(caches).map((cache) => ({
id: cache.id,
name: cache.name,
stats: cache.getStats(),
}))
);
const apiCaches = Object.values(cacheManagerCaches).map((cache) => ({
id: cache.id,
name: cache.name,
stats: cache.getStats(),
}));
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
return res.status(200).json({
apiCaches,
imageCache: {
tmdb: tmdbImageCache,
},
});
});
settingsRoutes.post<{ cacheId: AvailableCacheIds }>(

View File

@@ -497,11 +497,14 @@ router.post(
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = [];
const { hostname, externalHostname } = getSettings().jellyfin;
const jellyfinHost =
let jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const jellyfinUsers = await jellyfinClient.getUsers();