mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-01 12:18:35 -05:00
* refactor(jellyfinsettings): abstract jellyfin hostname, updated ui to reflect it, better validation This PR refactors and abstracts jellyfin hostname into, jellyfin ip, jellyfin port, jellyfin useSsl, and jellyfin urlBase. This makes it more consistent with how plex settings are stored as well. In addition, this improves validation as validation can be applied seperately to them instead of as one whole regex doing the work to validate the url. UI was updated to reflect this. BREAKING CHANGE: Jellyfin settings now does not include a hostname. Instead it abstracted it to ip, port, useSsl, and urlBase. However, migration of old settings to new settings should work automatically. * refactor: remove console logs and use getHostname and ApiErrorCodes * fix: store req.body jellyfin settings temporarily and store only if valid This should fix the issue where settings are saved even if the url was invalid. Now the settings will only be saved if the url is valid. Sort of like a test connection. * refactor: clean up commented out code * refactor(i18n): extract translation keys * fix(auth): auth failing with jellyfin login is disabled * fix(settings): jellyfin migrations replacing the rest of the settings * fix(settings): jellyfin hostname should be carried out if hostname exists * fix(settings): merging the wrong settings source * refactor(settings): use migrator for dynamic settings migrations * refactor(settingsmigrator): settings migration handler and the migrations * test(cypress): fix cypress tests failing cypress settings were lacking some of the jobs so when the startJobs() is called when the app starts, it was failing to schedule the jobs where their cron timings were not specified in the cypress settings. Therefore, this commit adds those jobs back. In addition, other setting options were added to keep cypress settings consistent with a normal user. * chore(prettierignore): ignore cypress/config/settings.cypress.json as it does not need prettier * chore(prettier): ran formatter on cypress config to fix format check error format check locally passes on this file. However, it fails during the github actions format check. Therefore, json language features formatter was run instead of prettier to see if that fixes the issue. * test(cypress): add only missing jobs to the cypress settings * ci: attempt at trying to get formatter to pass on cypress config json file * refactor: revert the changes brought to try and fix formatter added back the rest of the cypress settings and removed cypress settings from .prettierignore * refactor(settings): better erorr logging when jellyfin connection test fails in settings page
800 lines
23 KiB
TypeScript
800 lines
23 KiB
TypeScript
import JellyfinAPI from '@server/api/jellyfin';
|
|
import PlexAPI from '@server/api/plexapi';
|
|
import PlexTvAPI from '@server/api/plextv';
|
|
import TautulliAPI from '@server/api/tautulli';
|
|
import { ApiErrorCode } from '@server/constants/error';
|
|
import { getRepository } from '@server/datasource';
|
|
import Media from '@server/entity/Media';
|
|
import { MediaRequest } from '@server/entity/MediaRequest';
|
|
import { User } from '@server/entity/User';
|
|
import type { PlexConnection } from '@server/interfaces/api/plexInterfaces';
|
|
import type {
|
|
LogMessage,
|
|
LogsResultsResponse,
|
|
SettingsAboutResponse,
|
|
} from '@server/interfaces/api/settingsInterfaces';
|
|
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 { jellyfinFullScanner } from '@server/lib/scanners/jellyfin';
|
|
import { plexFullScanner } from '@server/lib/scanners/plex';
|
|
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 { ApiError } from '@server/types/error';
|
|
import { appDataPath } from '@server/utils/appDataVolume';
|
|
import { getAppVersion } from '@server/utils/appVersion';
|
|
import { getHostname } from '@server/utils/getHostname';
|
|
import { Router } from 'express';
|
|
import rateLimit from 'express-rate-limit';
|
|
import fs from 'fs';
|
|
import gravatarUrl from 'gravatar-url';
|
|
import { escapeRegExp, merge, omit, set, sortBy } from 'lodash';
|
|
import { rescheduleJob } from 'node-schedule';
|
|
import path from 'path';
|
|
import semver from 'semver';
|
|
import { URL } from 'url';
|
|
import notificationRoutes from './notifications';
|
|
import radarrRoutes from './radarr';
|
|
import sonarrRoutes from './sonarr';
|
|
|
|
const settingsRoutes = Router();
|
|
|
|
settingsRoutes.use('/notifications', notificationRoutes);
|
|
settingsRoutes.use('/radarr', radarrRoutes);
|
|
settingsRoutes.use('/sonarr', sonarrRoutes);
|
|
settingsRoutes.use('/discover', discoverSettingRoutes);
|
|
|
|
const filteredMainSettings = (
|
|
user: User,
|
|
main: MainSettings
|
|
): Partial<MainSettings> => {
|
|
if (!user?.hasPermission(Permission.ADMIN)) {
|
|
return omit(main, 'apiKey');
|
|
}
|
|
|
|
return main;
|
|
};
|
|
|
|
settingsRoutes.get('/main', (req, res, next) => {
|
|
const settings = getSettings();
|
|
|
|
if (!req.user) {
|
|
return next({ status: 400, message: 'User missing from request.' });
|
|
}
|
|
|
|
res.status(200).json(filteredMainSettings(req.user, settings.main));
|
|
});
|
|
|
|
settingsRoutes.post('/main', (req, res) => {
|
|
const settings = getSettings();
|
|
|
|
settings.main = merge(settings.main, req.body);
|
|
settings.save();
|
|
|
|
return res.status(200).json(settings.main);
|
|
});
|
|
|
|
settingsRoutes.post('/main/regenerate', (req, res, next) => {
|
|
const settings = getSettings();
|
|
|
|
const main = settings.regenerateApiKey();
|
|
|
|
if (!req.user) {
|
|
return next({ status: 500, message: 'User missing from request.' });
|
|
}
|
|
|
|
return res.status(200).json(filteredMainSettings(req.user, main));
|
|
});
|
|
|
|
settingsRoutes.get('/plex', (_req, res) => {
|
|
const settings = getSettings();
|
|
|
|
res.status(200).json(settings.plex);
|
|
});
|
|
|
|
settingsRoutes.post('/plex', async (req, res, next) => {
|
|
const userRepository = getRepository(User);
|
|
const settings = getSettings();
|
|
try {
|
|
const admin = await userRepository.findOneOrFail({
|
|
select: { id: true, plexToken: true },
|
|
where: { id: 1 },
|
|
});
|
|
|
|
Object.assign(settings.plex, req.body);
|
|
|
|
const plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
|
|
|
const result = await plexClient.getStatus();
|
|
|
|
if (!result?.MediaContainer?.machineIdentifier) {
|
|
throw new Error('Server not found');
|
|
}
|
|
|
|
settings.plex.machineId = result.MediaContainer.machineIdentifier;
|
|
settings.plex.name = result.MediaContainer.friendlyName;
|
|
|
|
settings.save();
|
|
} catch (e) {
|
|
logger.error('Something went wrong testing Plex connection', {
|
|
label: 'API',
|
|
errorMessage: e.message,
|
|
});
|
|
return next({
|
|
status: 500,
|
|
message: 'Unable to connect to Plex.',
|
|
});
|
|
}
|
|
|
|
return res.status(200).json(settings.plex);
|
|
});
|
|
|
|
settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
|
|
const userRepository = getRepository(User);
|
|
try {
|
|
const admin = await userRepository.findOneOrFail({
|
|
select: { id: true, plexToken: true },
|
|
where: { id: 1 },
|
|
});
|
|
const plexTvClient = admin.plexToken
|
|
? new PlexTvAPI(admin.plexToken)
|
|
: null;
|
|
const devices = (await plexTvClient?.getDevices())?.filter((device) => {
|
|
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) => {
|
|
const plexDeviceSettings = {
|
|
...settings.plex,
|
|
ip: connection.address,
|
|
port: connection.port,
|
|
useSsl: connection.protocol === 'https',
|
|
};
|
|
const plexClient = new PlexAPI({
|
|
plexToken: admin.plexToken,
|
|
plexSettings: plexDeviceSettings,
|
|
timeout: 5000,
|
|
});
|
|
|
|
try {
|
|
await plexClient.getStatus();
|
|
connection.status = 200;
|
|
connection.message = 'OK';
|
|
} catch (e) {
|
|
connection.status = 500;
|
|
connection.message = e.message.split(':')[0];
|
|
}
|
|
})
|
|
);
|
|
})
|
|
);
|
|
}
|
|
return res.status(200).json(devices);
|
|
} catch (e) {
|
|
logger.error('Something went wrong retrieving Plex server list', {
|
|
label: 'API',
|
|
errorMessage: e.message,
|
|
});
|
|
return next({
|
|
status: 500,
|
|
message: 'Unable to retrieve Plex server list.',
|
|
});
|
|
}
|
|
});
|
|
|
|
settingsRoutes.get('/plex/library', async (req, res) => {
|
|
const settings = getSettings();
|
|
|
|
if (req.query.sync) {
|
|
const userRepository = getRepository(User);
|
|
const admin = await userRepository.findOneOrFail({
|
|
select: { id: true, plexToken: true },
|
|
where: { id: 1 },
|
|
});
|
|
const plexapi = new PlexAPI({ plexToken: admin.plexToken });
|
|
|
|
await plexapi.syncLibraries();
|
|
}
|
|
|
|
const enabledLibraries = req.query.enable
|
|
? (req.query.enable as string).split(',')
|
|
: [];
|
|
settings.plex.libraries = settings.plex.libraries.map((library) => ({
|
|
...library,
|
|
enabled: enabledLibraries.includes(library.id),
|
|
}));
|
|
settings.save();
|
|
return res.status(200).json(settings.plex.libraries);
|
|
});
|
|
|
|
settingsRoutes.get('/plex/sync', (_req, res) => {
|
|
return res.status(200).json(plexFullScanner.status());
|
|
});
|
|
|
|
settingsRoutes.post('/plex/sync', (req, res) => {
|
|
if (req.body.cancel) {
|
|
plexFullScanner.cancel();
|
|
} else if (req.body.start) {
|
|
plexFullScanner.run();
|
|
}
|
|
return res.status(200).json(plexFullScanner.status());
|
|
});
|
|
|
|
settingsRoutes.get('/jellyfin', (_req, res) => {
|
|
const settings = getSettings();
|
|
|
|
res.status(200).json(settings.jellyfin);
|
|
});
|
|
|
|
settingsRoutes.post('/jellyfin', async (req, res, next) => {
|
|
const userRepository = getRepository(User);
|
|
const settings = getSettings();
|
|
|
|
try {
|
|
const admin = await userRepository.findOneOrFail({
|
|
where: { id: 1 },
|
|
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
|
|
order: { id: 'ASC' },
|
|
});
|
|
|
|
const tempJellyfinSettings = { ...settings.jellyfin, ...req.body };
|
|
|
|
const jellyfinClient = new JellyfinAPI(
|
|
getHostname(tempJellyfinSettings),
|
|
admin.jellyfinAuthToken ?? '',
|
|
admin.jellyfinDeviceId ?? ''
|
|
);
|
|
|
|
const result = await jellyfinClient.getSystemInfo();
|
|
|
|
if (!result?.Id) {
|
|
throw new ApiError(result?.status, ApiErrorCode.InvalidUrl);
|
|
}
|
|
|
|
Object.assign(settings.jellyfin, req.body);
|
|
settings.jellyfin.serverId = result.Id;
|
|
settings.jellyfin.name = result.ServerName;
|
|
settings.save();
|
|
} catch (e) {
|
|
if (e instanceof ApiError) {
|
|
logger.error('Something went wrong testing Jellyfin connection', {
|
|
label: 'API',
|
|
status: e.statusCode,
|
|
errorMessage: ApiErrorCode.InvalidUrl,
|
|
});
|
|
|
|
return next({
|
|
status: e.statusCode,
|
|
message: ApiErrorCode.InvalidUrl,
|
|
});
|
|
} else {
|
|
logger.error('Something went wrong', {
|
|
label: 'API',
|
|
errorMessage: e.message,
|
|
});
|
|
|
|
return next({
|
|
status: e.statusCode ?? 500,
|
|
message: ApiErrorCode.Unknown,
|
|
});
|
|
}
|
|
}
|
|
|
|
return res.status(200).json(settings.jellyfin);
|
|
});
|
|
|
|
settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
|
const settings = getSettings();
|
|
|
|
if (req.query.sync) {
|
|
const userRepository = getRepository(User);
|
|
const admin = await userRepository.findOneOrFail({
|
|
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
|
|
where: { id: 1 },
|
|
order: { id: 'ASC' },
|
|
});
|
|
const jellyfinClient = new JellyfinAPI(
|
|
getHostname(),
|
|
admin.jellyfinAuthToken ?? '',
|
|
admin.jellyfinDeviceId ?? ''
|
|
);
|
|
|
|
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
|
|
|
const libraries = await jellyfinClient.getLibraries();
|
|
|
|
if (libraries.length === 0) {
|
|
// Check if no libraries are found due to the fallback to user views
|
|
// This only affects LDAP users
|
|
const account = await jellyfinClient.getUser();
|
|
|
|
// Automatic Library grouping is not supported when user views are used to get library
|
|
if (account.Configuration.GroupedFolders.length > 0) {
|
|
return next({
|
|
status: 501,
|
|
message: ApiErrorCode.SyncErrorGroupedFolders,
|
|
});
|
|
}
|
|
|
|
return next({ status: 404, message: ApiErrorCode.SyncErrorNoLibraries });
|
|
}
|
|
|
|
const newLibraries: Library[] = libraries.map((library) => {
|
|
const existing = settings.jellyfin.libraries.find(
|
|
(l) => l.id === library.key && l.name === library.title
|
|
);
|
|
|
|
return {
|
|
id: library.key,
|
|
name: library.title,
|
|
enabled: existing?.enabled ?? false,
|
|
type: library.type,
|
|
};
|
|
});
|
|
|
|
settings.jellyfin.libraries = newLibraries;
|
|
}
|
|
|
|
const enabledLibraries = req.query.enable
|
|
? (req.query.enable as string).split(',')
|
|
: [];
|
|
settings.jellyfin.libraries = settings.jellyfin.libraries.map((library) => ({
|
|
...library,
|
|
enabled: enabledLibraries.includes(library.id),
|
|
}));
|
|
settings.save();
|
|
return res.status(200).json(settings.jellyfin.libraries);
|
|
});
|
|
|
|
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
|
const { externalHostname } = getSettings().jellyfin;
|
|
const jellyfinHost =
|
|
externalHostname && externalHostname.length > 0
|
|
? externalHostname
|
|
: getHostname();
|
|
|
|
const userRepository = getRepository(User);
|
|
const admin = await userRepository.findOneOrFail({
|
|
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
|
|
where: { id: 1 },
|
|
order: { id: 'ASC' },
|
|
});
|
|
const jellyfinClient = new JellyfinAPI(
|
|
admin.jellyfinAuthToken ?? '',
|
|
admin.jellyfinDeviceId ?? ''
|
|
);
|
|
|
|
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
|
const resp = await jellyfinClient.getUsers();
|
|
const users = resp.users.map((user) => ({
|
|
username: user.Name,
|
|
id: user.Id,
|
|
thumb: user.PrimaryImageTag
|
|
? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
|
|
: gravatarUrl(user.Name, { default: 'mm', size: 200 }),
|
|
email: user.Name,
|
|
}));
|
|
|
|
return res.status(200).json(users);
|
|
});
|
|
|
|
settingsRoutes.get('/jellyfin/sync', (_req, res) => {
|
|
return res.status(200).json(jellyfinFullScanner.status());
|
|
});
|
|
|
|
settingsRoutes.post('/jellyfin/sync', (req, res) => {
|
|
if (req.body.cancel) {
|
|
jellyfinFullScanner.cancel();
|
|
} else if (req.body.start) {
|
|
jellyfinFullScanner.run();
|
|
}
|
|
return res.status(200).json(jellyfinFullScanner.status());
|
|
});
|
|
settingsRoutes.get('/tautulli', (_req, res) => {
|
|
const settings = getSettings();
|
|
|
|
res.status(200).json(settings.tautulli);
|
|
});
|
|
|
|
settingsRoutes.post('/tautulli', async (req, res, next) => {
|
|
const settings = getSettings();
|
|
|
|
Object.assign(settings.tautulli, req.body);
|
|
|
|
if (settings.tautulli.hostname) {
|
|
try {
|
|
const tautulliClient = new TautulliAPI(settings.tautulli);
|
|
|
|
const result = await tautulliClient.getInfo();
|
|
|
|
if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) {
|
|
throw new Error('Tautulli version not supported');
|
|
}
|
|
|
|
settings.save();
|
|
} catch (e) {
|
|
logger.error('Something went wrong testing Tautulli connection', {
|
|
label: 'API',
|
|
errorMessage: e.message,
|
|
});
|
|
return next({
|
|
status: 500,
|
|
message: 'Unable to connect to Tautulli.',
|
|
});
|
|
}
|
|
}
|
|
|
|
return res.status(200).json(settings.tautulli);
|
|
});
|
|
|
|
settingsRoutes.get(
|
|
'/plex/users',
|
|
isAuthenticated(Permission.MANAGE_USERS),
|
|
async (req, res, next) => {
|
|
const userRepository = getRepository(User);
|
|
const qb = userRepository.createQueryBuilder('user');
|
|
|
|
try {
|
|
const admin = await userRepository.findOneOrFail({
|
|
select: { id: true, plexToken: true },
|
|
where: { id: 1 },
|
|
});
|
|
const plexApi = new PlexTvAPI(admin.plexToken ?? '');
|
|
const plexUsers = (await plexApi.getUsers()).MediaContainer.User.map(
|
|
(user) => user.$
|
|
).filter((user) => user.email);
|
|
|
|
const unimportedPlexUsers: {
|
|
id: string;
|
|
title: string;
|
|
username: string;
|
|
email: string;
|
|
thumb: string;
|
|
}[] = [];
|
|
|
|
const existingUsers = await qb
|
|
.where('user.plexId IN (:...plexIds)', {
|
|
plexIds: plexUsers.map((plexUser) => plexUser.id),
|
|
})
|
|
.orWhere('user.email IN (:...plexEmails)', {
|
|
plexEmails: plexUsers.map((plexUser) => plexUser.email.toLowerCase()),
|
|
})
|
|
.getMany();
|
|
|
|
await Promise.all(
|
|
plexUsers.map(async (plexUser) => {
|
|
if (
|
|
!existingUsers.find(
|
|
(user) =>
|
|
user.plexId === parseInt(plexUser.id) ||
|
|
user.email === plexUser.email.toLowerCase()
|
|
) &&
|
|
(await plexApi.checkUserAccess(parseInt(plexUser.id)))
|
|
) {
|
|
unimportedPlexUsers.push(plexUser);
|
|
}
|
|
})
|
|
);
|
|
|
|
return res.status(200).json(sortBy(unimportedPlexUsers, 'username'));
|
|
} catch (e) {
|
|
logger.error('Something went wrong getting unimported Plex users', {
|
|
label: 'API',
|
|
errorMessage: e.message,
|
|
});
|
|
next({
|
|
status: 500,
|
|
message: 'Unable to retrieve unimported Plex users.',
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
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;
|
|
const search = (req.query.search as string) ?? '';
|
|
const searchRegexp = new RegExp(escapeRegExp(search), 'i');
|
|
|
|
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/.machinelogs.json`
|
|
: path.join(__dirname, '../../../config/logs/.machinelogs.json');
|
|
const logs: LogMessage[] = [];
|
|
const logMessageProperties = [
|
|
'timestamp',
|
|
'level',
|
|
'label',
|
|
'message',
|
|
'data',
|
|
];
|
|
|
|
const deepValueStrings = (obj: Record<string, unknown>): string[] => {
|
|
const values = [];
|
|
|
|
for (const val of Object.values(obj)) {
|
|
if (typeof val === 'string') {
|
|
values.push(val);
|
|
} else if (typeof val === 'number') {
|
|
values.push(val.toString());
|
|
} else if (val !== null && typeof val === 'object') {
|
|
values.push(...deepValueStrings(val as Record<string, unknown>));
|
|
}
|
|
}
|
|
|
|
return values;
|
|
};
|
|
|
|
try {
|
|
fs.readFileSync(logFile, 'utf-8')
|
|
.split('\n')
|
|
.forEach((line) => {
|
|
if (!line.length) return;
|
|
|
|
const logMessage = JSON.parse(line);
|
|
|
|
if (!filter.includes(logMessage.level)) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!Object.keys(logMessage).every((key) =>
|
|
logMessageProperties.includes(key)
|
|
)
|
|
) {
|
|
Object.keys(logMessage)
|
|
.filter((prop) => !logMessageProperties.includes(prop))
|
|
.forEach((prop) => {
|
|
set(logMessage, `data.${prop}`, logMessage[prop]);
|
|
});
|
|
}
|
|
|
|
if (req.query.search) {
|
|
if (
|
|
// label and data are sometimes undefined
|
|
!searchRegexp.test(logMessage.label ?? '') &&
|
|
!searchRegexp.test(logMessage.message) &&
|
|
!deepValueStrings(logMessage.data ?? {}).some((val) =>
|
|
searchRegexp.test(val)
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
logs.push(logMessage);
|
|
});
|
|
|
|
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 retrieving logs', {
|
|
label: 'Logs',
|
|
errorMessage: error.message,
|
|
});
|
|
return next({
|
|
status: 500,
|
|
message: 'Unable to retrieve logs.',
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
settingsRoutes.get('/jobs', (_req, res) => {
|
|
return res.status(200).json(
|
|
scheduledJobs.map((job) => ({
|
|
id: job.id,
|
|
name: job.name,
|
|
type: job.type,
|
|
interval: job.interval,
|
|
cronSchedule: job.cronSchedule,
|
|
nextExecutionTime: job.job.nextInvocation(),
|
|
running: job.running ? job.running() : false,
|
|
}))
|
|
);
|
|
});
|
|
|
|
settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
|
|
const scheduledJob = scheduledJobs.find((job) => job.id === req.params.jobId);
|
|
|
|
if (!scheduledJob) {
|
|
return next({ status: 404, message: 'Job not found.' });
|
|
}
|
|
|
|
scheduledJob.job.invoke();
|
|
|
|
return res.status(200).json({
|
|
id: scheduledJob.id,
|
|
name: scheduledJob.name,
|
|
type: scheduledJob.type,
|
|
interval: scheduledJob.interval,
|
|
cronSchedule: scheduledJob.cronSchedule,
|
|
nextExecutionTime: scheduledJob.job.nextInvocation(),
|
|
running: scheduledJob.running ? scheduledJob.running() : false,
|
|
});
|
|
});
|
|
|
|
settingsRoutes.post<{ jobId: JobId }>(
|
|
'/jobs/:jobId/cancel',
|
|
(req, res, next) => {
|
|
const scheduledJob = scheduledJobs.find(
|
|
(job) => job.id === req.params.jobId
|
|
);
|
|
|
|
if (!scheduledJob) {
|
|
return next({ status: 404, message: 'Job not found.' });
|
|
}
|
|
|
|
if (scheduledJob.cancelFn) {
|
|
scheduledJob.cancelFn();
|
|
}
|
|
|
|
return res.status(200).json({
|
|
id: scheduledJob.id,
|
|
name: scheduledJob.name,
|
|
type: scheduledJob.type,
|
|
interval: scheduledJob.interval,
|
|
cronSchedule: scheduledJob.cronSchedule,
|
|
nextExecutionTime: scheduledJob.job.nextInvocation(),
|
|
running: scheduledJob.running ? scheduledJob.running() : false,
|
|
});
|
|
}
|
|
);
|
|
|
|
settingsRoutes.post<{ jobId: JobId }>(
|
|
'/jobs/:jobId/schedule',
|
|
(req, res, next) => {
|
|
const scheduledJob = scheduledJobs.find(
|
|
(job) => job.id === req.params.jobId
|
|
);
|
|
|
|
if (!scheduledJob) {
|
|
return next({ status: 404, message: 'Job not found.' });
|
|
}
|
|
|
|
const result = rescheduleJob(scheduledJob.job, req.body.schedule);
|
|
const settings = getSettings();
|
|
|
|
if (result) {
|
|
settings.jobs[scheduledJob.id].schedule = req.body.schedule;
|
|
settings.save();
|
|
|
|
scheduledJob.cronSchedule = req.body.schedule;
|
|
|
|
return res.status(200).json({
|
|
id: scheduledJob.id,
|
|
name: scheduledJob.name,
|
|
type: scheduledJob.type,
|
|
interval: scheduledJob.interval,
|
|
cronSchedule: scheduledJob.cronSchedule,
|
|
nextExecutionTime: scheduledJob.job.nextInvocation(),
|
|
running: scheduledJob.running ? scheduledJob.running() : false,
|
|
});
|
|
} else {
|
|
return next({ status: 400, message: 'Invalid job schedule.' });
|
|
}
|
|
}
|
|
);
|
|
|
|
settingsRoutes.get('/cache', async (_req, res) => {
|
|
const cacheManagerCaches = cacheManager.getAllCaches();
|
|
|
|
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 }>(
|
|
'/cache/:cacheId/flush',
|
|
(req, res, next) => {
|
|
const cache = cacheManager.getCache(req.params.cacheId);
|
|
|
|
if (cache) {
|
|
cache.flush();
|
|
return res.status(204).send();
|
|
}
|
|
|
|
next({ status: 404, message: 'Cache not found.' });
|
|
}
|
|
);
|
|
|
|
settingsRoutes.post(
|
|
'/initialize',
|
|
isAuthenticated(Permission.ADMIN),
|
|
(_req, res) => {
|
|
const settings = getSettings();
|
|
|
|
settings.public.initialized = true;
|
|
settings.save();
|
|
|
|
return res.status(200).json(settings.public);
|
|
}
|
|
);
|
|
|
|
settingsRoutes.get('/about', async (req, res) => {
|
|
const mediaRepository = getRepository(Media);
|
|
const mediaRequestRepository = getRepository(MediaRequest);
|
|
|
|
const totalMediaItems = await mediaRepository.count();
|
|
const totalRequests = await mediaRequestRepository.count();
|
|
|
|
return res.status(200).json({
|
|
version: getAppVersion(),
|
|
totalMediaItems,
|
|
totalRequests,
|
|
tz: process.env.TZ,
|
|
appDataPath: appDataPath(),
|
|
} as SettingsAboutResponse);
|
|
});
|
|
|
|
export default settingsRoutes;
|