From 984aa8fb414603b72bacb3b9714c025b7611b80f Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 15 May 2024 18:58:23 -0400 Subject: [PATCH] refactor(server): system config (#9517) --- docs/docs/guides/database-queries.md | 2 +- e2e/src/utils.ts | 1 - server/src/cores/storage.core.ts | 10 +- server/src/cores/system-config.core.ts | 98 ++---- server/src/entities/index.ts | 2 - server/src/entities/system-config.entity.ts | 145 -------- server/src/entities/system-metadata.entity.ts | 15 +- .../src/interfaces/system-config.interface.ts | 11 - .../interfaces/system-metadata.interface.ts | 2 + .../1715787369686-RemoveSystemConfigTable.ts | 31 ++ .../src/queries/system.config.repository.sql | 13 - server/src/repositories/index.ts | 3 - .../repositories/system-config.repository.ts | 50 --- .../system-metadata.repository.ts | 19 + server/src/services/asset.service.spec.ts | 10 +- server/src/services/asset.service.ts | 6 +- server/src/services/auth.service.spec.ts | 40 +-- server/src/services/auth.service.ts | 6 +- server/src/services/job.service.spec.ts | 14 +- server/src/services/job.service.ts | 6 +- server/src/services/library.service.spec.ts | 38 +- server/src/services/library.service.ts | 6 +- server/src/services/media.service.spec.ts | 332 +++++++----------- server/src/services/media.service.ts | 9 +- server/src/services/metadata.service.spec.ts | 15 +- server/src/services/metadata.service.ts | 8 +- server/src/services/notification.service.ts | 6 +- server/src/services/person.service.spec.ts | 44 +-- server/src/services/person.service.ts | 8 +- server/src/services/search.service.spec.ts | 10 +- server/src/services/search.service.ts | 6 +- .../src/services/server-info.service.spec.ts | 22 +- server/src/services/server-info.service.ts | 4 +- .../src/services/smart-info.service.spec.ts | 16 +- server/src/services/smart-info.service.ts | 6 +- .../services/storage-template.service.spec.ts | 25 +- .../src/services/storage-template.service.ts | 8 +- .../services/system-config.service.spec.ts | 76 ++-- server/src/services/system-config.service.ts | 4 +- server/src/services/user.service.spec.ts | 12 +- server/src/services/user.service.ts | 6 +- server/src/utils/misc.spec.ts | 52 +++ server/src/utils/misc.ts | 41 +++ server/test/fixtures/system-config.stub.ts | 105 ++++-- .../system-config.repository.mock.ts | 17 - .../system-metadata.repository.mock.ts | 9 +- 46 files changed, 599 insertions(+), 770 deletions(-) delete mode 100644 server/src/entities/system-config.entity.ts delete mode 100644 server/src/interfaces/system-config.interface.ts create mode 100644 server/src/migrations/1715787369686-RemoveSystemConfigTable.ts delete mode 100644 server/src/queries/system.config.repository.sql delete mode 100644 server/src/repositories/system-config.repository.ts create mode 100644 server/src/utils/misc.spec.ts delete mode 100644 server/test/repositories/system-config.repository.mock.ts diff --git a/docs/docs/guides/database-queries.md b/docs/docs/guides/database-queries.md index e20321e052..8baf9cf825 100644 --- a/docs/docs/guides/database-queries.md +++ b/docs/docs/guides/database-queries.md @@ -96,7 +96,7 @@ SELECT * FROM "users"; ## System Config ```sql title="Custom settings" -SELECT "key", "value" FROM "system_config"; +SELECT "key", "value" FROM "system_metadata" WHERE "key" = 'system-config'; ``` (Only used when not using the [config file](/docs/install/config-file)) diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 908c87faed..12dbac2f0a 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -145,7 +145,6 @@ export const utils = { 'sessions', 'users', 'system_metadata', - 'system_config', ]; const sql: string[] = []; diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index eace24d5be..5861c31ff8 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -12,7 +12,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; export enum StorageFolder { ENCODED_VIDEO = 'encoded-video', @@ -49,10 +49,10 @@ export class StorageCore { private moveRepository: IMoveRepository, private personRepository: IPersonRepository, private storageRepository: IStorageRepository, - systemConfigRepository: ISystemConfigRepository, + systemMetadataRepository: ISystemMetadataRepository, private logger: ILoggerRepository, ) { - this.configCore = SystemConfigCore.create(systemConfigRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } static create( @@ -61,7 +61,7 @@ export class StorageCore { moveRepository: IMoveRepository, personRepository: IPersonRepository, storageRepository: IStorageRepository, - systemConfigRepository: ISystemConfigRepository, + systemMetadataRepository: ISystemMetadataRepository, logger: ILoggerRepository, ) { if (!instance) { @@ -71,7 +71,7 @@ export class StorageCore { moveRepository, personRepository, storageRepository, - systemConfigRepository, + systemMetadataRepository, logger, ); } diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts index e5fda4d376..97d5fd82c6 100644 --- a/server/src/cores/system-config.core.ts +++ b/server/src/cores/system-config.core.ts @@ -7,10 +7,12 @@ import * as _ from 'lodash'; import { Subject } from 'rxjs'; import { SystemConfig, defaults } from 'src/config'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; -import { SystemConfigEntity, SystemConfigKey, SystemConfigValue } from 'src/entities/system-config.entity'; +import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; import { DatabaseLock } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { getKeysDeep, unsetDeep } from 'src/utils/misc'; +import { DeepPartial } from 'typeorm'; export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise; @@ -25,11 +27,11 @@ export class SystemConfigCore { config$ = new Subject(); private constructor( - private repository: ISystemConfigRepository, + private repository: ISystemMetadataRepository, private logger: ILoggerRepository, ) {} - static create(repository: ISystemConfigRepository, logger: ILoggerRepository) { + static create(repository: ISystemMetadataRepository, logger: ILoggerRepository) { if (!instance) { instance = new SystemConfigCore(repository, logger); } @@ -55,41 +57,25 @@ export class SystemConfigCore { } async updateConfig(newConfig: SystemConfig): Promise { - const updates: SystemConfigEntity[] = []; - const deletes: SystemConfigEntity[] = []; + // get the difference between the new config and the default config + const partialConfig: DeepPartial = {}; + for (const property of getKeysDeep(defaults)) { + const newValue = _.get(newConfig, property); + const isEmpty = newValue === undefined || newValue === null || newValue === ''; + const defaultValue = _.get(defaults, property); + const isEqual = newValue === defaultValue || _.isEqual(newValue, defaultValue); - for (const key of Object.values(SystemConfigKey)) { - // get via dot notation - const item = { key, value: _.get(newConfig, key) as SystemConfigValue }; - const defaultValue = _.get(defaults, key); - const isMissing = !_.has(newConfig, key); - - if ( - isMissing || - item.value === null || - item.value === '' || - item.value === defaultValue || - _.isEqual(item.value, defaultValue) - ) { - deletes.push(item); + if (isEmpty || isEqual) { continue; } - updates.push(item); + _.set(partialConfig, property, newValue); } - if (updates.length > 0) { - await this.repository.saveAll(updates); - } - - if (deletes.length > 0) { - await this.repository.deleteKeys(deletes.map((item) => item.key)); - } + await this.repository.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); const config = await this.getConfig(true); - this.config$.next(config); - return config; } @@ -103,16 +89,28 @@ export class SystemConfigCore { } private async buildConfig() { - const config = _.cloneDeep(defaults); - const overrides = this.isUsingConfigFile() + // load partial + const partial = this.isUsingConfigFile() ? await this.loadFromFile(process.env.IMMICH_CONFIG_FILE as string) - : await this.repository.load(); + : await this.repository.get(SystemMetadataKey.SYSTEM_CONFIG); - for (const { key, value } of overrides) { - // set via dot notation - _.set(config, key, value); + // merge with defaults + const config = _.cloneDeep(defaults); + for (const property of getKeysDeep(partial)) { + _.set(config, property, _.get(partial, property)); } + // check for extra properties + const unknownKeys = _.cloneDeep(config); + for (const property of getKeysDeep(defaults)) { + unsetDeep(unknownKeys, property); + } + + if (!_.isEmpty(unknownKeys)) { + this.logger.warn(`Unknown keys found: ${JSON.stringify(unknownKeys, null, 2)}`); + } + + // validate full config const errors = await validate(plainToInstance(SystemConfigDto, config)); if (errors.length > 0) { if (this.isUsingConfigFile()) { @@ -136,36 +134,10 @@ export class SystemConfigCore { 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; + return loadYaml(file.toString()) as unknown; } catch (error: Error | any) { this.logger.error(`Unable to load configuration file: ${filepath}`); throw error; } } - - private unsetDeep(object: object, key: string) { - _.unset(object, key); - const path = key.split('.'); - while (path.pop()) { - if (!_.isEmpty(_.get(object, path))) { - return; - } - _.unset(object, path); - } - } } diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 0862dd48a2..abe67efdd5 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -18,7 +18,6 @@ import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { SmartSearchEntity } from 'src/entities/smart-search.entity'; -import { SystemConfigEntity } from 'src/entities/system-config.entity'; import { SystemMetadataEntity } from 'src/entities/system-metadata.entity'; import { TagEntity } from 'src/entities/tag.entity'; import { UserEntity } from 'src/entities/user.entity'; @@ -42,7 +41,6 @@ export const entities = [ SharedLinkEntity, SmartInfoEntity, SmartSearchEntity, - SystemConfigEntity, SystemMetadataEntity, TagEntity, UserEntity, diff --git a/server/src/entities/system-config.entity.ts b/server/src/entities/system-config.entity.ts deleted file mode 100644 index 64342cc195..0000000000 --- a/server/src/entities/system-config.entity.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { SystemConfig } from 'src/config'; -import { Column, Entity, PrimaryColumn } from 'typeorm'; - -export type SystemConfigValue = string | string[] | number | boolean; - -// https://stackoverflow.com/a/47058976 -// https://stackoverflow.com/a/70692231 -type PathsToStringProps = T extends SystemConfigValue - ? [] - : { - [K in keyof T]: [K, ...PathsToStringProps]; - }[keyof T]; - -type Join = T extends [] - ? never - : T extends [infer F] - ? F - : T extends [infer F, ...infer R] - ? F extends string - ? `${F}${D}${Join, D>}` - : never - : string; - -// dot notation matches path in `SystemConfig` -// TODO: migrate to key value per section -export const SystemConfigKey = { - FFMPEG_CRF: 'ffmpeg.crf', - FFMPEG_THREADS: 'ffmpeg.threads', - FFMPEG_PRESET: 'ffmpeg.preset', - FFMPEG_TARGET_VIDEO_CODEC: 'ffmpeg.targetVideoCodec', - FFMPEG_ACCEPTED_VIDEO_CODECS: 'ffmpeg.acceptedVideoCodecs', - FFMPEG_TARGET_AUDIO_CODEC: 'ffmpeg.targetAudioCodec', - FFMPEG_ACCEPTED_AUDIO_CODECS: 'ffmpeg.acceptedAudioCodecs', - FFMPEG_TARGET_RESOLUTION: 'ffmpeg.targetResolution', - FFMPEG_MAX_BITRATE: 'ffmpeg.maxBitrate', - FFMPEG_BFRAMES: 'ffmpeg.bframes', - FFMPEG_REFS: 'ffmpeg.refs', - FFMPEG_GOP_SIZE: 'ffmpeg.gopSize', - FFMPEG_NPL: 'ffmpeg.npl', - FFMPEG_TEMPORAL_AQ: 'ffmpeg.temporalAQ', - FFMPEG_CQ_MODE: 'ffmpeg.cqMode', - FFMPEG_TWO_PASS: 'ffmpeg.twoPass', - FFMPEG_PREFERRED_HW_DEVICE: 'ffmpeg.preferredHwDevice', - FFMPEG_TRANSCODE: 'ffmpeg.transcode', - FFMPEG_ACCEL: 'ffmpeg.accel', - FFMPEG_TONEMAP: 'ffmpeg.tonemap', - - JOB_THUMBNAIL_GENERATION_CONCURRENCY: 'job.thumbnailGeneration.concurrency', - JOB_METADATA_EXTRACTION_CONCURRENCY: 'job.metadataExtraction.concurrency', - JOB_VIDEO_CONVERSION_CONCURRENCY: 'job.videoConversion.concurrency', - JOB_FACE_DETECTION_CONCURRENCY: 'job.faceDetection.concurrency', - JOB_CLIP_ENCODING_CONCURRENCY: 'job.smartSearch.concurrency', - JOB_BACKGROUND_TASK_CONCURRENCY: 'job.backgroundTask.concurrency', - JOB_SEARCH_CONCURRENCY: 'job.search.concurrency', - JOB_SIDECAR_CONCURRENCY: 'job.sidecar.concurrency', - JOB_LIBRARY_CONCURRENCY: 'job.library.concurrency', - JOB_MIGRATION_CONCURRENCY: 'job.migration.concurrency', - - LIBRARY_SCAN_ENABLED: 'library.scan.enabled', - LIBRARY_SCAN_CRON_EXPRESSION: 'library.scan.cronExpression', - - LIBRARY_WATCH_ENABLED: 'library.watch.enabled', - - LOGGING_ENABLED: 'logging.enabled', - LOGGING_LEVEL: 'logging.level', - - MACHINE_LEARNING_ENABLED: 'machineLearning.enabled', - MACHINE_LEARNING_URL: 'machineLearning.url', - - MACHINE_LEARNING_CLIP_ENABLED: 'machineLearning.clip.enabled', - MACHINE_LEARNING_CLIP_MODEL_NAME: 'machineLearning.clip.modelName', - - MACHINE_LEARNING_FACIAL_RECOGNITION_ENABLED: 'machineLearning.facialRecognition.enabled', - MACHINE_LEARNING_FACIAL_RECOGNITION_MODEL_NAME: 'machineLearning.facialRecognition.modelName', - MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_SCORE: 'machineLearning.facialRecognition.minScore', - MACHINE_LEARNING_FACIAL_RECOGNITION_MAX_DISTANCE: 'machineLearning.facialRecognition.maxDistance', - MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES: 'machineLearning.facialRecognition.minFaces', - - MAP_ENABLED: 'map.enabled', - MAP_LIGHT_STYLE: 'map.lightStyle', - MAP_DARK_STYLE: 'map.darkStyle', - - NOTIFICATIONS_SMTP_ENABLED: 'notifications.smtp.enabled', - NOTIFICATIONS_SMTP_FROM: 'notifications.smtp.from', - NOTIFICATIONS_SMTP_REPLY_TO: 'notifications.smtp.replyTo', - NOTIFICATIONS_SMTP_TRANSPORT_IGNORE_CERT: 'notifications.smtp.transport.ignoreCert', - NOTIFICATIONS_SMTP_TRANSPORT_HOST: 'notifications.smtp.transport.host', - NOTIFICATIONS_SMTP_TRANSPORT_PORT: 'notifications.smtp.transport.port', - NOTIFICATIONS_SMTP_TRANSPORT_USERNAME: 'notifications.smtp.transport.username', - NOTIFICATIONS_SMTP_TRANSPORT_PASSWORD: 'notifications.smtp.transport.password', - - REVERSE_GEOCODING_ENABLED: 'reverseGeocoding.enabled', - - NEW_VERSION_CHECK_ENABLED: 'newVersionCheck.enabled', - - OAUTH_AUTO_LAUNCH: 'oauth.autoLaunch', - OAUTH_AUTO_REGISTER: 'oauth.autoRegister', - OAUTH_BUTTON_TEXT: 'oauth.buttonText', - OAUTH_CLIENT_ID: 'oauth.clientId', - OAUTH_CLIENT_SECRET: 'oauth.clientSecret', - OAUTH_DEFAULT_STORAGE_QUOTA: 'oauth.defaultStorageQuota', - OAUTH_ENABLED: 'oauth.enabled', - OAUTH_ISSUER_URL: 'oauth.issuerUrl', - OAUTH_MOBILE_OVERRIDE_ENABLED: 'oauth.mobileOverrideEnabled', - OAUTH_MOBILE_REDIRECT_URI: 'oauth.mobileRedirectUri', - OAUTH_SCOPE: 'oauth.scope', - OAUTH_SIGNING_ALGORITHM: 'oauth.signingAlgorithm', - OAUTH_STORAGE_LABEL_CLAIM: 'oauth.storageLabelClaim', - OAUTH_STORAGE_QUOTA_CLAIM: 'oauth.storageQuotaClaim', - - PASSWORD_LOGIN_ENABLED: 'passwordLogin.enabled', - - SERVER_EXTERNAL_DOMAIN: 'server.externalDomain', - SERVER_LOGIN_PAGE_MESSAGE: 'server.loginPageMessage', - - STORAGE_TEMPLATE_ENABLED: 'storageTemplate.enabled', - STORAGE_TEMPLATE_HASH_VERIFICATION_ENABLED: 'storageTemplate.hashVerificationEnabled', - STORAGE_TEMPLATE: 'storageTemplate.template', - - IMAGE_THUMBNAIL_FORMAT: 'image.thumbnailFormat', - IMAGE_THUMBNAIL_SIZE: 'image.thumbnailSize', - IMAGE_PREVIEW_FORMAT: 'image.previewFormat', - IMAGE_PREVIEW_SIZE: 'image.previewSize', - IMAGE_QUALITY: 'image.quality', - IMAGE_COLORSPACE: 'image.colorspace', - IMAGE_EXTRACT_EMBEDDED: 'image.extractEmbedded', - - TRASH_ENABLED: 'trash.enabled', - TRASH_DAYS: 'trash.days', - - THEME_CUSTOM_CSS: 'theme.customCss', - - USER_DELETE_DELAY: 'user.deleteDelay', -} as const satisfies Record, '.'>>; - -export type SystemConfigKeyPaths = (typeof SystemConfigKey)[keyof typeof SystemConfigKey]; - -@Entity('system_config') -export class SystemConfigEntity { - @PrimaryColumn({ type: 'varchar' }) - key!: SystemConfigKeyPaths; - - @Column({ type: 'varchar', nullable: true, transformer: { to: JSON.stringify, from: JSON.parse } }) - value!: T; -} diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts index 24e9f83c74..b702d2606d 100644 --- a/server/src/entities/system-metadata.entity.ts +++ b/server/src/entities/system-metadata.entity.ts @@ -1,20 +1,23 @@ -import { Column, Entity, PrimaryColumn } from 'typeorm'; +import { SystemConfig } from 'src/config'; +import { Column, DeepPartial, Entity, PrimaryColumn } from 'typeorm'; @Entity('system_metadata') -export class SystemMetadataEntity { - @PrimaryColumn() - key!: string; +export class SystemMetadataEntity { + @PrimaryColumn({ type: 'varchar' }) + key!: T; @Column({ type: 'jsonb', default: '{}', transformer: { to: JSON.stringify, from: JSON.parse } }) - value!: { [key: string]: unknown }; + value!: SystemMetadata[T]; } export enum SystemMetadataKey { REVERSE_GEOCODING_STATE = 'reverse-geocoding-state', ADMIN_ONBOARDING = 'admin-onboarding', + SYSTEM_CONFIG = 'system-config', } -export interface SystemMetadata extends Record { +export interface SystemMetadata extends Record> { [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string }; [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; + [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial; } diff --git a/server/src/interfaces/system-config.interface.ts b/server/src/interfaces/system-config.interface.ts deleted file mode 100644 index f591a6671d..0000000000 --- a/server/src/interfaces/system-config.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { SystemConfigEntity } from 'src/entities/system-config.entity'; - -export const ISystemConfigRepository = 'ISystemConfigRepository'; - -export interface ISystemConfigRepository { - fetchStyle(url: string): Promise; - load(): Promise; - readFile(filename: string): Promise; - saveAll(items: SystemConfigEntity[]): Promise; - deleteKeys(keys: string[]): Promise; -} diff --git a/server/src/interfaces/system-metadata.interface.ts b/server/src/interfaces/system-metadata.interface.ts index cbbce44e26..9bb9fd5077 100644 --- a/server/src/interfaces/system-metadata.interface.ts +++ b/server/src/interfaces/system-metadata.interface.ts @@ -5,4 +5,6 @@ export const ISystemMetadataRepository = 'ISystemMetadataRepository'; export interface ISystemMetadataRepository { get(key: T): Promise; set(key: T, value: SystemMetadata[T]): Promise; + fetchStyle(url: string): Promise; + readFile(filename: string): Promise; } diff --git a/server/src/migrations/1715787369686-RemoveSystemConfigTable.ts b/server/src/migrations/1715787369686-RemoveSystemConfigTable.ts new file mode 100644 index 0000000000..c16eec7160 --- /dev/null +++ b/server/src/migrations/1715787369686-RemoveSystemConfigTable.ts @@ -0,0 +1,31 @@ +import _ from 'lodash'; +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveSystemConfigTable1715787369686 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const overrides = await queryRunner.query('SELECT "key", "value" FROM "system_config"'); + if (overrides.length === 0) { + return; + } + + const config = {}; + for (const { key, value } of overrides) { + _.set(config, key, JSON.parse(value)); + } + + await queryRunner.query(`INSERT INTO "system_metadata" ("key", "value") VALUES ($1, $2)`, [ + 'system-config', + // yup, we're double-stringifying it + JSON.stringify(JSON.stringify(config)), + ]); + + await queryRunner.query(`DROP TABLE "system_config"`); + } + + public async down(queryRunner: QueryRunner): Promise { + // no data restore, you just get the table back + await queryRunner.query( + `CREATE TABLE "system_config" ("key" character varying NOT NULL, "value" character varying, CONSTRAINT "PK_aab69295b445016f56731f4d535" PRIMARY KEY ("key"))`, + ); + } +} diff --git a/server/src/queries/system.config.repository.sql b/server/src/queries/system.config.repository.sql deleted file mode 100644 index 276cab20fe..0000000000 --- a/server/src/queries/system.config.repository.sql +++ /dev/null @@ -1,13 +0,0 @@ --- NOTE: This file is auto generated by ./sql-generator - --- SystemConfigRepository.load -SELECT - "SystemConfigEntity"."key" AS "SystemConfigEntity_key", - "SystemConfigEntity"."value" AS "SystemConfigEntity_value" -FROM - "system_config" "SystemConfigEntity" - --- SystemConfigRepository.deleteKeys -DELETE FROM "system_config" -WHERE - "key" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9) diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index f21424e9d3..9ac9081c91 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -27,7 +27,6 @@ import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; @@ -60,7 +59,6 @@ import { ServerInfoRepository } from 'src/repositories/server-info.repository'; import { SessionRepository } from 'src/repositories/session.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; -import { SystemConfigRepository } from 'src/repositories/system-config.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { TagRepository } from 'src/repositories/tag.repository'; import { UserRepository } from 'src/repositories/user.repository'; @@ -94,7 +92,6 @@ export const repositories = [ { provide: ISearchRepository, useClass: SearchRepository }, { provide: ISessionRepository, useClass: SessionRepository }, { provide: IStorageRepository, useClass: StorageRepository }, - { provide: ISystemConfigRepository, useClass: SystemConfigRepository }, { provide: ISystemMetadataRepository, useClass: SystemMetadataRepository }, { provide: ITagRepository, useClass: TagRepository }, { provide: IMediaRepository, useClass: MediaRepository }, diff --git a/server/src/repositories/system-config.repository.ts b/server/src/repositories/system-config.repository.ts deleted file mode 100644 index 3d2dbecbc8..0000000000 --- a/server/src/repositories/system-config.repository.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { readFile } from 'node:fs/promises'; -import { Chunked, DummyValue, GenerateSql } from 'src/decorators'; -import { SystemConfigEntity } from 'src/entities/system-config.entity'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; -import { In, Repository } from 'typeorm'; - -@Instrumentation() -@Injectable() -export class SystemConfigRepository implements ISystemConfigRepository { - constructor( - @InjectRepository(SystemConfigEntity) - private repository: Repository, - ) {} - - async fetchStyle(url: string) { - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Failed to fetch data from ${url} with status ${response.status}: ${await response.text()}`); - } - - return response.json(); - } catch (error) { - throw new Error(`Failed to fetch data from ${url}: ${error}`); - } - } - - @GenerateSql() - load(): Promise { - return this.repository.find(); - } - - readFile(filename: string): Promise { - return readFile(filename, { encoding: 'utf8' }); - } - - saveAll(items: SystemConfigEntity[]): Promise { - return this.repository.save(items); - } - - @GenerateSql({ params: [DummyValue.STRING] }) - @Chunked() - async deleteKeys(keys: string[]): Promise { - await this.repository.delete({ key: In(keys) }); - } -} diff --git a/server/src/repositories/system-metadata.repository.ts b/server/src/repositories/system-metadata.repository.ts index 91b887a176..c8bf9489cb 100644 --- a/server/src/repositories/system-metadata.repository.ts +++ b/server/src/repositories/system-metadata.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { readFile } from 'node:fs/promises'; import { SystemMetadata, SystemMetadataEntity } from 'src/entities/system-metadata.entity'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { Instrumentation } from 'src/utils/instrumentation'; @@ -24,4 +25,22 @@ export class SystemMetadataRepository implements ISystemMetadataRepository { async set(key: T, value: SystemMetadata[T]): Promise { await this.repository.upsert({ key, value }, { conflictPaths: { key: true } }); } + + async fetchStyle(url: string) { + try { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch data from ${url} with status ${response.status}: ${await response.text()}`); + } + + return response.json(); + } catch (error) { + throw new Error(`Failed to fetch data from ${url}: ${error}`); + } + } + + readFile(filename: string): Promise { + return readFile(filename, { encoding: 'utf8' }); + } } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 2673e2436d..ca13adf31c 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -10,7 +10,7 @@ import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AssetService } from 'src/services/asset.service'; import { assetStackStub, assetStub } from 'test/fixtures/asset.stub'; @@ -27,7 +27,7 @@ import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mocked, vitest } from 'vitest'; @@ -159,7 +159,7 @@ describe(AssetService.name, () => { let storageMock: Mocked; let userMock: Mocked; let eventMock: Mocked; - let configMock: Mocked; + let systemMock: Mocked; let partnerMock: Mocked; let assetStackMock: Mocked; let albumMock: Mocked; @@ -182,7 +182,7 @@ describe(AssetService.name, () => { jobMock = newJobRepositoryMock(); storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); partnerMock = newPartnerRepositoryMock(); assetStackMock = newAssetStackRepositoryMock(); albumMock = newAlbumRepositoryMock(); @@ -192,7 +192,7 @@ describe(AssetService.name, () => { accessMock, assetMock, jobMock, - configMock, + systemMock, storageMock, userMock, eventMock, diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 715b2bb4aa..d266b1ed2f 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -45,7 +45,7 @@ import { import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { mimeTypes } from 'src/utils/mime-types'; import { usePagination } from 'src/utils/pagination'; @@ -73,7 +73,7 @@ export class AssetService { @Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, @@ -84,7 +84,7 @@ export class AssetService { ) { this.logger.setContext(AssetService.name); this.access = AccessCore.create(accessRepository); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise { diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index f00e10b13c..11339315db 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -11,7 +11,7 @@ import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AuthService } from 'src/services/auth.service'; import { keyStub } from 'test/fixtures/api-key.stub'; @@ -27,7 +27,7 @@ import { newLibraryRepositoryMock } from 'test/repositories/library.repository.m import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock'; import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mock, Mocked, vitest } from 'vitest'; @@ -64,7 +64,7 @@ describe('AuthService', () => { let userMock: Mocked; let libraryMock: Mocked; let loggerMock: Mocked; - let configMock: Mocked; + let systemMock: Mocked; let sessionMock: Mocked; let shareMock: Mocked; let keyMock: Mocked; @@ -97,7 +97,7 @@ describe('AuthService', () => { userMock = newUserRepositoryMock(); libraryMock = newLibraryRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); sessionMock = newSessionRepositoryMock(); shareMock = newSharedLinkRepositoryMock(); keyMock = newKeyRepositoryMock(); @@ -105,7 +105,7 @@ describe('AuthService', () => { sut = new AuthService( accessMock, cryptoMock, - configMock, + systemMock, libraryMock, loggerMock, userMock, @@ -121,7 +121,7 @@ describe('AuthService', () => { describe('login', () => { it('should throw an error if password login is disabled', async () => { - configMock.load.mockResolvedValue(systemConfigStub.disabled); + systemMock.get.mockResolvedValue(systemConfigStub.disabled); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); }); @@ -199,7 +199,7 @@ describe('AuthService', () => { describe('logout', () => { it('should return the end session endpoint', async () => { - configMock.load.mockResolvedValue(systemConfigStub.enabled); + systemMock.get.mockResolvedValue(systemConfigStub.enabled); const auth = { user: { id: '123' } } as AuthDto; await expect(sut.logout(auth, AuthType.OAUTH)).resolves.toEqual({ successful: true, @@ -377,7 +377,7 @@ describe('AuthService', () => { }); it('should not allow auto registering', async () => { - configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister); + systemMock.get.mockResolvedValue(systemConfigStub.noAutoRegister); userMock.getByEmail.mockResolvedValue(null); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( BadRequestException, @@ -386,7 +386,7 @@ describe('AuthService', () => { }); it('should link an existing user', async () => { - configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister); + systemMock.get.mockResolvedValue(systemConfigStub.noAutoRegister); userMock.getByEmail.mockResolvedValue(userStub.user1); userMock.update.mockResolvedValue(userStub.user1); sessionMock.create.mockResolvedValue(sessionStub.valid); @@ -400,7 +400,7 @@ describe('AuthService', () => { }); it('should allow auto registering by default', async () => { - configMock.load.mockResolvedValue(systemConfigStub.enabled); + systemMock.get.mockResolvedValue(systemConfigStub.enabled); userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); @@ -415,7 +415,7 @@ describe('AuthService', () => { }); it('should use the mobile redirect override', async () => { - configMock.load.mockResolvedValue(systemConfigStub.override); + systemMock.get.mockResolvedValue(systemConfigStub.override); userMock.getByOAuthId.mockResolvedValue(userStub.user1); sessionMock.create.mockResolvedValue(sessionStub.valid); @@ -425,7 +425,7 @@ describe('AuthService', () => { }); it('should use the mobile redirect override for ios urls with multiple slashes', async () => { - configMock.load.mockResolvedValue(systemConfigStub.override); + systemMock.get.mockResolvedValue(systemConfigStub.override); userMock.getByOAuthId.mockResolvedValue(userStub.user1); sessionMock.create.mockResolvedValue(sessionStub.valid); @@ -435,7 +435,7 @@ describe('AuthService', () => { }); it('should use the default quota', async () => { - configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); + systemMock.get.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); @@ -448,7 +448,7 @@ describe('AuthService', () => { }); it('should ignore an invalid storage quota', async () => { - configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); + systemMock.get.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); @@ -462,7 +462,7 @@ describe('AuthService', () => { }); it('should ignore a negative quota', async () => { - configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); + systemMock.get.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); @@ -476,7 +476,7 @@ describe('AuthService', () => { }); it('should not set quota for 0 quota', async () => { - configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); + systemMock.get.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); @@ -496,7 +496,7 @@ describe('AuthService', () => { }); it('should use a valid storage quota', async () => { - configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); + systemMock.get.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); @@ -518,7 +518,7 @@ describe('AuthService', () => { describe('link', () => { it('should link an account', async () => { - configMock.load.mockResolvedValue(systemConfigStub.enabled); + systemMock.get.mockResolvedValue(systemConfigStub.enabled); userMock.update.mockResolvedValue(userStub.user1); await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' }); @@ -527,7 +527,7 @@ describe('AuthService', () => { }); it('should not link an already linked oauth.sub', async () => { - configMock.load.mockResolvedValue(systemConfigStub.enabled); + systemMock.get.mockResolvedValue(systemConfigStub.enabled); userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity); await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf( @@ -540,7 +540,7 @@ describe('AuthService', () => { describe('unlink', () => { it('should unlink an account', async () => { - configMock.load.mockResolvedValue(systemConfigStub.enabled); + systemMock.get.mockResolvedValue(systemConfigStub.enabled); userMock.update.mockResolvedValue(userStub.user1); await sut.unlink(authStub.user1); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 7e7bbb6675..4c0efc4e6b 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -37,7 +37,7 @@ import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { HumanReadableSize } from 'src/utils/bytes'; @@ -67,7 +67,7 @@ export class AuthService { constructor( @Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ILibraryRepository) libraryRepository: ILibraryRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @@ -77,7 +77,7 @@ export class AuthService { ) { this.logger.setContext(AuthService.name); this.access = AccessCore.create(accessRepository); - this.configCore = SystemConfigCore.create(configRepository, logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository); custom.setHttpOptionsDefaults({ timeout: 30_000 }); diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 22ccf6766f..abbd41f7bf 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -15,7 +15,7 @@ import { import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMetricRepository } from 'src/interfaces/metric.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { JobService } from 'src/services/job.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; @@ -24,7 +24,7 @@ import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newMetricRepositoryMock } from 'test/repositories/metric.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked, vitest } from 'vitest'; const makeMockHandlers = (status: JobStatus) => { @@ -38,22 +38,22 @@ const makeMockHandlers = (status: JobStatus) => { describe(JobService.name, () => { let sut: JobService; let assetMock: Mocked; - let configMock: Mocked; let eventMock: Mocked; let jobMock: Mocked; let personMock: Mocked; let metricMock: Mocked; + let systemMock: Mocked; let loggerMock: Mocked; beforeEach(() => { assetMock = newAssetRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); eventMock = newEventRepositoryMock(); jobMock = newJobRepositoryMock(); personMock = newPersonRepositoryMock(); metricMock = newMetricRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - sut = new JobService(assetMock, eventMock, jobMock, configMock, personMock, metricMock, loggerMock); + sut = new JobService(assetMock, eventMock, jobMock, systemMock, personMock, metricMock, loggerMock); }); it('should work', () => { @@ -234,14 +234,14 @@ describe(JobService.name, () => { describe('init', () => { it('should register a handler for each queue', async () => { await sut.init(makeMockHandlers(JobStatus.SUCCESS)); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length); }); it('should subscribe to config changes', async () => { await sut.init(makeMockHandlers(JobStatus.FAILED)); - SystemConfigCore.create(newSystemConfigRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ + SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ job: { [QueueName.BACKGROUND_TASK]: { concurrency: 10 }, [QueueName.SMART_SEARCH]: { concurrency: 10 }, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index aa625da1de..9e1bb78db1 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -20,7 +20,7 @@ import { import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMetricRepository } from 'src/interfaces/metric.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @Injectable() export class JobService { @@ -30,13 +30,13 @@ export class JobService { @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IPersonRepository) private personRepository: IPersonRepository, @Inject(IMetricRepository) private metricRepository: IMetricRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(JobService.name); - this.configCore = SystemConfigCore.create(configRepository, logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise { diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index fa45341784..9f2cb073c2 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -5,7 +5,6 @@ import { SystemConfigCore } from 'src/cores/system-config.core'; import { mapLibrary } from 'src/dtos/library.dto'; import { AssetType } from 'src/entities/asset.entity'; import { LibraryType } from 'src/entities/library.entity'; -import { SystemConfigKey } from 'src/entities/system-config.entity'; import { UserEntity } from 'src/entities/user.entity'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -14,7 +13,7 @@ import { IJobRepository, ILibraryFileJob, ILibraryRefreshJob, JobName, JobStatus import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { LibraryService } from 'src/services/library.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; @@ -28,14 +27,14 @@ import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { makeMockWatcher, newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked, vitest } from 'vitest'; describe(LibraryService.name, () => { let sut: LibraryService; let assetMock: Mocked; - let configMock: Mocked; + let systemMock: Mocked; let cryptoMock: Mocked; let jobMock: Mocked; let libraryMock: Mocked; @@ -44,7 +43,7 @@ describe(LibraryService.name, () => { let loggerMock: Mocked; beforeEach(() => { - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); libraryMock = newLibraryRepositoryMock(); assetMock = newAssetRepositoryMock(); jobMock = newJobRepositoryMock(); @@ -55,7 +54,7 @@ describe(LibraryService.name, () => { sut = new LibraryService( assetMock, - configMock, + systemMock, cryptoMock, jobMock, libraryMock, @@ -73,16 +72,13 @@ describe(LibraryService.name, () => { describe('init', () => { it('should init cron job and subscribe to config changes', async () => { - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.LIBRARY_SCAN_ENABLED, value: true }, - { key: SystemConfigKey.LIBRARY_SCAN_CRON_EXPRESSION, value: '0 0 * * *' }, - ]); + systemMock.get.mockResolvedValue(systemConfigStub.libraryScan); await sut.init(); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); expect(jobMock.addCronJob).toHaveBeenCalled(); - SystemConfigCore.create(newSystemConfigRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ + SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ library: { scan: { enabled: true, @@ -101,7 +97,7 @@ describe(LibraryService.name, () => { libraryStub.externalLibraryWithImportPaths2, ]); - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.get.mockImplementation((id) => Promise.resolve( [libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2].find( @@ -121,7 +117,7 @@ describe(LibraryService.name, () => { }); it('should not initialize watcher when watching is disabled', async () => { - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchDisabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled); await sut.init(); @@ -129,7 +125,7 @@ describe(LibraryService.name, () => { }); it('should not initialize watcher when lock is taken', async () => { - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); databaseMock.tryLock.mockResolvedValue(false); await sut.init(); @@ -757,7 +753,7 @@ describe(LibraryService.name, () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); const mockClose = vitest.fn(); storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); @@ -897,7 +893,7 @@ describe(LibraryService.name, () => { }); it('should create watched with import paths', async () => { - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.create.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.getAll.mockResolvedValue([]); @@ -1041,7 +1037,7 @@ describe(LibraryService.name, () => { describe('update', () => { beforeEach(async () => { - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.getAll.mockResolvedValue([]); await sut.init(); @@ -1058,7 +1054,7 @@ describe(LibraryService.name, () => { describe('watchAll', () => { describe('watching disabled', () => { beforeEach(async () => { - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchDisabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled); await sut.init(); }); @@ -1074,7 +1070,7 @@ describe(LibraryService.name, () => { describe('watching enabled', () => { beforeEach(async () => { - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.getAll.mockResolvedValue([]); await sut.init(); }); @@ -1229,7 +1225,7 @@ describe(LibraryService.name, () => { libraryStub.externalLibraryWithImportPaths2, ]); - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.get.mockImplementation((id) => diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 3c6e26a315..fb6991d016 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -38,7 +38,7 @@ import { import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { mimeTypes } from 'src/utils/mime-types'; import { handlePromiseError } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @@ -55,7 +55,7 @@ export class LibraryService { constructor( @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ILibraryRepository) private repository: ILibraryRepository, @@ -64,7 +64,7 @@ export class LibraryService { @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(LibraryService.name); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } async init() { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 4a7a6836af..271987c8f7 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -10,7 +10,6 @@ import { } from 'src/config'; import { AssetType } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; -import { SystemConfigKey } from 'src/entities/system-config.entity'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -19,7 +18,7 @@ import { IMediaRepository } from 'src/interfaces/media.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { MediaService } from 'src/services/media.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { faceStub } from 'test/fixtures/face.stub'; @@ -33,24 +32,24 @@ import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock' import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked } from 'vitest'; describe(MediaService.name, () => { let sut: MediaService; let assetMock: Mocked; - let configMock: Mocked; let jobMock: Mocked; let mediaMock: Mocked; let moveMock: Mocked; let personMock: Mocked; let storageMock: Mocked; + let systemMock: Mocked; let cryptoMock: Mocked; let loggerMock: Mocked; beforeEach(() => { assetMock = newAssetRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); jobMock = newJobRepositoryMock(); mediaMock = newMediaRepositoryMock(); moveMock = newMoveRepositoryMock(); @@ -65,7 +64,7 @@ describe(MediaService.name, () => { jobMock, mediaMock, storageMock, - configMock, + systemMock, moveMock, cryptoMock, loggerMock, @@ -235,7 +234,7 @@ describe(MediaService.name, () => { }); it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_PREVIEW_FORMAT, value: format }]); + systemMock.get.mockResolvedValue({ image: { previewFormat: format } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; @@ -254,7 +253,7 @@ describe(MediaService.name, () => { it('should delete previous preview if different path', async () => { const previousPreviewPath = assetStub.image.previewPath; - configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_THUMBNAIL_FORMAT, value: ImageFormat.WEBP }]); + systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGeneratePreview({ id: assetStub.image.id }); @@ -337,10 +336,9 @@ describe(MediaService.name, () => { it('should always generate video thumbnail in one pass', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '5000k' }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { twoPass: true, maxBitrate: '5000k' }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleGeneratePreview({ id: assetStub.video.id }); @@ -385,7 +383,7 @@ describe(MediaService.name, () => { it.each(Object.values(ImageFormat))( 'should generate a %s thumbnail for an image when specified', async (format) => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_THUMBNAIL_FORMAT, value: format }]); + systemMock.get.mockResolvedValue({ image: { thumbnailFormat: format } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; @@ -405,7 +403,7 @@ describe(MediaService.name, () => { it('should delete previous thumbnail if different path', async () => { const previousThumbnailPath = assetStub.image.thumbnailPath; - configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_THUMBNAIL_FORMAT, value: ImageFormat.WEBP }]); + systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); @@ -438,7 +436,7 @@ describe(MediaService.name, () => { it('should extract embedded image if enabled and available', async () => { mediaMock.extract.mockResolvedValue(true); mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); @@ -463,7 +461,7 @@ describe(MediaService.name, () => { it('should resize original image if embedded image is too small', async () => { mediaMock.extract.mockResolvedValue(true); mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); @@ -486,7 +484,7 @@ describe(MediaService.name, () => { }); it('should resize original image if embedded image not found', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); @@ -505,7 +503,7 @@ describe(MediaService.name, () => { }); it('should resize original image if embedded image extraction is not enabled', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: false }]); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } }); assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); @@ -626,7 +624,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext'); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); expect(storageMock.mkdirSync).toHaveBeenCalled(); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -655,7 +653,7 @@ describe(MediaService.name, () => { it('should transcode when set to all', async () => { mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -671,7 +669,7 @@ describe(MediaService.name, () => { it('should transcode when optimal and too big', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -686,10 +684,7 @@ describe(MediaService.name, () => { it('should transcode when policy Bitrate and bitrate higher than max bitrate', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.BITRATE }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '30M' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: '30M' } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -704,10 +699,7 @@ describe(MediaService.name, () => { it('should not scale resolution if no target resolution', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL }, - { key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: 'original' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -722,7 +714,7 @@ describe(MediaService.name, () => { it('should scale horizontally when video is horizontal', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -738,7 +730,7 @@ describe(MediaService.name, () => { it('should scale vertically when video is vertical', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -754,10 +746,7 @@ describe(MediaService.name, () => { it('should always scale video if height is uneven', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamOddHeight); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL }, - { key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: 'original' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -773,10 +762,7 @@ describe(MediaService.name, () => { it('should always scale video if width is uneven', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamOddWidth); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL }, - { key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: 'original' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -792,10 +778,9 @@ describe(MediaService.name, () => { it('should copy video stream when video matches target', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, - { key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { targetVideoCodec: VideoCodec.HEVC, acceptedAudioCodecs: [AudioCodec.AAC] }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -811,11 +796,13 @@ describe(MediaService.name, () => { it('should not include hevc tag when target is hevc and video stream is copied from a different codec', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamH264); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, - { key: SystemConfigKey.FFMPEG_ACCEPTED_VIDEO_CODECS, value: [VideoCodec.H264, VideoCodec.HEVC] }, - { key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { + targetVideoCodec: VideoCodec.HEVC, + acceptedVideoCodecs: [VideoCodec.H264, VideoCodec.HEVC], + acceptedAudioCodecs: [AudioCodec.AAC], + }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -831,11 +818,13 @@ describe(MediaService.name, () => { it('should include hevc tag when target is hevc and copying hevc video stream', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, - { key: SystemConfigKey.FFMPEG_ACCEPTED_VIDEO_CODECS, value: [VideoCodec.H264, VideoCodec.HEVC] }, - { key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { + targetVideoCodec: VideoCodec.HEVC, + acceptedVideoCodecs: [VideoCodec.H264, VideoCodec.HEVC], + acceptedAudioCodecs: [AudioCodec.AAC], + }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -851,7 +840,7 @@ describe(MediaService.name, () => { it('should copy audio stream when audio matches target', async () => { mediaMock.probe.mockResolvedValue(probeStub.audioStreamAac); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -867,7 +856,7 @@ describe(MediaService.name, () => { it('should throw an exception if transcode value is invalid', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'invalid' }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } }); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrow(); expect(mediaMock.transcode).not.toHaveBeenCalled(); @@ -875,7 +864,7 @@ describe(MediaService.name, () => { it('should not transcode if transcoding is disabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.DISABLED }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).not.toHaveBeenCalled(); @@ -883,7 +872,7 @@ describe(MediaService.name, () => { it('should not transcode if target codec is invalid', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'invalid' }]); + systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: 'invalid' as any } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).not.toHaveBeenCalled(); @@ -892,7 +881,7 @@ describe(MediaService.name, () => { it('should delete existing transcode if current policy does not require transcoding', async () => { const asset = assetStub.hasEncodedVideo; mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.DISABLED }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); assetMock.getByIds.mockResolvedValue([asset]); await sut.handleVideoConversion({ id: asset.id }); @@ -906,7 +895,7 @@ describe(MediaService.name, () => { it('should set max bitrate if above 0', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }]); + systemMock.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500k' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -922,10 +911,7 @@ describe(MediaService.name, () => { it('should transcode in two passes for h264/h265 when enabled and max bitrate is above 0', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }, - { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '4500k' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -941,7 +927,7 @@ describe(MediaService.name, () => { it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }]); + systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -957,11 +943,13 @@ describe(MediaService.name, () => { it('should transcode by bitrate in two passes for vp9 when two pass mode and max bitrate are enabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }, - { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { + maxBitrate: '4500k', + twoPass: true, + targetVideoCodec: VideoCodec.VP9, + }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -977,11 +965,13 @@ describe(MediaService.name, () => { it('should transcode by crf in two passes for vp9 when two pass mode is enabled and max bitrate is disabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '0' }, - { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { + maxBitrate: '0', + twoPass: true, + targetVideoCodec: VideoCodec.VP9, + }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -997,10 +987,7 @@ describe(MediaService.name, () => { it('should configure preset for vp9', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, - { key: SystemConfigKey.FFMPEG_PRESET, value: 'slow' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, preset: 'slow' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1016,10 +1003,7 @@ describe(MediaService.name, () => { it('should not configure preset for vp9 if invalid', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, - { key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { preset: 'invalid', targetVideoCodec: VideoCodec.VP9 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1035,10 +1019,7 @@ describe(MediaService.name, () => { it('should configure threads if above 0', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, - { key: SystemConfigKey.FFMPEG_THREADS, value: 2 }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, threads: 2 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1054,7 +1035,7 @@ describe(MediaService.name, () => { it('should disable thread pooling for h264 if thread limit is 1', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 1 }]); + systemMock.get.mockResolvedValue({ ffmpeg: { threads: 1 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1070,7 +1051,7 @@ describe(MediaService.name, () => { it('should omit thread flags for h264 if thread limit is at or below 0', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 0 }]); + systemMock.get.mockResolvedValue({ ffmpeg: { threads: 0 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1086,10 +1067,7 @@ describe(MediaService.name, () => { it('should disable thread pooling for hevc if thread limit is 1', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_THREADS, value: 1 }, - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { threads: 1, targetVideoCodec: VideoCodec.HEVC } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1105,10 +1083,7 @@ describe(MediaService.name, () => { it('should omit thread flags for hevc if thread limit is at or below 0', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_THREADS, value: 0 }, - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { threads: 0, targetVideoCodec: VideoCodec.HEVC } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1124,7 +1099,7 @@ describe(MediaService.name, () => { it('should use av1 if specified', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }]); + systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1150,10 +1125,7 @@ describe(MediaService.name, () => { it('should map `veryslow` preset to 4 for av1', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }, - { key: SystemConfigKey.FFMPEG_PRESET, value: 'veryslow' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, preset: 'veryslow' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1169,10 +1141,7 @@ describe(MediaService.name, () => { it('should set max bitrate for av1 if specified', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '2M' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, maxBitrate: '2M' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1188,10 +1157,7 @@ describe(MediaService.name, () => { it('should set threads for av1 if specified', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }, - { key: SystemConfigKey.FFMPEG_THREADS, value: 4 }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1207,11 +1173,7 @@ describe(MediaService.name, () => { it('should set both bitrate and threads for av1 if specified', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }, - { key: SystemConfigKey.FFMPEG_THREADS, value: 4 }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '2M' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4, maxBitrate: '2M' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1227,11 +1189,13 @@ describe(MediaService.name, () => { it('should skip transcoding for audioless videos with optimal policy if video codec is correct', async () => { mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, - { key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }, - { key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: '1080p' }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { + targetVideoCodec: VideoCodec.HEVC, + transcode: TranscodePolicy.OPTIMAL, + targetResolution: '1080p', + }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).not.toHaveBeenCalled(); @@ -1239,10 +1203,7 @@ describe(MediaService.name, () => { it('should fail if hwaccel is enabled for an unsupported codec', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }, - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, targetVideoCodec: VideoCodec.VP9 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); expect(mediaMock.transcode).not.toHaveBeenCalled(); @@ -1250,7 +1211,7 @@ describe(MediaService.name, () => { it('should fail if hwaccel option is invalid', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: 'invalid' }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); expect(mediaMock.transcode).not.toHaveBeenCalled(); @@ -1258,7 +1219,7 @@ describe(MediaService.name, () => { it('should set options for nvenc', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1290,11 +1251,13 @@ describe(MediaService.name, () => { it('should set two pass options for nvenc when enabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' }, - { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { + accel: TranscodeHWAccel.NVENC, + maxBitrate: '10000k', + twoPass: true, + }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1310,10 +1273,7 @@ describe(MediaService.name, () => { it('should set vbr options for nvenc when max bitrate is enabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1329,10 +1289,7 @@ describe(MediaService.name, () => { it('should set cq options for nvenc when max bitrate is disabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1348,10 +1305,7 @@ describe(MediaService.name, () => { it('should omit preset for nvenc if invalid', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }, - { key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, preset: 'invalid' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1367,7 +1321,7 @@ describe(MediaService.name, () => { it('should ignore two pass for nvenc if max bitrate is disabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1384,10 +1338,7 @@ describe(MediaService.name, () => { it('should set options for qsv', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1420,11 +1371,13 @@ describe(MediaService.name, () => { it('should set options for qsv with custom dri node', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' }, - { key: SystemConfigKey.FFMPEG_PREFERRED_HW_DEVICE, value: '/dev/dri/renderD128' }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { + accel: TranscodeHWAccel.QSV, + maxBitrate: '10000k', + preferredHwDevice: '/dev/dri/renderD128', + }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1444,10 +1397,7 @@ describe(MediaService.name, () => { it('should omit preset for qsv if invalid', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV }, - { key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, preset: 'invalid' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1464,10 +1414,7 @@ describe(MediaService.name, () => { it('should set low power mode for qsv if target video codec is vp9', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV }, - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, targetVideoCodec: VideoCodec.VP9 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1484,7 +1431,7 @@ describe(MediaService.name, () => { it('should fail for qsv if no hw devices', async () => { storageMock.readdir.mockResolvedValue([]); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); expect(mediaMock.transcode).not.toHaveBeenCalled(); @@ -1493,7 +1440,7 @@ describe(MediaService.name, () => { it('should set options for vaapi', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1525,10 +1472,7 @@ describe(MediaService.name, () => { it('should set vbr options for vaapi when max bitrate is enabled', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, maxBitrate: '10000k' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1554,7 +1498,7 @@ describe(MediaService.name, () => { it('should set cq options for vaapi when max bitrate is disabled', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1580,10 +1524,7 @@ describe(MediaService.name, () => { it('should omit preset for vaapi if invalid', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }, - { key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preset: 'invalid' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1603,7 +1544,7 @@ describe(MediaService.name, () => { it('should prefer gpu for vaapi if available', async () => { storageMock.readdir.mockResolvedValue(['renderD129', 'card1', 'card0', 'renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1623,7 +1564,7 @@ describe(MediaService.name, () => { it('should prefer higher index gpu node', async () => { storageMock.readdir.mockResolvedValue(['renderD129', 'renderD130', 'renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1643,10 +1584,9 @@ describe(MediaService.name, () => { it('should select specific gpu node if selected', async () => { storageMock.readdir.mockResolvedValue(['renderD129', 'card1', 'card0', 'renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }, - { key: SystemConfigKey.FFMPEG_PREFERRED_HW_DEVICE, value: '/dev/dri/renderD128' }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.VAAPI, preferredHwDevice: '/dev/dri/renderD128' }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1666,7 +1606,7 @@ describe(MediaService.name, () => { it('should fallback to sw transcoding if hw transcoding fails', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); mediaMock.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -1685,7 +1625,7 @@ describe(MediaService.name, () => { it('should fail for vaapi if no hw devices', async () => { storageMock.readdir.mockResolvedValue([]); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); expect(mediaMock.transcode).not.toHaveBeenCalled(); @@ -1694,7 +1634,7 @@ describe(MediaService.name, () => { it('should set options for rkmpp', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1724,11 +1664,13 @@ describe(MediaService.name, () => { it('should set vbr options for rkmpp when max bitrate is enabled', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' }, - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { + accel: TranscodeHWAccel.RKMPP, + maxBitrate: '10000k', + targetVideoCodec: VideoCodec.HEVC, + }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1745,11 +1687,9 @@ describe(MediaService.name, () => { it('should set cqp options for rkmpp when max bitrate is disabled', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP }, - { key: SystemConfigKey.FFMPEG_CRF, value: 30 }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '0' }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.RKMPP, crf: 30, maxBitrate: '0' }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1767,11 +1707,7 @@ describe(MediaService.name, () => { storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP }, - { key: SystemConfigKey.FFMPEG_CRF, value: 30 }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '0' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, crf: 30, maxBitrate: '0' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1792,7 +1728,7 @@ describe(MediaService.name, () => { it('should tonemap when policy is required and video is hdr', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.REQUIRED }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1812,7 +1748,7 @@ describe(MediaService.name, () => { it('should tonemap when policy is optimal and video is hdr', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1832,7 +1768,7 @@ describe(MediaService.name, () => { it('should set npl to 250 for reinhard and mobius tone-mapping algorithms', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TONEMAP, value: ToneMapping.MOBIUS }]); + systemMock.get.mockResolvedValue({ ffmpeg: { tonemap: ToneMapping.MOBIUS } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index e493b22f95..86fe8252ad 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -31,7 +31,7 @@ import { AudioStreamInfo, IMediaRepository, VideoCodecHWConfig, VideoStreamInfo import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { AV1Config, H264Config, @@ -59,20 +59,20 @@ export class MediaService { @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IMediaRepository) private mediaRepository: IMediaRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IMoveRepository) moveRepository: IMoveRepository, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(MediaService.name); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, moveRepository, personRepository, storageRepository, - configRepository, + systemMetadataRepository, this.logger, ); } @@ -329,7 +329,6 @@ export class MediaService { } const { ffmpeg: config } = await this.configCore.getConfig(); - const target = this.getTranscodeTarget(config, mainVideoStream, mainAudioStream); if (target === TranscodeTarget.NONE) { if (asset.encodedVideoPath) { diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index fefd40becb..da90b83794 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -4,7 +4,6 @@ import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; import { AssetType } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; -import { SystemConfigKey } from 'src/entities/system-config.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -17,7 +16,7 @@ import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interfa import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { MetadataService, Orientation } from 'src/services/metadata.service'; import { assetStub } from 'test/fixtures/asset.stub'; @@ -35,14 +34,14 @@ import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mocked } from 'vitest'; describe(MetadataService.name, () => { let albumMock: Mocked; let assetMock: Mocked; - let configMock: Mocked; + let systemMock: Mocked; let cryptoRepository: Mocked; let jobMock: Mocked; let metadataMock: Mocked; @@ -59,7 +58,6 @@ describe(MetadataService.name, () => { beforeEach(() => { albumMock = newAlbumRepositoryMock(); assetMock = newAssetRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); cryptoRepository = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); metadataMock = newMetadataRepositoryMock(); @@ -67,6 +65,7 @@ describe(MetadataService.name, () => { personMock = newPersonRepositoryMock(); eventMock = newEventRepositoryMock(); storageMock = newStorageRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); mediaMock = newMediaRepositoryMock(); databaseMock = newDatabaseRepositoryMock(); userMock = newUserRepositoryMock(); @@ -84,7 +83,7 @@ describe(MetadataService.name, () => { moveMock, personMock, storageMock, - configMock, + systemMock, userMock, loggerMock, ); @@ -108,7 +107,7 @@ describe(MetadataService.name, () => { }); it('should return if reverse geocoding is disabled', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: false }]); + systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: false } }); await sut.init(); @@ -297,7 +296,7 @@ describe(MetadataService.name, () => { it('should apply reverse geocoding', async () => { assetMock.getByIds.mockResolvedValue([assetStub.withLocation]); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: true }]); + systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); metadataMock.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); metadataMock.readTags.mockResolvedValue({ GPSLatitude: assetStub.withLocation.exifInfo!.latitude!, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 2d9333d1e4..cca6d9eb19 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -31,7 +31,7 @@ import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interfa import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { handlePromiseError } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @@ -113,19 +113,19 @@ export class MetadataService { @Inject(IMoveRepository) moveRepository: IMoveRepository, @Inject(IPersonRepository) personRepository: IPersonRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(MetadataService.name); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, moveRepository, personRepository, storageRepository, - configRepository, + systemMetadataRepository, this.logger, ); } diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index dc5d89056b..503fe4afdd 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -5,7 +5,7 @@ import { ServerAsyncEvent, ServerAsyncEventMap } from 'src/interfaces/event.inte import { IEmailJob, IJobRepository, INotifySignupJob, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; @Injectable() @@ -13,14 +13,14 @@ export class NotificationService { private configCore: SystemConfigCore; constructor( - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(INotificationRepository) private notificationRepository: INotificationRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(NotificationService.name); - this.configCore = SystemConfigCore.create(configRepository, logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } init() { diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 934a204246..1644c0c896 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -3,7 +3,6 @@ import { Colorspace } from 'src/config'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { SystemConfigKey } from 'src/entities/system-config.entity'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -14,13 +13,14 @@ import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { FaceSearchResult, ISearchRepository } from 'src/interfaces/search.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { PersonService } from 'src/services/person.service'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { personStub } from 'test/fixtures/person.stub'; +import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; @@ -32,7 +32,7 @@ import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { IsNull } from 'typeorm'; import { Mocked } from 'vitest'; @@ -64,7 +64,7 @@ const detectFaceMock = { describe(PersonService.name, () => { let accessMock: IAccessRepositoryMock; let assetMock: Mocked; - let configMock: Mocked; + let systemMock: Mocked; let jobMock: Mocked; let machineLearningMock: Mocked; let mediaMock: Mocked; @@ -79,7 +79,7 @@ describe(PersonService.name, () => { beforeEach(() => { accessMock = newAccessRepositoryMock(); assetMock = newAssetRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); jobMock = newJobRepositoryMock(); machineLearningMock = newMachineLearningRepositoryMock(); moveMock = newMoveRepositoryMock(); @@ -96,7 +96,7 @@ describe(PersonService.name, () => { moveMock, mediaMock, personMock, - configMock, + systemMock, storageMock, jobMock, searchMock, @@ -451,12 +451,12 @@ describe(PersonService.name, () => { describe('handleQueueDetectFaces', () => { it('should skip if machine learning is disabled', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleQueueDetectFaces({})).resolves.toBe(JobStatus.SKIPPED); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); }); it('should queue missing assets', async () => { @@ -528,11 +528,11 @@ describe(PersonService.name, () => { describe('handleQueueRecognizeFaces', () => { it('should skip if machine learning is disabled', async () => { jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED); expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); }); it('should skip if recognition jobs are already queued', async () => { @@ -609,11 +609,11 @@ describe(PersonService.name, () => { describe('handleDetectFaces', () => { it('should skip if machine learning is disabled', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleDetectFaces({ id: 'foo' })).resolves.toBe(JobStatus.SKIPPED); expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); }); it('should skip when no resize path', async () => { @@ -740,9 +740,7 @@ describe(PersonService.name, () => { { face: faceStub.face1, distance: 0.4 }, ] as FaceSearchResult[]; - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 1 }, - ]); + systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.create.mockResolvedValue(faceStub.primaryFace1.person); @@ -767,9 +765,7 @@ describe(PersonService.name, () => { { face: faceStub.noPerson2, distance: 0.3 }, ] as FaceSearchResult[]; - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 1 }, - ]); + systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.create.mockResolvedValue(personStub.withName); @@ -807,9 +803,7 @@ describe(PersonService.name, () => { { face: faceStub.noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 3 }, - ]); + systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.create.mockResolvedValue(personStub.withName); @@ -831,9 +825,7 @@ describe(PersonService.name, () => { { face: faceStub.noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 3 }, - ]); + systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.create.mockResolvedValue(personStub.withName); @@ -849,11 +841,11 @@ describe(PersonService.name, () => { describe('handleGeneratePersonThumbnail', () => { it('should skip if machine learning is disabled', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.SKIPPED); expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); }); it('should skip a person not found', async () => { diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 3e9f3a4bb6..de0c191667 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -45,7 +45,7 @@ import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { Orientation } from 'src/services/metadata.service'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; @@ -66,7 +66,7 @@ export class PersonService { @Inject(IMoveRepository) moveRepository: IMoveRepository, @Inject(IMediaRepository) private mediaRepository: IMediaRepository, @Inject(IPersonRepository) private repository: IPersonRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ISearchRepository) private smartInfoRepository: ISearchRepository, @@ -75,14 +75,14 @@ export class PersonService { ) { this.access = AccessCore.create(accessRepository); this.logger.setContext(PersonService.name); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, moveRepository, repository, storageRepository, - configRepository, + systemMetadataRepository, this.logger, ); } diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index bf4cd7c679..321e495fdc 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -6,7 +6,7 @@ import { IMetadataRepository } from 'src/interfaces/metadata.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SearchService } from 'src/services/search.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; @@ -18,7 +18,7 @@ import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked, vitest } from 'vitest'; vitest.useFakeTimers(); @@ -26,7 +26,7 @@ vitest.useFakeTimers(); describe(SearchService.name, () => { let sut: SearchService; let assetMock: Mocked; - let configMock: Mocked; + let systemMock: Mocked; let machineMock: Mocked; let personMock: Mocked; let searchMock: Mocked; @@ -36,7 +36,7 @@ describe(SearchService.name, () => { beforeEach(() => { assetMock = newAssetRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); machineMock = newMachineLearningRepositoryMock(); personMock = newPersonRepositoryMock(); searchMock = newSearchRepositoryMock(); @@ -45,7 +45,7 @@ describe(SearchService.name, () => { loggerMock = newLoggerRepositoryMock(); sut = new SearchService( - configMock, + systemMock, machineMock, personMock, searchMock, diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index e528142e62..10a2ccda2a 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -23,7 +23,7 @@ import { IMetadataRepository } from 'src/interfaces/metadata.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() @@ -31,7 +31,7 @@ export class SearchService { private configCore: SystemConfigCore; constructor( - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, @Inject(IPersonRepository) private personRepository: IPersonRepository, @Inject(ISearchRepository) private searchRepository: ISearchRepository, @@ -41,7 +41,7 @@ export class SearchService { @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(SearchService.name); - this.configCore = SystemConfigCore.create(configRepository, logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise { diff --git a/server/src/services/server-info.service.spec.ts b/server/src/services/server-info.service.spec.ts index a007498b40..273582b1cf 100644 --- a/server/src/services/server-info.service.spec.ts +++ b/server/src/services/server-info.service.spec.ts @@ -3,14 +3,12 @@ import { IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { ServerInfoService } from 'src/services/server-info.service'; import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { newServerInfoRepositoryMock } from 'test/repositories/system-info.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; @@ -19,31 +17,21 @@ import { Mocked } from 'vitest'; describe(ServerInfoService.name, () => { let sut: ServerInfoService; let eventMock: Mocked; - let configMock: Mocked; let serverInfoMock: Mocked; let storageMock: Mocked; let userMock: Mocked; - let systemMetadataMock: Mocked; + let systemMock: Mocked; let loggerMock: Mocked; beforeEach(() => { - configMock = newSystemConfigRepositoryMock(); eventMock = newEventRepositoryMock(); serverInfoMock = newServerInfoRepositoryMock(); storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); - systemMetadataMock = newSystemMetadataRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - sut = new ServerInfoService( - eventMock, - configMock, - userMock, - serverInfoMock, - storageMock, - systemMetadataMock, - loggerMock, - ); + sut = new ServerInfoService(eventMock, userMock, serverInfoMock, storageMock, systemMock, loggerMock); }); it('should work', () => { @@ -188,7 +176,7 @@ describe(ServerInfoService.name, () => { trash: true, email: false, }); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); }); }); @@ -203,7 +191,7 @@ describe(ServerInfoService.name, () => { isOnboarded: false, externalDomain: '', }); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); }); }); diff --git a/server/src/services/server-info.service.ts b/server/src/services/server-info.service.ts index 198e71dea6..c8ca3069b3 100644 --- a/server/src/services/server-info.service.ts +++ b/server/src/services/server-info.service.ts @@ -18,7 +18,6 @@ import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface'; import { asHumanReadable } from 'src/utils/bytes'; @@ -34,7 +33,6 @@ export class ServerInfoService { constructor( @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IServerInfoRepository) private repository: IServerInfoRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @@ -42,7 +40,7 @@ export class ServerInfoService { @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(ServerInfoService.name); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } onConnect() {} diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 8dedcb5c5f..7ac6dac414 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,27 +1,27 @@ -import { SystemConfigKey } from 'src/entities/system-config.entity'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SmartInfoService } from 'src/services/smart-info.service'; import { getCLIPModelInfo } from 'src/utils/misc'; import { assetStub } from 'test/fixtures/asset.stub'; +import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked } from 'vitest'; describe(SmartInfoService.name, () => { let sut: SmartInfoService; let assetMock: Mocked; - let configMock: Mocked; + let systemMock: Mocked; let jobMock: Mocked; let searchMock: Mocked; let machineMock: Mocked; @@ -30,13 +30,13 @@ describe(SmartInfoService.name, () => { beforeEach(() => { assetMock = newAssetRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); searchMock = newSearchRepositoryMock(); jobMock = newJobRepositoryMock(); machineMock = newMachineLearningRepositoryMock(); databaseMock = newDatabaseRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, searchMock, configMock, loggerMock); + sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, searchMock, systemMock, loggerMock); assetMock.getByIds.mockResolvedValue([assetStub.image]); }); @@ -47,7 +47,7 @@ describe(SmartInfoService.name, () => { describe('handleQueueEncodeClip', () => { it('should do nothing if machine learning is disabled', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await sut.handleQueueEncodeClip({}); @@ -84,7 +84,7 @@ describe(SmartInfoService.name, () => { describe('handleEncodeClip', () => { it('should do nothing if machine learning is disabled', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED); diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index ef2c379770..f902aa7e57 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -14,7 +14,7 @@ import { import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { isSmartSearchEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @@ -28,11 +28,11 @@ export class SmartInfoService { @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, @Inject(ISearchRepository) private repository: ISearchRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(SmartInfoService.name); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } async init() { diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 26263cafbd..c1a47cdcf0 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -3,7 +3,6 @@ import { SystemConfig, defaults } from 'src/config'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType } from 'src/entities/move.entity'; -import { SystemConfigKey } from 'src/entities/system-config.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -13,7 +12,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { assetStub } from 'test/fixtures/asset.stub'; @@ -26,7 +25,7 @@ import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.moc import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mocked } from 'vitest'; @@ -34,13 +33,13 @@ describe(StorageTemplateService.name, () => { let sut: StorageTemplateService; let albumMock: Mocked; let assetMock: Mocked; - let configMock: Mocked; + let cryptoMock: Mocked; + let databaseMock: Mocked; let moveMock: Mocked; let personMock: Mocked; let storageMock: Mocked; + let systemMock: Mocked; let userMock: Mocked; - let cryptoMock: Mocked; - let databaseMock: Mocked; let loggerMock: Mocked; it('should work', () => { @@ -48,23 +47,23 @@ describe(StorageTemplateService.name, () => { }); beforeEach(() => { - configMock = newSystemConfigRepositoryMock(); assetMock = newAssetRepositoryMock(); albumMock = newAlbumRepositoryMock(); + cryptoMock = newCryptoRepositoryMock(); + databaseMock = newDatabaseRepositoryMock(); moveMock = newMoveRepositoryMock(); personMock = newPersonRepositoryMock(); storageMock = newStorageRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); userMock = newUserRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - databaseMock = newDatabaseRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.STORAGE_TEMPLATE_ENABLED, value: true }]); + systemMock.get.mockResolvedValue({ storageTemplate: { enabled: true } }); sut = new StorageTemplateService( albumMock, assetMock, - configMock, + systemMock, moveMock, personMock, storageMock, @@ -74,7 +73,7 @@ describe(StorageTemplateService.name, () => { loggerMock, ); - SystemConfigCore.create(configMock, loggerMock).config$.next(defaults); + SystemConfigCore.create(systemMock, loggerMock).config$.next(defaults); }); describe('onValidateConfig', () => { @@ -108,7 +107,7 @@ describe(StorageTemplateService.name, () => { describe('handleMigrationSingle', () => { it('should skip when storage template is disabled', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.STORAGE_TEMPLATE_ENABLED, value: false }]); + systemMock.get.mockResolvedValue({ storageTemplate: { enabled: false } }); await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); expect(assetMock.getByIds).not.toHaveBeenCalled(); expect(storageMock.checkFileExists).not.toHaveBeenCalled(); diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index f7d25054af..945b6f4500 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -28,7 +28,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { getLivePhotoMotionFilename } from 'src/utils/file'; import { usePagination } from 'src/utils/pagination'; @@ -65,7 +65,7 @@ export class StorageTemplateService { constructor( @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IMoveRepository) moveRepository: IMoveRepository, @Inject(IPersonRepository) personRepository: IPersonRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @@ -75,7 +75,7 @@ export class StorageTemplateService { @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(StorageTemplateService.name); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.configCore.config$.subscribe((config) => this.onConfig(config)); this.storageCore = StorageCore.create( assetRepository, @@ -83,7 +83,7 @@ export class StorageTemplateService { moveRepository, personRepository, storageRepository, - configRepository, + systemMetadataRepository, this.logger, ); } diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index d345d55df6..e349b2fc11 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -12,24 +12,25 @@ import { VideoCodec, defaults, } from 'src/config'; -import { SystemConfigEntity, SystemConfigKey } from 'src/entities/system-config.entity'; +import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; import { IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; import { QueueName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SystemConfigService } from 'src/services/system-config.service'; import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { DeepPartial } from 'typeorm'; import { Mocked } from 'vitest'; -const updates: SystemConfigEntity[] = [ - { key: SystemConfigKey.FFMPEG_CRF, value: 30 }, - { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true }, - { key: SystemConfigKey.TRASH_DAYS, value: 10 }, - { key: SystemConfigKey.USER_DELETE_DELAY, value: 15 }, -]; +const partialConfig = { + ffmpeg: { crf: 30 }, + oauth: { autoLaunch: true }, + trash: { days: 10 }, + user: { deleteDelay: 15 }, +} satisfies DeepPartial; const updatedConfig = Object.freeze({ job: { @@ -171,17 +172,17 @@ const updatedConfig = Object.freeze({ describe(SystemConfigService.name, () => { let sut: SystemConfigService; - let configMock: Mocked; + let systemMock: Mocked; let eventMock: Mocked; let loggerMock: Mocked; let smartInfoMock: Mocked; beforeEach(() => { delete process.env.IMMICH_CONFIG_FILE; - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); eventMock = newEventRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - sut = new SystemConfigService(configMock, eventMock, loggerMock, smartInfoMock); + sut = new SystemConfigService(systemMock, eventMock, loggerMock, smartInfoMock); }); it('should work', () => { @@ -190,44 +191,39 @@ describe(SystemConfigService.name, () => { describe('getDefaults', () => { it('should return the default config', () => { - configMock.load.mockResolvedValue(updates); + systemMock.get.mockResolvedValue(partialConfig); expect(sut.getDefaults()).toEqual(defaults); - expect(configMock.load).not.toHaveBeenCalled(); + expect(systemMock.get).not.toHaveBeenCalled(); }); }); describe('getConfig', () => { it('should return the default config', async () => { - configMock.load.mockResolvedValue([]); + systemMock.get.mockResolvedValue({}); await expect(sut.getConfig()).resolves.toEqual(defaults); }); it('should merge the overrides', async () => { - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_CRF, value: 30 }, - { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true }, - { key: SystemConfigKey.TRASH_DAYS, value: 10 }, - { key: SystemConfigKey.USER_DELETE_DELAY, value: 15 }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { crf: 30 }, + oauth: { autoLaunch: true }, + trash: { days: 10 }, + user: { deleteDelay: 15 }, + }); await expect(sut.getConfig()).resolves.toEqual(updatedConfig); }); it('should load the config from a json file', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; - const partialConfig = { - ffmpeg: { crf: 30 }, - oauth: { autoLaunch: true }, - trash: { days: 10 }, - user: { deleteDelay: 15 }, - }; - configMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + + systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); await expect(sut.getConfig()).resolves.toEqual(updatedConfig); - expect(configMock.readFile).toHaveBeenCalledWith('immich-config.json'); + expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); }); it('should load the config from a yaml file', async () => { @@ -242,26 +238,26 @@ describe(SystemConfigService.name, () => { user: deleteDelay: 15 `; - configMock.readFile.mockResolvedValue(partialConfig); + systemMock.readFile.mockResolvedValue(partialConfig); await expect(sut.getConfig()).resolves.toEqual(updatedConfig); - expect(configMock.readFile).toHaveBeenCalledWith('immich-config.yaml'); + expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.yaml'); }); it('should accept an empty configuration file', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; - configMock.readFile.mockResolvedValue(JSON.stringify({})); + systemMock.readFile.mockResolvedValue(JSON.stringify({})); await expect(sut.getConfig()).resolves.toEqual(defaults); - expect(configMock.readFile).toHaveBeenCalledWith('immich-config.json'); + expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); }); it('should allow underscores in the machine learning url', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; const partialConfig = { machineLearning: { url: 'immich_machine_learning' } }; - configMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); const config = await sut.getConfig(); expect(config.machineLearning.url).toEqual('immich_machine_learning'); @@ -272,7 +268,7 @@ describe(SystemConfigService.name, () => { const partialConfig = ` unknownOption: true `; - configMock.readFile.mockResolvedValue(partialConfig); + systemMock.readFile.mockResolvedValue(partialConfig); await sut.getConfig(); expect(loggerMock.warn).toHaveBeenCalled(); @@ -290,7 +286,7 @@ describe(SystemConfigService.name, () => { for (const test of tests) { it(`should ${test.should}`, async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; - configMock.readFile.mockResolvedValue(JSON.stringify(test.config)); + systemMock.readFile.mockResolvedValue(JSON.stringify(test.config)); if (test.warn) { await sut.getConfig(); @@ -338,20 +334,20 @@ describe(SystemConfigService.name, () => { describe('updateConfig', () => { it('should update the config and emit client and server events', async () => { - configMock.load.mockResolvedValue(updates); + systemMock.get.mockResolvedValue(partialConfig); await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig); expect(eventMock.clientBroadcast).toHaveBeenCalled(); expect(eventMock.serverSend).toHaveBeenCalledWith(ServerEvent.CONFIG_UPDATE, null); - expect(configMock.saveAll).toHaveBeenCalledWith(updates); + expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); }); it('should throw an error if a config file is in use', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; - configMock.readFile.mockResolvedValue(JSON.stringify({})); + systemMock.readFile.mockResolvedValue(JSON.stringify({})); await expect(sut.updateConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); - expect(configMock.saveAll).not.toHaveBeenCalled(); + expect(systemMock.set).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index 2e4ae8cb5f..3474771875 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -24,14 +24,14 @@ import { } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @Injectable() export class SystemConfigService { private core: SystemConfigCore; constructor( - @Inject(ISystemConfigRepository) private repository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) private repository: ISystemMetadataRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ISearchRepository) private smartInfoRepository: ISearchRepository, diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 1bf4fc1012..da9c375131 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -12,7 +12,7 @@ import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { UserService } from 'src/services/user.service'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; @@ -25,7 +25,7 @@ import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mocked, vitest } from 'vitest'; @@ -44,12 +44,12 @@ describe(UserService.name, () => { let jobMock: Mocked; let libraryMock: Mocked; let storageMock: Mocked; - let configMock: Mocked; + let systemMock: Mocked; let loggerMock: Mocked; beforeEach(() => { albumMock = newAlbumRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); cryptoRepositoryMock = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); libraryMock = newLibraryRepositoryMock(); @@ -63,7 +63,7 @@ describe(UserService.name, () => { jobMock, libraryMock, storageMock, - configMock, + systemMock, userMock, loggerMock, ); @@ -486,7 +486,7 @@ describe(UserService.name, () => { }); it('should skip users not ready for deletion - deleteDelay30', async () => { - configMock.load.mockResolvedValue(systemConfigStub.deleteDelay30); + systemMock.get.mockResolvedValue(systemConfigStub.deleteDelay30); userMock.getDeletedUsers.mockResolvedValue([ {}, { deletedAt: undefined }, diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index c367e3985f..0df2ecae94 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -13,7 +13,7 @@ import { IEntityJob, IJobRepository, JobName, JobStatus } from 'src/interfaces/j import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; @@ -28,13 +28,13 @@ export class UserService { @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ILibraryRepository) libraryRepository: ILibraryRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository); this.logger.setContext(UserService.name); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } async listUsers(): Promise { diff --git a/server/src/utils/misc.spec.ts b/server/src/utils/misc.spec.ts new file mode 100644 index 0000000000..c36772ad43 --- /dev/null +++ b/server/src/utils/misc.spec.ts @@ -0,0 +1,52 @@ +import { getKeysDeep, unsetDeep } from 'src/utils/misc'; +import { describe, expect, it } from 'vitest'; + +describe('getKeysDeep', () => { + it('should handle an empty object', () => { + expect(getKeysDeep({})).toEqual([]); + }); + + it('should list properties', () => { + expect( + getKeysDeep({ + foo: 'bar', + flag: true, + count: 42, + }), + ).toEqual(['foo', 'flag', 'count']); + }); + + it('should skip undefined properties', () => { + expect(getKeysDeep({ foo: 'bar', hello: undefined })).toEqual(['foo']); + }); + + it('should skip array indices', () => { + expect(getKeysDeep({ foo: 'bar', hello: ['foo', 'bar'] })).toEqual(['foo', 'hello']); + expect(getKeysDeep({ foo: 'bar', nested: { hello: ['foo', 'bar'] } })).toEqual(['foo', 'nested.hello']); + }); + + it('should list nested properties', () => { + expect(getKeysDeep({ foo: 'bar', hello: { world: true } })).toEqual(['foo', 'hello.world']); + }); +}); + +describe('unsetDeep', () => { + it('should remove a property', () => { + expect(unsetDeep({ hello: 'world', foo: 'bar' }, 'foo')).toEqual({ hello: 'world' }); + }); + + it('should remove the last property', () => { + expect(unsetDeep({ foo: 'bar' }, 'foo')).toBeUndefined(); + }); + + it('should remove a nested property', () => { + expect(unsetDeep({ foo: 'bar', nested: { enabled: true, count: 42 } }, 'nested.enabled')).toEqual({ + foo: 'bar', + nested: { count: 42 }, + }); + }); + + it('should clean up an empty property', () => { + expect(unsetDeep({ foo: 'bar', nested: { enabled: true } }, 'nested.enabled')).toEqual({ foo: 'bar' }); + }); +}); diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 7a98fea139..ce0a0df4b7 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -16,6 +16,47 @@ import { ImmichCookie, ImmichHeader } from 'src/dtos/auth.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { Metadata } from 'src/middleware/auth.guard'; +/** + * @returns a list of strings representing the keys of the object in dot notation + */ +export const getKeysDeep = (target: unknown, path: string[] = []) => { + if (!target || typeof target !== 'object') { + return []; + } + + const obj = target as object; + + const properties: string[] = []; + for (const key of Object.keys(obj as object)) { + const value = obj[key as keyof object]; + if (value === undefined) { + continue; + } + + if (_.isObject(value) && !_.isArray(value)) { + properties.push(...getKeysDeep(value, [...path, key])); + continue; + } + + properties.push([...path, key].join('.')); + } + + return properties; +}; + +export const unsetDeep = (object: unknown, key: string) => { + const parts = key.split('.'); + while (parts.length > 0) { + _.unset(object, parts); + parts.pop(); + if (!_.isEmpty(_.get(object, parts))) { + break; + } + } + + return _.isEmpty(object) ? undefined : object; +}; + const isMachineLearningEnabled = (machineLearning: SystemConfig['machineLearning']) => machineLearning.enabled; export const isSmartSearchEnabled = (machineLearning: SystemConfig['machineLearning']) => isMachineLearningEnabled(machineLearning) && machineLearning.clip.enabled; diff --git a/server/test/fixtures/system-config.stub.ts b/server/test/fixtures/system-config.stub.ts index b557644efa..c01fd212ec 100644 --- a/server/test/fixtures/system-config.stub.ts +++ b/server/test/fixtures/system-config.stub.ts @@ -1,33 +1,74 @@ -import { SystemConfigEntity, SystemConfigKey } from 'src/entities/system-config.entity'; +import { SystemConfig } from 'src/config'; +import { DeepPartial } from 'typeorm'; -export const systemConfigStub: Record = { - defaults: [], - enabled: [ - { key: SystemConfigKey.OAUTH_ENABLED, value: true }, - { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true }, - { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false }, - { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' }, - ], - disabled: [{ key: SystemConfigKey.PASSWORD_LOGIN_ENABLED, value: false }], - noAutoRegister: [ - { key: SystemConfigKey.OAUTH_ENABLED, value: true }, - { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false }, - { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: false }, - { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' }, - ], - override: [ - { key: SystemConfigKey.OAUTH_ENABLED, value: true }, - { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true }, - { key: SystemConfigKey.OAUTH_MOBILE_OVERRIDE_ENABLED, value: true }, - { key: SystemConfigKey.OAUTH_MOBILE_REDIRECT_URI, value: 'http://mobile-redirect' }, - { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' }, - ], - withDefaultStorageQuota: [ - { key: SystemConfigKey.OAUTH_ENABLED, value: true }, - { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true }, - { key: SystemConfigKey.OAUTH_DEFAULT_STORAGE_QUOTA, value: 1 }, - ], - deleteDelay30: [{ key: SystemConfigKey.USER_DELETE_DELAY, value: 30 }], - libraryWatchEnabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: true }], - libraryWatchDisabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: false }], -}; +export const systemConfigStub = { + enabled: { + oauth: { + enabled: true, + autoRegister: true, + autoLaunch: false, + buttonText: 'OAuth', + }, + }, + disabled: { + passwordLogin: { + enabled: false, + }, + }, + noAutoRegister: { + oauth: { + enabled: true, + autoRegister: false, + autoLaunch: false, + buttonText: 'OAuth', + }, + }, + override: { + oauth: { + enabled: true, + autoRegister: true, + mobileOverrideEnabled: true, + mobileRedirectUri: 'http://mobile-redirect', + buttonText: 'OAuth', + }, + }, + withDefaultStorageQuota: { + oauth: { + enabled: true, + autoRegister: true, + defaultStorageQuota: 1, + }, + }, + deleteDelay30: { + user: { + deleteDelay: 30, + }, + }, + libraryWatchEnabled: { + library: { + watch: { + enabled: true, + }, + }, + }, + libraryWatchDisabled: { + library: { + watch: { + enabled: false, + }, + }, + }, + libraryScan: { + library: { + scan: { + enabled: true, + cronExpression: '0 0 * * *', + }, + }, + }, + machineLearningDisabled: { + machineLearning: { + enabled: false, + }, + }, +} satisfies Record>; diff --git a/server/test/repositories/system-config.repository.mock.ts b/server/test/repositories/system-config.repository.mock.ts deleted file mode 100644 index 41135b7d74..0000000000 --- a/server/test/repositories/system-config.repository.mock.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newSystemConfigRepositoryMock = (reset = true): Mocked => { - if (reset) { - SystemConfigCore.reset(); - } - - return { - fetchStyle: vitest.fn(), - load: vitest.fn().mockResolvedValue([]), - readFile: vitest.fn(), - saveAll: vitest.fn().mockResolvedValue([]), - deleteKeys: vitest.fn(), - }; -}; diff --git a/server/test/repositories/system-metadata.repository.mock.ts b/server/test/repositories/system-metadata.repository.mock.ts index 1044076ea8..d0cf4fe2e5 100644 --- a/server/test/repositories/system-metadata.repository.mock.ts +++ b/server/test/repositories/system-metadata.repository.mock.ts @@ -1,9 +1,16 @@ +import { SystemConfigCore } from 'src/cores/system-config.core'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { Mocked, vitest } from 'vitest'; -export const newSystemMetadataRepositoryMock = (): Mocked => { +export const newSystemMetadataRepositoryMock = (reset = true): Mocked => { + if (reset) { + SystemConfigCore.reset(); + } + return { get: vitest.fn() as any, set: vitest.fn(), + readFile: vitest.fn(), + fetchStyle: vitest.fn(), }; };