import PlexAPI from '@server/api/plexapi'; import PlexTvAPI from '@server/api/plextv'; import TautulliAPI from '@server/api/tautulli'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; import type { PlexConnection } from '@server/interfaces/api/plexInterfaces'; import type { LogMessage, LogsResultsResponse, SettingsAboutResponse, } from '@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 { plexFullScanner } from '@server/lib/scanners/plex'; import type { JobId, MainSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { appDataPath } from '@server/utils/appDataVolume'; import { getAppVersion } from '@server/utils/appVersion'; import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import fs from 'fs'; import { escapeRegExp, merge, omit, set, sortBy } from 'lodash'; import { rescheduleJob } from 'node-schedule'; import path from 'path'; import semver from 'semver'; import { URL } from 'url'; import notificationRoutes from './notifications'; import radarrRoutes from './radarr'; import sonarrRoutes from './sonarr'; const settingsRoutes = Router(); settingsRoutes.use('/notifications', notificationRoutes); settingsRoutes.use('/radarr', radarrRoutes); settingsRoutes.use('/sonarr', sonarrRoutes); const filteredMainSettings = ( user: User, main: MainSettings ): Partial => { 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('/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); 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[] => { 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)); } } 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;