From 3f2e0780d54511bee9ed51e8c5690f5d0d841d95 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 19 Sep 2025 12:18:42 -0400 Subject: [PATCH] feat: availability checks (#22185) --- docs/docs/install/environment-variables.md | 2 - i18n/en.json | 7 + mobile/openapi/README.md | Bin 40855 -> 40943 bytes mobile/openapi/lib/api.dart | Bin 14536 -> 14596 bytes mobile/openapi/lib/api_client.dart | Bin 36154 -> 36278 bytes ...hine_learning_availability_checks_dto.dart | Bin 0 -> 3678 bytes .../system_config_machine_learning_dto.dart | Bin 5060 -> 4822 bytes open-api/immich-openapi-specs.json | 28 +++- open-api/typescript-sdk/src/fetch-client.ts | 8 +- server/src/bin/sync-sql.ts | 3 +- server/src/config.ts | 12 ++ server/src/constants.ts | 5 - server/src/dtos/system-config.dto.ts | 24 +++- server/src/repositories/logging.repository.ts | 4 + .../machine-learning.repository.ts | 136 ++++++++++-------- server/src/services/person.service.spec.ts | 1 - server/src/services/person.service.ts | 1 - server/src/services/search.service.spec.ts | 4 - server/src/services/search.service.ts | 2 +- .../src/services/smart-info.service.spec.ts | 2 - server/src/services/smart-info.service.ts | 6 +- .../services/system-config.service.spec.ts | 5 + server/src/services/system-config.service.ts | 18 ++- .../MachineLearningSettings.svelte | 82 ++++++++--- .../settings/setting-input-field.svelte | 2 +- 25 files changed, 233 insertions(+), 119 deletions(-) create mode 100644 mobile/openapi/lib/model/machine_learning_availability_checks_dto.dart 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 239c62449fdeeab910d2ba9df9eef240c533e680..7a426b74fc9ddf70e8e9a3669730194e42d4d636 100644 GIT binary patch delta 89 zcmbQfpXvR6rVSdUUdb7md8t0BiA8ytdFhU2iJ3WxNtrpBC6&$@sma;JE+zS~8Y%h7 Q`ue_!1e9);Dy`K508-r}Jpcdz delta 14 VcmaFApK1DjrVSdUn;S~=wE#9O2A2Q; diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e87c160d96a179743ba27710790696f95d9eb422..df2c2226b189c34ad72f67afff5a8a076bd6e8b3 100644 GIT binary patch delta 50 zcmX?6*iy7%rj0o6gL0> delta 12 TcmZoEI#IY`r`YDNV*E+~Dbod> diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index ae5fd9227ba9b3088a0eb6253ca008437eb4fa03..06d27593c922c361c6fe49d2e4e01ac0faf07d6d 100644 GIT binary patch delta 84 zcmdlri)q_zrVVVi^2r&Qd8t0BiA8ytdFhU2iJ3WxNtrpBC6&$@sma;JlMl*@EBYo9 LP__A?O=JK7aoHhK delta 14 Vcmdlsn`zf9rVVVio11J+0st->1(N^( 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 0000000000000000000000000000000000000000..84b3181426ace5da4b151d812cb523edbf4f6bcd GIT binary patch literal 3678 zcmbtXZExE)5dQ98aVdgY!BlzMry{Mr22C=wOJbnS1`L5AFfwhil|_xDVi>9a`|kK6 z$&I>c-G`=E(!C#k!P=rz@$_l_>Zr)Mc&tGUWx|@Y2NLU2H0;Os@wkwrr=< zsy3vd&xHVw_*zaH(Y7lTn0s?@-slU$8JJw?1QWk6_%iNU5+yeB zffapgMr^pO4K3|Yux5aJDGTts*$-vC!4%yV%?Iyi-`~gwt`ve0ys?dn8L418jpp~C zc!te2Iaenzoq+=@V?{)H$6@8m7xx5WV9TFBx24*RL-iOiqz(t`A%8Mxo)0X1L1gf) z+Rzc!+(6EB{#GtIR%26WhJj|FpGv#% zq~RZpLRNy2?jf>09uJ|>ozG`_9Jtf8W_lb_ibFT1$3BK8^7Lt-GB5$}FVT+J!G)8Ty1#|`~Q1{ft5>eil;#s4LEi+*RWD@Xwx}RcAO{I;F8oQ2~v}QWVQvE{SOF!yqB~#pY!p^ANgx1AcY2R&2>p z;3AuJ49|uT>lq3Obr>sjer~3#ur0s=G3?e!#2d*LUBO9HZJfluw!>T_B ziTUbwSMk@RakGes8AHpvlOv~)X)ZRE*@ZZWKb{Sd4=1RgqP+sK!y_ZkBC{jsYCQzql)tBZ-uAOT;Ni? zL*Z4Cu7vIwJU}16s$IYzPjNiVxDFLk*Ikw0#{;{0VtqTPwyM<22W%)FjF?sOneXT% zvi^?aEe!%44oHEn&ER4y@Riivc22rm&{minKhccRb#n+>FPtmx#uEc_4QzlVzhX*Pti{-o&W(Z4bhYds{$5EK)FylHWV zm~kT+t$4%@TYHoT!*@tFf!mxnd}@9XAaylfp%_x6MylO&!25&7urz86-hZ6m(2otz l9DAu~eop*GzKx{J9&L-7yO}%e+5^zPhr^9t@mhz<_cwDXz@GpB literal 0 HcmV?d00001 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 a4a9ca7d82bdff1595724c11646d84f3e4d73fe8..d7b2566d5999ec83552caa49c8b41e8d9bcc2c04 100644 GIT binary patch delta 427 zcmX@2eob}59!8nCmB9r$pR!nwc5;ylvOwP#6OZ7=j zEXvEwOLxRj?NXAjfW?durjE%KER!`br4(#!v1$0rvS#u@)^a&a4SE@g#Tm}|DXE$Y z+LMjg>L=f13&distqP`A9fiqS?D>-yvnT3dN~*^y*eaA{WENwQoBWmi*k(1(w~Psx z`m7X)2^77wqWoO1;`}^~EFckGq>gEjdaS0R&Soxd1IEp{JipjPu&B_PoG6?O0B%vC A>Hq)$ delta 691 zcmZvaKZ_GV7{$earoY`G1qFXHNywCf4p7)*ocJQ_L=}XK1Xua?cwhsKqt=Hvv zJjQ9x4GOIa(sqSQW{^^$DPfjUaEaw)G?`9Ehr2+K*Ka%$#0)_yOS27v$(0i+a<(Ka zh-47SJJJck7J|As;W8oEoM4KQ5}xtk#Fa$G1vT_kk%{bJp9q%{SjdzyCoGmsI6~hU zF3&5*(un4N~D-m51*>c({ 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}