diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index f65503477..7b45cdaf7 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -410,7 +410,7 @@ class JellyfinAPI extends ExternalAPI { ).AccessToken; } catch (e) { logger.error( - `Something went wrong while creating an API key the Jellyfin server: ${e.message}`, + `Something went wrong while creating an API key from the Jellyfin server: ${e.message}`, { label: 'Jellyfin API' } ); diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index f6b8f3cb0..10d5d1d2a 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -180,7 +180,7 @@ class PlexAPI { settings.plex.libraries = []; } - settings.save(); + await settings.save(); } public async getLibraryContents( diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index f074872bb..f6049630c 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -129,7 +129,7 @@ class PlexScanner }); settings.plex.libraries = newLibraries; - settings.save(); + await settings.save(); } } else { for (const library of this.libraries) { diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 360aeb29d..d0e6166d0 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -2,7 +2,7 @@ import { MediaServerType } from '@server/constants/server'; import { Permission } from '@server/lib/permissions'; import { runMigrations } from '@server/lib/settings/migrator'; import { randomUUID } from 'crypto'; -import fs from 'fs'; +import fs from 'fs/promises'; import { merge } from 'lodash'; import path from 'path'; import webpush from 'web-push'; @@ -481,10 +481,6 @@ class Settings { } get main(): MainSettings { - if (!this.data.main.apiKey) { - this.data.main.apiKey = this.generateApiKey(); - this.save(); - } return this.data.main; } @@ -586,29 +582,20 @@ class Settings { } get clientId(): string { - if (!this.data.clientId) { - this.data.clientId = randomUUID(); - this.save(); - } - return this.data.clientId; } get vapidPublic(): string { - this.generateVapidKeys(); - return this.data.vapidPublic; } get vapidPrivate(): string { - this.generateVapidKeys(); - return this.data.vapidPrivate; } - public regenerateApiKey(): MainSettings { + public async regenerateApiKey(): Promise { this.main.apiKey = this.generateApiKey(); - this.save(); + await this.save(); return this.main; } @@ -620,15 +607,6 @@ class Settings { } } - private generateVapidKeys(force = false): void { - if (!this.data.vapidPublic || !this.data.vapidPrivate || force) { - const vapidKeys = webpush.generateVAPIDKeys(); - this.data.vapidPrivate = vapidKeys.privateKey; - this.data.vapidPublic = vapidKeys.publicKey; - this.save(); - } - } - /** * Settings Load * @@ -643,30 +621,51 @@ class Settings { return this; } - if (!fs.existsSync(SETTINGS_PATH)) { - this.save(); + let data; + try { + data = await fs.readFile(SETTINGS_PATH, 'utf-8'); + } catch { + await this.save(); } - const data = fs.readFileSync(SETTINGS_PATH, 'utf-8'); if (data) { const parsedJson = JSON.parse(data); - this.data = await runMigrations(parsedJson, SETTINGS_PATH); - - this.data = merge(this.data, parsedJson); - - if (process.env.API_KEY) { - if (this.main.apiKey != process.env.API_KEY) { - this.main.apiKey = process.env.API_KEY; - } - } - - this.save(); + const migratedData = await runMigrations(parsedJson, SETTINGS_PATH); + this.data = merge(this.data, migratedData); } + + // generate keys and ids if it's missing + let change = false; + if (!this.data.main.apiKey) { + this.data.main.apiKey = this.generateApiKey(); + change = true; + } else if (process.env.API_KEY) { + if (this.main.apiKey != process.env.API_KEY) { + this.main.apiKey = process.env.API_KEY; + } + } + if (!this.data.clientId) { + this.data.clientId = randomUUID(); + change = true; + } + if (!this.data.vapidPublic || !this.data.vapidPrivate) { + const vapidKeys = webpush.generateVAPIDKeys(); + this.data.vapidPrivate = vapidKeys.privateKey; + this.data.vapidPublic = vapidKeys.publicKey; + change = true; + } + if (change) { + await this.save(); + } + return this; } - public save(): void { - fs.writeFileSync(SETTINGS_PATH, JSON.stringify(this.data, undefined, ' ')); + public async save(): Promise { + await fs.writeFile( + SETTINGS_PATH, + JSON.stringify(this.data, undefined, ' ') + ); } } diff --git a/server/lib/settings/migrations/0001_migrate_hostname.ts b/server/lib/settings/migrations/0001_migrate_hostname.ts index c514ac2db..ddc8211cf 100644 --- a/server/lib/settings/migrations/0001_migrate_hostname.ts +++ b/server/lib/settings/migrations/0001_migrate_hostname.ts @@ -1,15 +1,14 @@ import type { AllSettings } from '@server/lib/settings'; const migrateHostname = (settings: any): AllSettings => { - const oldJellyfinSettings = settings.jellyfin; - if (oldJellyfinSettings && oldJellyfinSettings.hostname) { - const { hostname } = oldJellyfinSettings; + if (settings.jellyfin?.hostname) { + const { hostname } = settings.jellyfin; const protocolMatch = hostname.match(/^(https?):\/\//i); const useSsl = protocolMatch && protocolMatch[1].toLowerCase() === 'https'; const remainingUrl = hostname.replace(/^(https?):\/\//i, ''); const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/); - delete oldJellyfinSettings.hostname; + delete settings.jellyfin.hostname; if (urlMatch) { const [, ip, , port, urlBase] = urlMatch; settings.jellyfin = { @@ -21,9 +20,7 @@ const migrateHostname = (settings: any): AllSettings => { }; } } - if (settings.jellyfin && settings.jellyfin.hostname) { - delete settings.jellyfin.hostname; - } + return settings; }; diff --git a/server/lib/settings/migrations/0002_migrate_apitokens.ts b/server/lib/settings/migrations/0002_migrate_apitokens.ts index 46340433b..0149c3e37 100644 --- a/server/lib/settings/migrations/0002_migrate_apitokens.ts +++ b/server/lib/settings/migrations/0002_migrate_apitokens.ts @@ -27,8 +27,14 @@ const migrateApiTokens = async (settings: any): Promise => { admin.jellyfinDeviceId ); jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); - const apiKey = await jellyfinClient.createApiToken('Jellyseerr'); - settings.jellyfin.apiKey = apiKey; + try { + const apiKey = await jellyfinClient.createApiToken('Jellyseerr'); + settings.jellyfin.apiKey = apiKey; + } catch { + throw new Error( + "Failed to create Jellyfin API token from admin account. Please check your network configuration or edit your settings.json by adding an 'apiKey' field inside of the 'jellyfin' section to fix this issue." + ); + } } return settings; }; diff --git a/server/lib/settings/migrator.ts b/server/lib/settings/migrator.ts index 6f61e5082..801140000 100644 --- a/server/lib/settings/migrator.ts +++ b/server/lib/settings/migrator.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ import type { AllSettings } from '@server/lib/settings'; import logger from '@server/logger'; import fs from 'fs/promises'; @@ -15,9 +14,9 @@ export const runMigrations = async ( try { // we read old backup and create a backup of currents settings const BACKUP_PATH = SETTINGS_PATH.replace('.json', '.old.json'); - let oldBackup: Buffer | null = null; + let oldBackup: string | null = null; try { - oldBackup = await fs.readFile(BACKUP_PATH); + oldBackup = await fs.readFile(BACKUP_PATH, 'utf-8'); } catch { /* empty */ } @@ -37,7 +36,7 @@ export const runMigrations = async ( const { default: migrationFn } = await import( path.join(migrationsDir, migration) ); - const newSettings = await migrationFn(migrated); + const newSettings = await migrationFn(structuredClone(migrated)); if (JSON.stringify(migrated) !== JSON.stringify(newSettings)) { logger.debug(`Migration '${migration}' has been applied.`, { label: 'Settings Migrator', @@ -45,10 +44,20 @@ export const runMigrations = async ( } migrated = newSettings; } catch (e) { - logger.error(`Error while running migration '${migration}'`, { - label: 'Settings Migrator', - }); - throw e; + // we stop jellyseerr if the migration failed + logger.error( + `Error while running migration '${migration}': ${e.message}`, + { + label: 'Settings Migrator', + } + ); + logger.error( + 'A common cause for this error is a permission issue with your configuration folder, a network issue or a corrupted database.', + { + label: 'Settings Migrator', + } + ); + process.exit(); } } @@ -72,22 +81,18 @@ export const runMigrations = async ( await fs.writeFile(BACKUP_PATH, oldBackup.toString()); } } catch (e) { + // we stop jellyseerr if the migration failed logger.error( `Something went wrong while running settings migrations: ${e.message}`, - { label: 'Settings Migrator' } + { + label: 'Settings Migrator', + } ); - // we stop jellyseerr if the migration failed - console.log( - '====================================================================' - ); - console.log( - ' SOMETHING WENT WRONG WHILE RUNNING SETTINGS MIGRATIONS ' - ); - console.log( - ' Please check that your configuration folder is properly set up ' - ); - console.log( - '====================================================================' + logger.error( + 'A common cause for this issue is a permission error of your configuration folder.', + { + label: 'Settings Migrator', + } ); process.exit(); } diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 70e674f97..d38ae2211 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -87,7 +87,7 @@ authRoutes.post('/plex', async (req, res, next) => { }); settings.main.mediaServerType = MediaServerType.PLEX; - settings.save(); + await settings.save(); startJobs(); await userRepository.save(user); @@ -366,7 +366,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { settings.jellyfin.urlBase = body.urlBase ?? ''; settings.jellyfin.useSsl = body.useSsl ?? false; settings.jellyfin.apiKey = apiKey; - settings.save(); + await settings.save(); startJobs(); await userRepository.save(user); diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index c5a070d2d..bc8c5ef7c 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -69,19 +69,19 @@ settingsRoutes.get('/main', (req, res, next) => { res.status(200).json(filteredMainSettings(req.user, settings.main)); }); -settingsRoutes.post('/main', (req, res) => { +settingsRoutes.post('/main', async (req, res) => { const settings = getSettings(); settings.main = merge(settings.main, req.body); - settings.save(); + await settings.save(); return res.status(200).json(settings.main); }); -settingsRoutes.post('/main/regenerate', (req, res, next) => { +settingsRoutes.post('/main/regenerate', async (req, res, next) => { const settings = getSettings(); - const main = settings.regenerateApiKey(); + const main = await settings.regenerateApiKey(); if (!req.user) { return next({ status: 500, message: 'User missing from request.' }); @@ -118,7 +118,7 @@ settingsRoutes.post('/plex', async (req, res, next) => { settings.plex.machineId = result.MediaContainer.machineIdentifier; settings.plex.name = result.MediaContainer.friendlyName; - settings.save(); + await settings.save(); } catch (e) { logger.error('Something went wrong testing Plex connection', { label: 'API', @@ -231,7 +231,7 @@ settingsRoutes.get('/plex/library', async (req, res) => { ...library, enabled: enabledLibraries.includes(library.id), })); - settings.save(); + await settings.save(); return res.status(200).json(settings.plex.libraries); }); @@ -282,7 +282,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => { Object.assign(settings.jellyfin, req.body); settings.jellyfin.serverId = result.Id; settings.jellyfin.name = result.ServerName; - settings.save(); + await settings.save(); } catch (e) { if (e instanceof ApiError) { logger.error('Something went wrong testing Jellyfin connection', { @@ -370,7 +370,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => { ...library, enabled: enabledLibraries.includes(library.id), })); - settings.save(); + await settings.save(); return res.status(200).json(settings.jellyfin.libraries); }); @@ -434,7 +434,7 @@ settingsRoutes.post('/tautulli', async (req, res, next) => { throw new Error('Tautulli version not supported'); } - settings.save(); + await settings.save(); } catch (e) { logger.error('Something went wrong testing Tautulli connection', { label: 'API', @@ -695,7 +695,7 @@ settingsRoutes.post<{ jobId: JobId }>( settingsRoutes.post<{ jobId: JobId }>( '/jobs/:jobId/schedule', - (req, res, next) => { + async (req, res, next) => { const scheduledJob = scheduledJobs.find( (job) => job.id === req.params.jobId ); @@ -709,7 +709,7 @@ settingsRoutes.post<{ jobId: JobId }>( if (result) { settings.jobs[scheduledJob.id].schedule = req.body.schedule; - settings.save(); + await settings.save(); scheduledJob.cronSchedule = req.body.schedule; @@ -766,11 +766,11 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>( settingsRoutes.post( '/initialize', isAuthenticated(Permission.ADMIN), - (_req, res) => { + async (_req, res) => { const settings = getSettings(); settings.public.initialized = true; - settings.save(); + await settings.save(); return res.status(200).json(settings.public); } diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts index be2fd89a8..5b2e1715b 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -31,11 +31,11 @@ notificationRoutes.get('/discord', (_req, res) => { res.status(200).json(settings.notifications.agents.discord); }); -notificationRoutes.post('/discord', (req, res) => { +notificationRoutes.post('/discord', async (req, res) => { const settings = getSettings(); settings.notifications.agents.discord = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.discord); }); @@ -65,11 +65,11 @@ notificationRoutes.get('/slack', (_req, res) => { res.status(200).json(settings.notifications.agents.slack); }); -notificationRoutes.post('/slack', (req, res) => { +notificationRoutes.post('/slack', async (req, res) => { const settings = getSettings(); settings.notifications.agents.slack = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.slack); }); @@ -99,11 +99,11 @@ notificationRoutes.get('/telegram', (_req, res) => { res.status(200).json(settings.notifications.agents.telegram); }); -notificationRoutes.post('/telegram', (req, res) => { +notificationRoutes.post('/telegram', async (req, res) => { const settings = getSettings(); settings.notifications.agents.telegram = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.telegram); }); @@ -133,11 +133,11 @@ notificationRoutes.get('/pushbullet', (_req, res) => { res.status(200).json(settings.notifications.agents.pushbullet); }); -notificationRoutes.post('/pushbullet', (req, res) => { +notificationRoutes.post('/pushbullet', async (req, res) => { const settings = getSettings(); settings.notifications.agents.pushbullet = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.pushbullet); }); @@ -167,11 +167,11 @@ notificationRoutes.get('/pushover', (_req, res) => { res.status(200).json(settings.notifications.agents.pushover); }); -notificationRoutes.post('/pushover', (req, res) => { +notificationRoutes.post('/pushover', async (req, res) => { const settings = getSettings(); settings.notifications.agents.pushover = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.pushover); }); @@ -201,11 +201,11 @@ notificationRoutes.get('/email', (_req, res) => { res.status(200).json(settings.notifications.agents.email); }); -notificationRoutes.post('/email', (req, res) => { +notificationRoutes.post('/email', async (req, res) => { const settings = getSettings(); settings.notifications.agents.email = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.email); }); @@ -235,11 +235,11 @@ notificationRoutes.get('/webpush', (_req, res) => { res.status(200).json(settings.notifications.agents.webpush); }); -notificationRoutes.post('/webpush', (req, res) => { +notificationRoutes.post('/webpush', async (req, res) => { const settings = getSettings(); settings.notifications.agents.webpush = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.webpush); }); @@ -284,7 +284,7 @@ notificationRoutes.get('/webhook', (_req, res) => { res.status(200).json(response); }); -notificationRoutes.post('/webhook', (req, res, next) => { +notificationRoutes.post('/webhook', async (req, res, next) => { const settings = getSettings(); try { JSON.parse(req.body.options.jsonPayload); @@ -300,7 +300,7 @@ notificationRoutes.post('/webhook', (req, res, next) => { authHeader: req.body.options.authHeader, }, }; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.webhook); } catch (e) { @@ -351,11 +351,11 @@ notificationRoutes.get('/lunasea', (_req, res) => { res.status(200).json(settings.notifications.agents.lunasea); }); -notificationRoutes.post('/lunasea', (req, res) => { +notificationRoutes.post('/lunasea', async (req, res) => { const settings = getSettings(); settings.notifications.agents.lunasea = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.lunasea); }); @@ -385,11 +385,11 @@ notificationRoutes.get('/gotify', (_req, res) => { res.status(200).json(settings.notifications.agents.gotify); }); -notificationRoutes.post('/gotify', (req, res) => { +notificationRoutes.post('/gotify', async (req, res) => { const settings = getSettings(); settings.notifications.agents.gotify = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.gotify); }); diff --git a/server/routes/settings/radarr.ts b/server/routes/settings/radarr.ts index c2b0a6f52..efa586658 100644 --- a/server/routes/settings/radarr.ts +++ b/server/routes/settings/radarr.ts @@ -12,7 +12,7 @@ radarrRoutes.get('/', (_req, res) => { res.status(200).json(settings.radarr); }); -radarrRoutes.post('/', (req, res) => { +radarrRoutes.post('/', async (req, res) => { const settings = getSettings(); const newRadarr = req.body as RadarrSettings; @@ -31,7 +31,7 @@ radarrRoutes.post('/', (req, res) => { } settings.radarr = [...settings.radarr, newRadarr]; - settings.save(); + await settings.save(); return res.status(201).json(newRadarr); }); @@ -76,7 +76,7 @@ radarrRoutes.post< radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>( '/:id', - (req, res, next) => { + async (req, res, next) => { const settings = getSettings(); const radarrIndex = settings.radarr.findIndex( @@ -102,7 +102,7 @@ radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>( ...req.body, id: Number(req.params.id), } as RadarrSettings; - settings.save(); + await settings.save(); return res.status(200).json(settings.radarr[radarrIndex]); } @@ -134,7 +134,7 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => { ); }); -radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => { +radarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => { const settings = getSettings(); const radarrIndex = settings.radarr.findIndex( @@ -146,7 +146,7 @@ radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => { } const removed = settings.radarr.splice(radarrIndex, 1); - settings.save(); + await settings.save(); return res.status(200).json(removed[0]); }); diff --git a/server/routes/settings/sonarr.ts b/server/routes/settings/sonarr.ts index 8c74fa20a..84bf4d793 100644 --- a/server/routes/settings/sonarr.ts +++ b/server/routes/settings/sonarr.ts @@ -12,7 +12,7 @@ sonarrRoutes.get('/', (_req, res) => { res.status(200).json(settings.sonarr); }); -sonarrRoutes.post('/', (req, res) => { +sonarrRoutes.post('/', async (req, res) => { const settings = getSettings(); const newSonarr = req.body as SonarrSettings; @@ -31,7 +31,7 @@ sonarrRoutes.post('/', (req, res) => { } settings.sonarr = [...settings.sonarr, newSonarr]; - settings.save(); + await settings.save(); return res.status(201).json(newSonarr); }); @@ -73,7 +73,7 @@ sonarrRoutes.post('/test', async (req, res, next) => { } }); -sonarrRoutes.put<{ id: string }>('/:id', (req, res) => { +sonarrRoutes.put<{ id: string }>('/:id', async (req, res) => { const settings = getSettings(); const sonarrIndex = settings.sonarr.findIndex( @@ -101,12 +101,12 @@ sonarrRoutes.put<{ id: string }>('/:id', (req, res) => { ...req.body, id: Number(req.params.id), } as SonarrSettings; - settings.save(); + await settings.save(); return res.status(200).json(settings.sonarr[sonarrIndex]); }); -sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => { +sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => { const settings = getSettings(); const sonarrIndex = settings.sonarr.findIndex( @@ -120,7 +120,7 @@ sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => { } const removed = settings.sonarr.splice(sonarrIndex, 1); - settings.save(); + await settings.save(); return res.status(200).json(removed[0]); });