Merge remote-tracking branch 'overseerr/develop' into develop

This commit is contained in:
notfakie
2023-01-27 17:55:55 +13:00
158 changed files with 10835 additions and 4524 deletions

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

@@ -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

@@ -1,7 +1,5 @@
import TheMovieDb from '@server/api/themoviedb';
import { MediaStatus } from '@server/constants/media';
import Media from '@server/entity/Media';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import {
mapCastCredits,
@@ -36,7 +34,6 @@ personRoutes.get('/:id', async (req, res, next) => {
personRoutes.get('/:id/combined_credits', async (req, res, next) => {
const tmdb = new TheMovieDb();
const settings = getSettings();
try {
const combinedCredits = await tmdb.getPersonCombinedCredits({
@@ -44,30 +41,14 @@ personRoutes.get('/:id/combined_credits', async (req, res, next) => {
language: req.locale ?? (req.query.language as string),
});
let castMedia = await Media.getRelatedMedia(
const castMedia = await Media.getRelatedMedia(
combinedCredits.cast.map((result) => result.id)
);
let crewMedia = await Media.getRelatedMedia(
const crewMedia = await Media.getRelatedMedia(
combinedCredits.crew.map((result) => result.id)
);
if (settings.main.hideAvailable) {
castMedia = castMedia.filter(
(media) =>
(media.mediaType === 'movie' || media.mediaType === 'tv') &&
media.status !== MediaStatus.AVAILABLE &&
media.status !== MediaStatus.PARTIALLY_AVAILABLE
);
crewMedia = crewMedia.filter(
(media) =>
(media.mediaType === 'movie' || media.mediaType === 'tv') &&
media.status !== MediaStatus.AVAILABLE &&
media.status !== MediaStatus.PARTIALLY_AVAILABLE
);
}
return res.status(200).json({
cast: combinedCredits.cast
.map((result) =>

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

@@ -23,6 +23,7 @@ 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';
@@ -42,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,