mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	chore(server): better typing for system config key (#8580)
* config type safety * typeorm fix * typing fixes * don't use enum in db * add todo
This commit is contained in:
		
							parent
							
								
									4681ff88d0
								
							
						
					
					
						commit
						335c03d0b8
					
				@ -1,117 +1,137 @@
 | 
				
			|||||||
import { ConcurrentQueueName } from 'src/interfaces/job.interface';
 | 
					import { ConcurrentQueueName } from 'src/interfaces/job.interface';
 | 
				
			||||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
 | 
					import { Column, Entity, PrimaryColumn } from 'typeorm';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Entity('system_config')
 | 
					export type SystemConfigValue = string | string[] | number | boolean;
 | 
				
			||||||
export class SystemConfigEntity<T = SystemConfigValue> {
 | 
					 | 
				
			||||||
  @PrimaryColumn()
 | 
					 | 
				
			||||||
  key!: SystemConfigKey;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Column({ type: 'varchar', nullable: true, transformer: { to: JSON.stringify, from: JSON.parse } })
 | 
					// https://stackoverflow.com/a/47058976
 | 
				
			||||||
  value!: T | T[];
 | 
					// https://stackoverflow.com/a/70692231
 | 
				
			||||||
}
 | 
					type PathsToStringProps<T> = T extends SystemConfigValue
 | 
				
			||||||
 | 
					  ? []
 | 
				
			||||||
 | 
					  : {
 | 
				
			||||||
 | 
					      [K in keyof T]: [K, ...PathsToStringProps<T[K]>];
 | 
				
			||||||
 | 
					    }[keyof T];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type SystemConfigValue = string | number | boolean;
 | 
					type Join<T extends string[], D extends string> = T extends []
 | 
				
			||||||
 | 
					  ? never
 | 
				
			||||||
 | 
					  : T extends [infer F]
 | 
				
			||||||
 | 
					    ? F
 | 
				
			||||||
 | 
					    : T extends [infer F, ...infer R]
 | 
				
			||||||
 | 
					      ? F extends string
 | 
				
			||||||
 | 
					        ? `${F}${D}${Join<Extract<R, string[]>, D>}`
 | 
				
			||||||
 | 
					        : never
 | 
				
			||||||
 | 
					      : string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// dot notation matches path in `SystemConfig`
 | 
					// dot notation matches path in `SystemConfig`
 | 
				
			||||||
export enum SystemConfigKey {
 | 
					// TODO: migrate to key value per section
 | 
				
			||||||
  FFMPEG_CRF = 'ffmpeg.crf',
 | 
					export const SystemConfigKey = {
 | 
				
			||||||
  FFMPEG_THREADS = 'ffmpeg.threads',
 | 
					  FFMPEG_CRF: 'ffmpeg.crf',
 | 
				
			||||||
  FFMPEG_PRESET = 'ffmpeg.preset',
 | 
					  FFMPEG_THREADS: 'ffmpeg.threads',
 | 
				
			||||||
  FFMPEG_TARGET_VIDEO_CODEC = 'ffmpeg.targetVideoCodec',
 | 
					  FFMPEG_PRESET: 'ffmpeg.preset',
 | 
				
			||||||
  FFMPEG_ACCEPTED_VIDEO_CODECS = 'ffmpeg.acceptedVideoCodecs',
 | 
					  FFMPEG_TARGET_VIDEO_CODEC: 'ffmpeg.targetVideoCodec',
 | 
				
			||||||
  FFMPEG_TARGET_AUDIO_CODEC = 'ffmpeg.targetAudioCodec',
 | 
					  FFMPEG_ACCEPTED_VIDEO_CODECS: 'ffmpeg.acceptedVideoCodecs',
 | 
				
			||||||
  FFMPEG_ACCEPTED_AUDIO_CODECS = 'ffmpeg.acceptedAudioCodecs',
 | 
					  FFMPEG_TARGET_AUDIO_CODEC: 'ffmpeg.targetAudioCodec',
 | 
				
			||||||
  FFMPEG_TARGET_RESOLUTION = 'ffmpeg.targetResolution',
 | 
					  FFMPEG_ACCEPTED_AUDIO_CODECS: 'ffmpeg.acceptedAudioCodecs',
 | 
				
			||||||
  FFMPEG_MAX_BITRATE = 'ffmpeg.maxBitrate',
 | 
					  FFMPEG_TARGET_RESOLUTION: 'ffmpeg.targetResolution',
 | 
				
			||||||
  FFMPEG_BFRAMES = 'ffmpeg.bframes',
 | 
					  FFMPEG_MAX_BITRATE: 'ffmpeg.maxBitrate',
 | 
				
			||||||
  FFMPEG_REFS = 'ffmpeg.refs',
 | 
					  FFMPEG_BFRAMES: 'ffmpeg.bframes',
 | 
				
			||||||
  FFMPEG_GOP_SIZE = 'ffmpeg.gopSize',
 | 
					  FFMPEG_REFS: 'ffmpeg.refs',
 | 
				
			||||||
  FFMPEG_NPL = 'ffmpeg.npl',
 | 
					  FFMPEG_GOP_SIZE: 'ffmpeg.gopSize',
 | 
				
			||||||
  FFMPEG_TEMPORAL_AQ = 'ffmpeg.temporalAQ',
 | 
					  FFMPEG_NPL: 'ffmpeg.npl',
 | 
				
			||||||
  FFMPEG_CQ_MODE = 'ffmpeg.cqMode',
 | 
					  FFMPEG_TEMPORAL_AQ: 'ffmpeg.temporalAQ',
 | 
				
			||||||
  FFMPEG_TWO_PASS = 'ffmpeg.twoPass',
 | 
					  FFMPEG_CQ_MODE: 'ffmpeg.cqMode',
 | 
				
			||||||
  FFMPEG_PREFERRED_HW_DEVICE = 'ffmpeg.preferredHwDevice',
 | 
					  FFMPEG_TWO_PASS: 'ffmpeg.twoPass',
 | 
				
			||||||
  FFMPEG_TRANSCODE = 'ffmpeg.transcode',
 | 
					  FFMPEG_PREFERRED_HW_DEVICE: 'ffmpeg.preferredHwDevice',
 | 
				
			||||||
  FFMPEG_ACCEL = 'ffmpeg.accel',
 | 
					  FFMPEG_TRANSCODE: 'ffmpeg.transcode',
 | 
				
			||||||
  FFMPEG_TONEMAP = 'ffmpeg.tonemap',
 | 
					  FFMPEG_ACCEL: 'ffmpeg.accel',
 | 
				
			||||||
 | 
					  FFMPEG_TONEMAP: 'ffmpeg.tonemap',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  JOB_THUMBNAIL_GENERATION_CONCURRENCY = 'job.thumbnailGeneration.concurrency',
 | 
					  JOB_THUMBNAIL_GENERATION_CONCURRENCY: 'job.thumbnailGeneration.concurrency',
 | 
				
			||||||
  JOB_METADATA_EXTRACTION_CONCURRENCY = 'job.metadataExtraction.concurrency',
 | 
					  JOB_METADATA_EXTRACTION_CONCURRENCY: 'job.metadataExtraction.concurrency',
 | 
				
			||||||
  JOB_VIDEO_CONVERSION_CONCURRENCY = 'job.videoConversion.concurrency',
 | 
					  JOB_VIDEO_CONVERSION_CONCURRENCY: 'job.videoConversion.concurrency',
 | 
				
			||||||
  JOB_FACE_DETECTION_CONCURRENCY = 'job.faceDetection.concurrency',
 | 
					  JOB_FACE_DETECTION_CONCURRENCY: 'job.faceDetection.concurrency',
 | 
				
			||||||
  JOB_CLIP_ENCODING_CONCURRENCY = 'job.smartSearch.concurrency',
 | 
					  JOB_CLIP_ENCODING_CONCURRENCY: 'job.smartSearch.concurrency',
 | 
				
			||||||
  JOB_BACKGROUND_TASK_CONCURRENCY = 'job.backgroundTask.concurrency',
 | 
					  JOB_BACKGROUND_TASK_CONCURRENCY: 'job.backgroundTask.concurrency',
 | 
				
			||||||
  JOB_STORAGE_TEMPLATE_MIGRATION_CONCURRENCY = 'job.storageTemplateMigration.concurrency',
 | 
					  JOB_SEARCH_CONCURRENCY: 'job.search.concurrency',
 | 
				
			||||||
  JOB_SEARCH_CONCURRENCY = 'job.search.concurrency',
 | 
					  JOB_SIDECAR_CONCURRENCY: 'job.sidecar.concurrency',
 | 
				
			||||||
  JOB_SIDECAR_CONCURRENCY = 'job.sidecar.concurrency',
 | 
					  JOB_LIBRARY_CONCURRENCY: 'job.library.concurrency',
 | 
				
			||||||
  JOB_LIBRARY_CONCURRENCY = 'job.library.concurrency',
 | 
					  JOB_MIGRATION_CONCURRENCY: 'job.migration.concurrency',
 | 
				
			||||||
  JOB_MIGRATION_CONCURRENCY = 'job.migration.concurrency',
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  LIBRARY_SCAN_ENABLED = 'library.scan.enabled',
 | 
					  LIBRARY_SCAN_ENABLED: 'library.scan.enabled',
 | 
				
			||||||
  LIBRARY_SCAN_CRON_EXPRESSION = 'library.scan.cronExpression',
 | 
					  LIBRARY_SCAN_CRON_EXPRESSION: 'library.scan.cronExpression',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  LIBRARY_WATCH_ENABLED = 'library.watch.enabled',
 | 
					  LIBRARY_WATCH_ENABLED: 'library.watch.enabled',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  LOGGING_ENABLED = 'logging.enabled',
 | 
					  LOGGING_ENABLED: 'logging.enabled',
 | 
				
			||||||
  LOGGING_LEVEL = 'logging.level',
 | 
					  LOGGING_LEVEL: 'logging.level',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  MACHINE_LEARNING_ENABLED = 'machineLearning.enabled',
 | 
					  MACHINE_LEARNING_ENABLED: 'machineLearning.enabled',
 | 
				
			||||||
  MACHINE_LEARNING_URL = 'machineLearning.url',
 | 
					  MACHINE_LEARNING_URL: 'machineLearning.url',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  MACHINE_LEARNING_CLIP_ENABLED = 'machineLearning.clip.enabled',
 | 
					  MACHINE_LEARNING_CLIP_ENABLED: 'machineLearning.clip.enabled',
 | 
				
			||||||
  MACHINE_LEARNING_CLIP_MODEL_NAME = 'machineLearning.clip.modelName',
 | 
					  MACHINE_LEARNING_CLIP_MODEL_NAME: 'machineLearning.clip.modelName',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  MACHINE_LEARNING_FACIAL_RECOGNITION_ENABLED = 'machineLearning.facialRecognition.enabled',
 | 
					  MACHINE_LEARNING_FACIAL_RECOGNITION_ENABLED: 'machineLearning.facialRecognition.enabled',
 | 
				
			||||||
  MACHINE_LEARNING_FACIAL_RECOGNITION_MODEL_NAME = 'machineLearning.facialRecognition.modelName',
 | 
					  MACHINE_LEARNING_FACIAL_RECOGNITION_MODEL_NAME: 'machineLearning.facialRecognition.modelName',
 | 
				
			||||||
  MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_SCORE = 'machineLearning.facialRecognition.minScore',
 | 
					  MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_SCORE: 'machineLearning.facialRecognition.minScore',
 | 
				
			||||||
  MACHINE_LEARNING_FACIAL_RECOGNITION_MAX_DISTANCE = 'machineLearning.facialRecognition.maxDistance',
 | 
					  MACHINE_LEARNING_FACIAL_RECOGNITION_MAX_DISTANCE: 'machineLearning.facialRecognition.maxDistance',
 | 
				
			||||||
  MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES = 'machineLearning.facialRecognition.minFaces',
 | 
					  MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES: 'machineLearning.facialRecognition.minFaces',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  MAP_ENABLED = 'map.enabled',
 | 
					  MAP_ENABLED: 'map.enabled',
 | 
				
			||||||
  MAP_LIGHT_STYLE = 'map.lightStyle',
 | 
					  MAP_LIGHT_STYLE: 'map.lightStyle',
 | 
				
			||||||
  MAP_DARK_STYLE = 'map.darkStyle',
 | 
					  MAP_DARK_STYLE: 'map.darkStyle',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  REVERSE_GEOCODING_ENABLED = 'reverseGeocoding.enabled',
 | 
					  REVERSE_GEOCODING_ENABLED: 'reverseGeocoding.enabled',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  NEW_VERSION_CHECK_ENABLED = 'newVersionCheck.enabled',
 | 
					  NEW_VERSION_CHECK_ENABLED: 'newVersionCheck.enabled',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  OAUTH_AUTO_LAUNCH = 'oauth.autoLaunch',
 | 
					  OAUTH_AUTO_LAUNCH: 'oauth.autoLaunch',
 | 
				
			||||||
  OAUTH_AUTO_REGISTER = 'oauth.autoRegister',
 | 
					  OAUTH_AUTO_REGISTER: 'oauth.autoRegister',
 | 
				
			||||||
  OAUTH_BUTTON_TEXT = 'oauth.buttonText',
 | 
					  OAUTH_BUTTON_TEXT: 'oauth.buttonText',
 | 
				
			||||||
  OAUTH_CLIENT_ID = 'oauth.clientId',
 | 
					  OAUTH_CLIENT_ID: 'oauth.clientId',
 | 
				
			||||||
  OAUTH_CLIENT_SECRET = 'oauth.clientSecret',
 | 
					  OAUTH_CLIENT_SECRET: 'oauth.clientSecret',
 | 
				
			||||||
  OAUTH_DEFAULT_STORAGE_QUOTA = 'oauth.defaultStorageQuota',
 | 
					  OAUTH_DEFAULT_STORAGE_QUOTA: 'oauth.defaultStorageQuota',
 | 
				
			||||||
  OAUTH_ENABLED = 'oauth.enabled',
 | 
					  OAUTH_ENABLED: 'oauth.enabled',
 | 
				
			||||||
  OAUTH_ISSUER_URL = 'oauth.issuerUrl',
 | 
					  OAUTH_ISSUER_URL: 'oauth.issuerUrl',
 | 
				
			||||||
  OAUTH_MOBILE_OVERRIDE_ENABLED = 'oauth.mobileOverrideEnabled',
 | 
					  OAUTH_MOBILE_OVERRIDE_ENABLED: 'oauth.mobileOverrideEnabled',
 | 
				
			||||||
  OAUTH_MOBILE_REDIRECT_URI = 'oauth.mobileRedirectUri',
 | 
					  OAUTH_MOBILE_REDIRECT_URI: 'oauth.mobileRedirectUri',
 | 
				
			||||||
  OAUTH_SCOPE = 'oauth.scope',
 | 
					  OAUTH_SCOPE: 'oauth.scope',
 | 
				
			||||||
  OAUTH_SIGNING_ALGORITHM = 'oauth.signingAlgorithm',
 | 
					  OAUTH_SIGNING_ALGORITHM: 'oauth.signingAlgorithm',
 | 
				
			||||||
  OAUTH_STORAGE_LABEL_CLAIM = 'oauth.storageLabelClaim',
 | 
					  OAUTH_STORAGE_LABEL_CLAIM: 'oauth.storageLabelClaim',
 | 
				
			||||||
  OAUTH_STORAGE_QUOTA_CLAIM = 'oauth.storageQuotaClaim',
 | 
					  OAUTH_STORAGE_QUOTA_CLAIM: 'oauth.storageQuotaClaim',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled',
 | 
					  PASSWORD_LOGIN_ENABLED: 'passwordLogin.enabled',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  SERVER_EXTERNAL_DOMAIN = 'server.externalDomain',
 | 
					  SERVER_EXTERNAL_DOMAIN: 'server.externalDomain',
 | 
				
			||||||
  SERVER_LOGIN_PAGE_MESSAGE = 'server.loginPageMessage',
 | 
					  SERVER_LOGIN_PAGE_MESSAGE: 'server.loginPageMessage',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  STORAGE_TEMPLATE_ENABLED = 'storageTemplate.enabled',
 | 
					  STORAGE_TEMPLATE_ENABLED: 'storageTemplate.enabled',
 | 
				
			||||||
  STORAGE_TEMPLATE_HASH_VERIFICATION_ENABLED = 'storageTemplate.hashVerificationEnabled',
 | 
					  STORAGE_TEMPLATE_HASH_VERIFICATION_ENABLED: 'storageTemplate.hashVerificationEnabled',
 | 
				
			||||||
  STORAGE_TEMPLATE = 'storageTemplate.template',
 | 
					  STORAGE_TEMPLATE: 'storageTemplate.template',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  IMAGE_THUMBNAIL_FORMAT = 'image.thumbnailFormat',
 | 
					  IMAGE_THUMBNAIL_FORMAT: 'image.thumbnailFormat',
 | 
				
			||||||
  IMAGE_THUMBNAIL_SIZE = 'image.thumbnailSize',
 | 
					  IMAGE_THUMBNAIL_SIZE: 'image.thumbnailSize',
 | 
				
			||||||
  IMAGE_PREVIEW_FORMAT = 'image.previewFormat',
 | 
					  IMAGE_PREVIEW_FORMAT: 'image.previewFormat',
 | 
				
			||||||
  IMAGE_PREVIEW_SIZE = 'image.previewSize',
 | 
					  IMAGE_PREVIEW_SIZE: 'image.previewSize',
 | 
				
			||||||
  IMAGE_QUALITY = 'image.quality',
 | 
					  IMAGE_QUALITY: 'image.quality',
 | 
				
			||||||
  IMAGE_COLORSPACE = 'image.colorspace',
 | 
					  IMAGE_COLORSPACE: 'image.colorspace',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  TRASH_ENABLED = 'trash.enabled',
 | 
					  TRASH_ENABLED: 'trash.enabled',
 | 
				
			||||||
  TRASH_DAYS = 'trash.days',
 | 
					  TRASH_DAYS: 'trash.days',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  THEME_CUSTOM_CSS = 'theme.customCss',
 | 
					  THEME_CUSTOM_CSS: 'theme.customCss',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  USER_DELETE_DELAY = 'user.deleteDelay',
 | 
					  USER_DELETE_DELAY: 'user.deleteDelay',
 | 
				
			||||||
 | 
					} as const satisfies Record<string, Join<PathsToStringProps<SystemConfig>, '.'>>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type SystemConfigKeyPaths = (typeof SystemConfigKey)[keyof typeof SystemConfigKey];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Entity('system_config')
 | 
				
			||||||
 | 
					export class SystemConfigEntity<T = SystemConfigValue> {
 | 
				
			||||||
 | 
					  @PrimaryColumn({ type: 'varchar' })
 | 
				
			||||||
 | 
					  key!: SystemConfigKeyPaths;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Column({ type: 'varchar', nullable: true, transformer: { to: JSON.stringify, from: JSON.parse } })
 | 
				
			||||||
 | 
					  value!: T;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export enum TranscodePolicy {
 | 
					export enum TranscodePolicy {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,6 @@
 | 
				
			|||||||
import { BadRequestException } from '@nestjs/common';
 | 
					import { BadRequestException } from '@nestjs/common';
 | 
				
			||||||
import { FeatureFlag, SystemConfigCore } from 'src/cores/system-config.core';
 | 
					import { FeatureFlag, SystemConfigCore } from 'src/cores/system-config.core';
 | 
				
			||||||
import { SystemConfig, SystemConfigKey } from 'src/entities/system-config.entity';
 | 
					import { SystemConfig, SystemConfigKey, SystemConfigKeyPaths } from 'src/entities/system-config.entity';
 | 
				
			||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
 | 
					import { IAssetRepository } from 'src/interfaces/asset.interface';
 | 
				
			||||||
import { IEventRepository } from 'src/interfaces/event.interface';
 | 
					import { IEventRepository } from 'src/interfaces/event.interface';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
@ -360,7 +360,7 @@ describe(JobService.name, () => {
 | 
				
			|||||||
      });
 | 
					      });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const featureTests: Array<{ queue: QueueName; feature: FeatureFlag; configKey: SystemConfigKey }> = [
 | 
					    const featureTests: Array<{ queue: QueueName; feature: FeatureFlag; configKey: SystemConfigKeyPaths }> = [
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        queue: QueueName.SMART_SEARCH,
 | 
					        queue: QueueName.SMART_SEARCH,
 | 
				
			||||||
        feature: FeatureFlag.SMART_SEARCH,
 | 
					        feature: FeatureFlag.SMART_SEARCH,
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user