diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 928e0b26e..4e081c896 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -169,8 +169,6 @@ Redis (Sentinel) URL example JSON before encoding: | `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | | `MACHINE_LEARNING_DEVICE_IDS`\*4 | Device IDs to use in multi-GPU environments | `0` | machine learning | | `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning | -| `MACHINE_LEARNING_PING_TIMEOUT` | How long (ms) to wait for a PING response when checking if an ML server is available | `2000` | server | -| `MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME` | How long to ignore ML servers that are offline before trying again | `30000` | server | | `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning | | `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning | diff --git a/i18n/en.json b/i18n/en.json index aa1999adc..af52770c6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -123,6 +123,13 @@ "logging_enable_description": "Enable logging", "logging_level_description": "When enabled, what log level to use.", "logging_settings": "Logging", + "machine_learning_availability_checks": "Availability checks", + "machine_learning_availability_checks_description": "Automatically detect and prefer available machine learning servers", + "machine_learning_availability_checks_enabled": "Enable availability checks", + "machine_learning_availability_checks_interval": "Check interval", + "machine_learning_availability_checks_interval_description": "Interval in milliseconds between availability checks", + "machine_learning_availability_checks_timeout": "Request timeout", + "machine_learning_availability_checks_timeout_description": "Timeout in milliseconds for availability checks", "machine_learning_clip_model": "CLIP model", "machine_learning_clip_model_description": "The name of a CLIP model listed here. Note that you must re-run the 'Smart Search' job for all images upon changing a model.", "machine_learning_duplicate_detection": "Duplicate Detection", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 239c62449..7a426b74f 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e87c160d9..df2c2226b 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index ae5fd9227..06d27593c 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/machine_learning_availability_checks_dto.dart b/mobile/openapi/lib/model/machine_learning_availability_checks_dto.dart new file mode 100644 index 000000000..84b318142 Binary files /dev/null and b/mobile/openapi/lib/model/machine_learning_availability_checks_dto.dart differ diff --git a/mobile/openapi/lib/model/system_config_machine_learning_dto.dart b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart index a4a9ca7d8..d7b2566d5 100644 Binary files a/mobile/openapi/lib/model/system_config_machine_learning_dto.dart and b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0b0391326..d3fe155a8 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -12259,6 +12259,25 @@ ], "type": "object" }, + "MachineLearningAvailabilityChecksDto": { + "properties": { + "enabled": { + "type": "boolean" + }, + "interval": { + "type": "number" + }, + "timeout": { + "type": "number" + } + }, + "required": [ + "enabled", + "interval", + "timeout" + ], + "type": "object" + }, "ManualJobName": { "enum": [ "person-cleanup", @@ -16395,6 +16414,9 @@ }, "SystemConfigMachineLearningDto": { "properties": { + "availabilityChecks": { + "$ref": "#/components/schemas/MachineLearningAvailabilityChecksDto" + }, "clip": { "$ref": "#/components/schemas/CLIPConfig" }, @@ -16407,11 +16429,6 @@ "facialRecognition": { "$ref": "#/components/schemas/FacialRecognitionConfig" }, - "url": { - "deprecated": true, - "description": "This property was deprecated in v1.122.0", - "type": "string" - }, "urls": { "format": "uri", "items": { @@ -16423,6 +16440,7 @@ } }, "required": [ + "availabilityChecks", "clip", "duplicateDetection", "enabled", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 079dbda63..a8b1b0f59 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1383,6 +1383,11 @@ export type SystemConfigLoggingDto = { enabled: boolean; level: LogLevel; }; +export type MachineLearningAvailabilityChecksDto = { + enabled: boolean; + interval: number; + timeout: number; +}; export type ClipConfig = { enabled: boolean; modelName: string; @@ -1399,12 +1404,11 @@ export type FacialRecognitionConfig = { modelName: string; }; export type SystemConfigMachineLearningDto = { + availabilityChecks: MachineLearningAvailabilityChecksDto; clip: ClipConfig; duplicateDetection: DuplicateDetectionConfig; enabled: boolean; facialRecognition: FacialRecognitionConfig; - /** This property was deprecated in v1.122.0 */ - url?: string; urls: string[]; }; export type SystemConfigMapDto = { diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts index 6d3cb42fa..b63233206 100644 --- a/server/src/bin/sync-sql.ts +++ b/server/src/bin/sync-sql.ts @@ -15,6 +15,7 @@ import { repositories } from 'src/repositories'; import { AccessRepository } from 'src/repositories/access.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; import { SyncRepository } from 'src/repositories/sync.repository'; import { AuthService } from 'src/services/auth.service'; import { getKyselyConfig } from 'src/utils/database'; @@ -57,7 +58,7 @@ class SqlGenerator { try { await this.setup(); for (const Repository of repositories) { - if (Repository === LoggingRepository) { + if (Repository === LoggingRepository || Repository === MachineLearningRepository) { continue; } await this.process(Repository); diff --git a/server/src/config.ts b/server/src/config.ts index 0d1e293be..66c03450f 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -54,6 +54,11 @@ export interface SystemConfig { machineLearning: { enabled: boolean; urls: string[]; + availabilityChecks: { + enabled: boolean; + timeout: number; + interval: number; + }; clip: { enabled: boolean; modelName: string; @@ -176,6 +181,8 @@ export interface SystemConfig { }; } +export type MachineLearningConfig = SystemConfig['machineLearning']; + export const defaults = Object.freeze({ backup: { database: { @@ -227,6 +234,11 @@ export const defaults = Object.freeze({ machineLearning: { enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false', urls: [process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'], + availabilityChecks: { + enabled: true, + timeout: Number(process.env.IMMICH_MACHINE_LEARNING_PING_TIMEOUT) || 2000, + interval: 30_000, + }, clip: { enabled: true, modelName: 'ViT-B-32__openai', diff --git a/server/src/constants.ts b/server/src/constants.ts index b47640c4a..1bae521a9 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -51,11 +51,6 @@ export const serverVersion = new SemVer(version); export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 }); export const ONE_HOUR = Duration.fromObject({ hours: 1 }); -export const MACHINE_LEARNING_PING_TIMEOUT = Number(process.env.MACHINE_LEARNING_PING_TIMEOUT || 2000); -export const MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME = Number( - process.env.MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME || 30_000, -); - export const citiesFile = 'cities500.txt'; export const reverseGeocodeMaxDistance = 25_000; diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 8a58995de..1facc6c33 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Exclude, Transform, Type } from 'class-transformer'; +import { Type } from 'class-transformer'; import { ArrayMinSize, IsInt, @@ -15,7 +15,6 @@ import { ValidateNested, } from 'class-validator'; import { SystemConfig } from 'src/config'; -import { PropertyLifecycle } from 'src/decorators'; import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto'; import { AudioCodec, @@ -257,21 +256,32 @@ class SystemConfigLoggingDto { level!: LogLevel; } +class MachineLearningAvailabilityChecksDto { + @ValidateBoolean() + enabled!: boolean; + + @IsInt() + timeout!: number; + + @IsInt() + interval!: number; +} + class SystemConfigMachineLearningDto { @ValidateBoolean() enabled!: boolean; - @PropertyLifecycle({ deprecatedAt: 'v1.122.0' }) - @Exclude() - url?: string; - @IsUrl({ require_tld: false, allow_underscores: true }, { each: true }) @ArrayMinSize(1) - @Transform(({ obj, value }) => (obj.url ? [obj.url] : value)) @ValidateIf((dto) => dto.enabled) @ApiProperty({ type: 'array', items: { type: 'string', format: 'uri' }, minItems: 1 }) urls!: string[]; + @Type(() => MachineLearningAvailabilityChecksDto) + @ValidateNested() + @IsObject() + availabilityChecks!: MachineLearningAvailabilityChecksDto; + @Type(() => CLIPConfig) @ValidateNested() @IsObject() diff --git a/server/src/repositories/logging.repository.ts b/server/src/repositories/logging.repository.ts index 939ecb718..576ee6c81 100644 --- a/server/src/repositories/logging.repository.ts +++ b/server/src/repositories/logging.repository.ts @@ -142,6 +142,10 @@ export class LoggingRepository { this.handleMessage(LogLevel.Fatal, message, details); } + deprecate(message: string) { + this.warn(`[Deprecated] ${message}`); + } + private handleFunction(level: LogLevel, message: LogFunction, details: LogDetails[]) { if (this.logger.isLevelEnabled(level)) { this.handleMessage(level, message(), details); diff --git a/server/src/repositories/machine-learning.repository.ts b/server/src/repositories/machine-learning.repository.ts index f880ed129..d148dc782 100644 --- a/server/src/repositories/machine-learning.repository.ts +++ b/server/src/repositories/machine-learning.repository.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; +import { Duration } from 'luxon'; import { readFile } from 'node:fs/promises'; -import { MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME, MACHINE_LEARNING_PING_TIMEOUT } from 'src/constants'; +import { MachineLearningConfig } from 'src/config'; import { CLIPConfig } from 'src/dtos/model-config.dto'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -57,82 +58,100 @@ export type TextEncodingOptions = ModelOptions & { language?: string }; @Injectable() export class MachineLearningRepository { - // Note that deleted URL's are not removed from this map (ie: they're leaked) - // Cleaning them up is low priority since there should be very few over a - // typical server uptime cycle - private urlAvailability: { - [url: string]: - | { - active: boolean; - lastChecked: number; - } - | undefined; - }; + private healthyMap: Record = {}; + private interval?: ReturnType; + private _config?: MachineLearningConfig; + + private get config(): MachineLearningConfig { + if (!this._config) { + throw new Error('Machine learning repository not been setup'); + } + + return this._config; + } constructor(private logger: LoggingRepository) { this.logger.setContext(MachineLearningRepository.name); - this.urlAvailability = {}; } - private setUrlAvailability(url: string, active: boolean) { - const current = this.urlAvailability[url]; - if (current?.active !== active) { - this.logger.verbose(`Setting ${url} ML server to ${active ? 'active' : 'inactive'}.`); + setup(config: MachineLearningConfig) { + this._config = config; + this.teardown(); + + // delete old servers + for (const url of Object.keys(this.healthyMap)) { + if (!config.urls.includes(url)) { + delete this.healthyMap[url]; + } } - this.urlAvailability[url] = { - active, - lastChecked: Date.now(), - }; + + if (!config.availabilityChecks.enabled) { + return; + } + + this.tick(); + this.interval = setInterval( + () => this.tick(), + Duration.fromObject({ milliseconds: config.availabilityChecks.interval }).as('milliseconds'), + ); } - private async checkAvailability(url: string) { - let active = false; + teardown() { + if (this.interval) { + clearInterval(this.interval); + } + } + + private tick() { + for (const url of this.config.urls) { + void this.check(url); + } + } + + private async check(url: string) { + let healthy = false; try { const response = await fetch(new URL('/ping', url), { - signal: AbortSignal.timeout(MACHINE_LEARNING_PING_TIMEOUT), + signal: AbortSignal.timeout(this.config.availabilityChecks.timeout), }); - active = response.ok; + if (response.ok) { + healthy = true; + } } catch { // nothing to do here } - this.setUrlAvailability(url, active); - return active; + + this.setHealthy(url, healthy); } - private async shouldSkipUrl(url: string) { - const availability = this.urlAvailability[url]; - if (availability === undefined) { - // If this is a new endpoint, then check inline and skip if it fails - if (!(await this.checkAvailability(url))) { - return true; - } - return false; + private setHealthy(url: string, healthy: boolean) { + if (this.healthyMap[url] !== healthy) { + this.logger.log(`Machine learning server became ${healthy ? 'healthy' : 'unhealthy'} (${url}).`); } - if (!availability.active && Date.now() - availability.lastChecked < MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME) { - // If this is an old inactive endpoint that hasn't been checked in a - // while then check but don't wait for the result, just skip it - // This avoids delays on every search whilst allowing higher priority - // ML servers to recover over time. - void this.checkAvailability(url); + + this.healthyMap[url] = healthy; + } + + private isHealthy(url: string) { + if (!this.config.availabilityChecks.enabled) { return true; } - return false; + + return this.healthyMap[url]; } - private async predict(urls: string[], payload: ModelPayload, config: MachineLearningRequest): Promise { + private async predict(payload: ModelPayload, config: MachineLearningRequest): Promise { const formData = await this.getFormData(payload, config); - let urlCounter = 0; - for (const url of urls) { - urlCounter++; - const isLast = urlCounter >= urls.length; - if (!isLast && (await this.shouldSkipUrl(url))) { - continue; - } + for (const url of [ + // try healthy servers first + ...this.config.urls.filter((url) => this.isHealthy(url)), + ...this.config.urls.filter((url) => !this.isHealthy(url)), + ]) { try { const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData }); if (response.ok) { - this.setUrlAvailability(url, true); + this.setHealthy(url, true); return response.json(); } @@ -144,20 +163,21 @@ export class MachineLearningRepository { `Machine learning request to "${url}" failed: ${error instanceof Error ? error.message : error}`, ); } - this.setUrlAvailability(url, false); + + this.setHealthy(url, false); } throw new Error(`Machine learning request '${JSON.stringify(config)}' failed for all URLs`); } - async detectFaces(urls: string[], imagePath: string, { modelName, minScore }: FaceDetectionOptions) { + async detectFaces(imagePath: string, { modelName, minScore }: FaceDetectionOptions) { const request = { [ModelTask.FACIAL_RECOGNITION]: { [ModelType.DETECTION]: { modelName, options: { minScore } }, [ModelType.RECOGNITION]: { modelName }, }, }; - const response = await this.predict(urls, { imagePath }, request); + const response = await this.predict({ imagePath }, request); return { imageHeight: response.imageHeight, imageWidth: response.imageWidth, @@ -165,15 +185,15 @@ export class MachineLearningRepository { }; } - async encodeImage(urls: string[], imagePath: string, { modelName }: CLIPConfig) { + async encodeImage(imagePath: string, { modelName }: CLIPConfig) { const request = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: { modelName } } }; - const response = await this.predict(urls, { imagePath }, request); + const response = await this.predict({ imagePath }, request); return response[ModelTask.SEARCH]; } - async encodeText(urls: string[], text: string, { language, modelName }: TextEncodingOptions) { + async encodeText(text: string, { language, modelName }: TextEncodingOptions) { const request = { [ModelTask.SEARCH]: { [ModelType.TEXTUAL]: { modelName, options: { language } } } }; - const response = await this.predict(urls, { text }, request); + const response = await this.predict({ text }, request); return response[ModelTask.SEARCH]; } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 13c312831..41c44ea47 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -729,7 +729,6 @@ describe(PersonService.name, () => { mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] }); await sut.handleDetectFaces({ id: assetStub.image.id }); expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith( - ['http://immich-machine-learning:3003'], '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }), ); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 344b69efd..6fa9b3fdd 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -316,7 +316,6 @@ export class PersonService extends BaseService { } const { imageHeight, imageWidth, faces } = await this.machineLearningRepository.detectFaces( - machineLearning.urls, previewFile.path, machineLearning.facialRecognition, ); diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index d87ccbde1..b6e09add1 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -211,7 +211,6 @@ describe(SearchService.name, () => { await sut.searchSmart(authStub.user1, { query: 'test' }); expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith( - [expect.any(String)], 'test', expect.objectContaining({ modelName: expect.any(String) }), ); @@ -225,7 +224,6 @@ describe(SearchService.name, () => { await sut.searchSmart(authStub.user1, { query: 'test', page: 2, size: 50 }); expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith( - [expect.any(String)], 'test', expect.objectContaining({ modelName: expect.any(String) }), ); @@ -243,7 +241,6 @@ describe(SearchService.name, () => { await sut.searchSmart(authStub.user1, { query: 'test' }); expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith( - [expect.any(String)], 'test', expect.objectContaining({ modelName: 'ViT-B-16-SigLIP__webli' }), ); @@ -253,7 +250,6 @@ describe(SearchService.name, () => { await sut.searchSmart(authStub.user1, { query: 'test', language: 'de' }); expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith( - [expect.any(String)], 'test', expect.objectContaining({ language: 'de' }), ); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 51a2c9433..fea1670e2 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -118,7 +118,7 @@ export class SearchService extends BaseService { const key = machineLearning.clip.modelName + dto.query + dto.language; embedding = this.embeddingCache.get(key); if (!embedding) { - embedding = await this.machineLearningRepository.encodeText(machineLearning.urls, dto.query, { + embedding = await this.machineLearningRepository.encodeText(dto.query, { modelName: machineLearning.clip.modelName, language: dto.language, }); diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index edd9f4663..b3af5cd15 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -205,7 +205,6 @@ describe(SmartInfoService.name, () => { expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.Success); expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( - ['http://immich-machine-learning:3003'], '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); @@ -242,7 +241,6 @@ describe(SmartInfoService.name, () => { expect(mocks.database.wait).toHaveBeenCalledWith(512); expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( - ['http://immich-machine-learning:3003'], '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 3b8e2d1fc..eff16fea4 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -108,11 +108,7 @@ export class SmartInfoService extends BaseService { return JobStatus.Skipped; } - const embedding = await this.machineLearningRepository.encodeImage( - machineLearning.urls, - asset.files[0].path, - machineLearning.clip, - ); + const embedding = await this.machineLearningRepository.encodeImage(asset.files[0].path, machineLearning.clip); if (this.databaseRepository.isBusy(DatabaseLock.CLIPDimSize)) { this.logger.verbose(`Waiting for CLIP dimension size to be updated`); diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 486945546..5a9c7f4df 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -82,6 +82,11 @@ const updatedConfig = Object.freeze({ machineLearning: { enabled: true, urls: ['http://immich-machine-learning:3003'], + availabilityChecks: { + enabled: true, + interval: 30_000, + timeout: 2000, + }, clip: { enabled: true, modelName: 'ViT-B-32__openai', diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index d046b0317..ea95b4df2 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -16,6 +16,20 @@ export class SystemConfigService extends BaseService { async onBootstrap() { const config = await this.getConfig({ withCache: false }); await this.eventRepository.emit('ConfigInit', { newConfig: config }); + + if ( + process.env.IMMICH_MACHINE_LEARNING_PING_TIMEOUT || + process.env.IMMICH_MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME + ) { + this.logger.deprecate( + 'IMMICH_MACHINE_LEARNING_PING_TIMEOUT and MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME have been moved to system config(`machineLearning.availabilityChecks`) and will be removed in a future release.', + ); + } + } + + @OnEvent({ name: 'AppShutdown' }) + onShutdown() { + this.machineLearningRepository.teardown(); } async getSystemConfig(): Promise { @@ -28,12 +42,14 @@ export class SystemConfigService extends BaseService { } @OnEvent({ name: 'ConfigInit', priority: -100 }) - onConfigInit({ newConfig: { logging } }: ArgOf<'ConfigInit'>) { + onConfigInit({ newConfig: { logging, machineLearning } }: ArgOf<'ConfigInit'>) { const { logLevel: envLevel } = this.configRepository.getEnv(); const configLevel = logging.enabled ? logging.level : false; const level = envLevel ?? configLevel; this.logger.setLogLevel(level); this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); + + this.machineLearningRepository.setup(machineLearning); } @OnEvent({ name: 'ConfigUpdate', server: true }) diff --git a/web/src/lib/components/admin-settings/MachineLearningSettings.svelte b/web/src/lib/components/admin-settings/MachineLearningSettings.svelte index 59bd05860..bb1aa04ec 100644 --- a/web/src/lib/components/admin-settings/MachineLearningSettings.svelte +++ b/web/src/lib/components/admin-settings/MachineLearningSettings.svelte @@ -9,7 +9,7 @@ import { featureFlags } from '$lib/stores/server-config.store'; import type { SystemConfigDto } from '@immich/sdk'; import { Button, IconButton } from '@immich/ui'; - import { mdiMinusCircle } from '@mdi/js'; + import { mdiPlus, mdiTrashCanOutline } from '@mdi/js'; import { isEqual } from 'lodash-es'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -46,19 +46,6 @@
{#each config.machineLearning.urls as _, i (i)} - {#snippet removeButton()} - {#if config.machineLearning.urls.length > 1} - config.machineLearning.urls.splice(i, 1)} - icon={mdiMinusCircle} - /> - {/if} - {/snippet} - + > + {#snippet trailingSnippet()} + {#if config.machineLearning.urls.length > 1} + config.machineLearning.urls.splice(i, 1)} + icon={mdiTrashCanOutline} + color="danger" + /> + {/if} + {/snippet} + {/each}
- +
+ +
+ +
+ + +
+ + + + +
+
+ +
{#if inputType === SettingInputFieldType.COLOR}