diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts index 17d9ed2270871..c94da0977bbbf 100644 --- a/server/src/cores/system-config.core.ts +++ b/server/src/cores/system-config.core.ts @@ -1,5 +1,6 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; +import AsyncLock from 'async-lock'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { load as loadYaml } from 'js-yaml'; @@ -21,6 +22,7 @@ import { TranscodePolicy, VideoCodec, } from 'src/entities/system-config.entity'; +import { DatabaseLock } from 'src/interfaces/database.interface'; import { QueueName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; @@ -186,7 +188,9 @@ let instance: SystemConfigCore | null; @Injectable() export class SystemConfigCore { - private configCache: SystemConfigEntity[] | null = null; + private readonly asyncLock = new AsyncLock(); + private config: SystemConfig | null = null; + private lastUpdated: number | null = null; public config$ = new Subject(); @@ -268,32 +272,17 @@ export class SystemConfigCore { } public async getConfig(force = false): Promise { - const configFilePath = process.env.IMMICH_CONFIG_FILE; - const config = _.cloneDeep(defaults); - const overrides = configFilePath ? await this.loadFromFile(configFilePath, force) : await this.repository.load(); - - for (const { key, value } of overrides) { - // set via dot notation - _.set(config, key, value); - } - - const errors = await validate(plainToInstance(SystemConfigDto, config)); - if (errors.length > 0) { - this.logger.error('Validation error', errors); - if (configFilePath) { - throw new Error(`Invalid value(s) in file: ${errors}`); - } - } - - if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) { - config.ffmpeg.acceptedVideoCodecs.unshift(config.ffmpeg.targetVideoCodec); - } - - if (!config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)) { - config.ffmpeg.acceptedAudioCodecs.unshift(config.ffmpeg.targetAudioCodec); + if (force || !this.config) { + const lastUpdated = this.lastUpdated; + await this.asyncLock.acquire(DatabaseLock[DatabaseLock.GetSystemConfig], async () => { + if (lastUpdated === this.lastUpdated) { + this.config = await this.buildConfig(); + this.lastUpdated = Date.now(); + } + }); } - return config; + return this.config!; } public async updateConfig(newConfig: SystemConfig): Promise { @@ -345,33 +334,60 @@ export class SystemConfigCore { this.config$.next(newConfig); } - private async loadFromFile(filepath: string, force = false) { - if (force || !this.configCache) { - try { - const file = await this.repository.readFile(filepath); - const config = loadYaml(file.toString()) as any; - const overrides: SystemConfigEntity[] = []; - - for (const key of Object.values(SystemConfigKey)) { - const value = _.get(config, key); - this.unsetDeep(config, key); - if (value !== undefined) { - overrides.push({ key, value }); - } - } + private async buildConfig() { + const config = _.cloneDeep(defaults); + const overrides = process.env.IMMICH_CONFIG_FILE + ? await this.loadFromFile(process.env.IMMICH_CONFIG_FILE) + : await this.repository.load(); - if (!_.isEmpty(config)) { - this.logger.warn(`Unknown keys found: ${JSON.stringify(config, null, 2)}`); - } + for (const { key, value } of overrides) { + // set via dot notation + _.set(config, key, value); + } - this.configCache = overrides; - } catch (error: Error | any) { - this.logger.error(`Unable to load configuration file: ${filepath}`); - throw error; + const errors = await validate(plainToInstance(SystemConfigDto, config)); + if (errors.length > 0) { + if (process.env.IMMICH_CONFIG_FILE) { + throw new Error(`Invalid value(s) in file: ${errors}`); + } else { + this.logger.error('Validation error', errors); } } - return this.configCache; + if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) { + config.ffmpeg.acceptedVideoCodecs.push(config.ffmpeg.targetVideoCodec); + } + + if (!config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)) { + config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec); + } + + return config; + } + + private async loadFromFile(filepath: string) { + try { + const file = await this.repository.readFile(filepath); + const config = loadYaml(file.toString()) as any; + const overrides: SystemConfigEntity[] = []; + + for (const key of Object.values(SystemConfigKey)) { + const value = _.get(config, key); + this.unsetDeep(config, key); + if (value !== undefined) { + overrides.push({ key, value }); + } + } + + if (!_.isEmpty(config)) { + this.logger.warn(`Unknown keys found: ${JSON.stringify(config, null, 2)}`); + } + + return overrides; + } catch (error: Error | any) { + this.logger.error(`Unable to load configuration file: ${filepath}`); + throw error; + } } private unsetDeep(object: object, key: string) { diff --git a/server/src/interfaces/database.interface.ts b/server/src/interfaces/database.interface.ts index 42342eccc3cd3..e57c91e917111 100644 --- a/server/src/interfaces/database.interface.ts +++ b/server/src/interfaces/database.interface.ts @@ -20,6 +20,7 @@ export enum DatabaseLock { StorageTemplateMigration = 420, CLIPDimSize = 512, LibraryWatch = 1337, + GetSystemConfig = 69, } export const extName: Record = { diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 5adeb1c7748fc..c0758eeeaf5a4 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -99,19 +99,22 @@ describe(MetadataService.name, () => { }); describe('init', () => { - beforeEach(async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: true }]); - + it('should pause and resume queue during init', async () => { await sut.init(); + + expect(jobMock.pause).toHaveBeenCalledTimes(1); + expect(metadataMock.init).toHaveBeenCalledTimes(1); + expect(jobMock.resume).toHaveBeenCalledTimes(1); }); it('should return if reverse geocoding is disabled', async () => { configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: false }]); await sut.init(); - expect(jobMock.pause).toHaveBeenCalledTimes(1); - expect(metadataMock.init).toHaveBeenCalledTimes(1); - expect(jobMock.resume).toHaveBeenCalledTimes(1); + + expect(jobMock.pause).not.toHaveBeenCalled(); + expect(metadataMock.init).not.toHaveBeenCalled(); + expect(jobMock.resume).not.toHaveBeenCalled(); }); });