feat: availability checks (#22185)

This commit is contained in:
Jason Rasmussen 2025-09-19 12:18:42 -04:00 committed by GitHub
parent 52363cf0fb
commit 3f2e0780d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 233 additions and 119 deletions

View file

@ -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_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning | | `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | 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_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` | 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 | | `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning |

View file

@ -123,6 +123,13 @@
"logging_enable_description": "Enable logging", "logging_enable_description": "Enable logging",
"logging_level_description": "When enabled, what log level to use.", "logging_level_description": "When enabled, what log level to use.",
"logging_settings": "Logging", "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": "CLIP model",
"machine_learning_clip_model_description": "The name of a CLIP model listed <link>here</link>. Note that you must re-run the 'Smart Search' job for all images upon changing a model.", "machine_learning_clip_model_description": "The name of a CLIP model listed <link>here</link>. Note that you must re-run the 'Smart Search' job for all images upon changing a model.",
"machine_learning_duplicate_detection": "Duplicate Detection", "machine_learning_duplicate_detection": "Duplicate Detection",

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -12259,6 +12259,25 @@
], ],
"type": "object" "type": "object"
}, },
"MachineLearningAvailabilityChecksDto": {
"properties": {
"enabled": {
"type": "boolean"
},
"interval": {
"type": "number"
},
"timeout": {
"type": "number"
}
},
"required": [
"enabled",
"interval",
"timeout"
],
"type": "object"
},
"ManualJobName": { "ManualJobName": {
"enum": [ "enum": [
"person-cleanup", "person-cleanup",
@ -16395,6 +16414,9 @@
}, },
"SystemConfigMachineLearningDto": { "SystemConfigMachineLearningDto": {
"properties": { "properties": {
"availabilityChecks": {
"$ref": "#/components/schemas/MachineLearningAvailabilityChecksDto"
},
"clip": { "clip": {
"$ref": "#/components/schemas/CLIPConfig" "$ref": "#/components/schemas/CLIPConfig"
}, },
@ -16407,11 +16429,6 @@
"facialRecognition": { "facialRecognition": {
"$ref": "#/components/schemas/FacialRecognitionConfig" "$ref": "#/components/schemas/FacialRecognitionConfig"
}, },
"url": {
"deprecated": true,
"description": "This property was deprecated in v1.122.0",
"type": "string"
},
"urls": { "urls": {
"format": "uri", "format": "uri",
"items": { "items": {
@ -16423,6 +16440,7 @@
} }
}, },
"required": [ "required": [
"availabilityChecks",
"clip", "clip",
"duplicateDetection", "duplicateDetection",
"enabled", "enabled",

View file

@ -1383,6 +1383,11 @@ export type SystemConfigLoggingDto = {
enabled: boolean; enabled: boolean;
level: LogLevel; level: LogLevel;
}; };
export type MachineLearningAvailabilityChecksDto = {
enabled: boolean;
interval: number;
timeout: number;
};
export type ClipConfig = { export type ClipConfig = {
enabled: boolean; enabled: boolean;
modelName: string; modelName: string;
@ -1399,12 +1404,11 @@ export type FacialRecognitionConfig = {
modelName: string; modelName: string;
}; };
export type SystemConfigMachineLearningDto = { export type SystemConfigMachineLearningDto = {
availabilityChecks: MachineLearningAvailabilityChecksDto;
clip: ClipConfig; clip: ClipConfig;
duplicateDetection: DuplicateDetectionConfig; duplicateDetection: DuplicateDetectionConfig;
enabled: boolean; enabled: boolean;
facialRecognition: FacialRecognitionConfig; facialRecognition: FacialRecognitionConfig;
/** This property was deprecated in v1.122.0 */
url?: string;
urls: string[]; urls: string[];
}; };
export type SystemConfigMapDto = { export type SystemConfigMapDto = {

View file

@ -15,6 +15,7 @@ import { repositories } from 'src/repositories';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
import { SyncRepository } from 'src/repositories/sync.repository'; import { SyncRepository } from 'src/repositories/sync.repository';
import { AuthService } from 'src/services/auth.service'; import { AuthService } from 'src/services/auth.service';
import { getKyselyConfig } from 'src/utils/database'; import { getKyselyConfig } from 'src/utils/database';
@ -57,7 +58,7 @@ class SqlGenerator {
try { try {
await this.setup(); await this.setup();
for (const Repository of repositories) { for (const Repository of repositories) {
if (Repository === LoggingRepository) { if (Repository === LoggingRepository || Repository === MachineLearningRepository) {
continue; continue;
} }
await this.process(Repository); await this.process(Repository);

View file

@ -54,6 +54,11 @@ export interface SystemConfig {
machineLearning: { machineLearning: {
enabled: boolean; enabled: boolean;
urls: string[]; urls: string[];
availabilityChecks: {
enabled: boolean;
timeout: number;
interval: number;
};
clip: { clip: {
enabled: boolean; enabled: boolean;
modelName: string; modelName: string;
@ -176,6 +181,8 @@ export interface SystemConfig {
}; };
} }
export type MachineLearningConfig = SystemConfig['machineLearning'];
export const defaults = Object.freeze<SystemConfig>({ export const defaults = Object.freeze<SystemConfig>({
backup: { backup: {
database: { database: {
@ -227,6 +234,11 @@ export const defaults = Object.freeze<SystemConfig>({
machineLearning: { machineLearning: {
enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false', enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false',
urls: [process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'], 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: { clip: {
enabled: true, enabled: true,
modelName: 'ViT-B-32__openai', modelName: 'ViT-B-32__openai',

View file

@ -51,11 +51,6 @@ export const serverVersion = new SemVer(version);
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 }); export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
export const ONE_HOUR = Duration.fromObject({ hours: 1 }); 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 citiesFile = 'cities500.txt';
export const reverseGeocodeMaxDistance = 25_000; export const reverseGeocodeMaxDistance = 25_000;

View file

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Exclude, Transform, Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { import {
ArrayMinSize, ArrayMinSize,
IsInt, IsInt,
@ -15,7 +15,6 @@ import {
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { PropertyLifecycle } from 'src/decorators';
import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto'; import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto';
import { import {
AudioCodec, AudioCodec,
@ -257,21 +256,32 @@ class SystemConfigLoggingDto {
level!: LogLevel; level!: LogLevel;
} }
class MachineLearningAvailabilityChecksDto {
@ValidateBoolean()
enabled!: boolean;
@IsInt()
timeout!: number;
@IsInt()
interval!: number;
}
class SystemConfigMachineLearningDto { class SystemConfigMachineLearningDto {
@ValidateBoolean() @ValidateBoolean()
enabled!: boolean; enabled!: boolean;
@PropertyLifecycle({ deprecatedAt: 'v1.122.0' })
@Exclude()
url?: string;
@IsUrl({ require_tld: false, allow_underscores: true }, { each: true }) @IsUrl({ require_tld: false, allow_underscores: true }, { each: true })
@ArrayMinSize(1) @ArrayMinSize(1)
@Transform(({ obj, value }) => (obj.url ? [obj.url] : value))
@ValidateIf((dto) => dto.enabled) @ValidateIf((dto) => dto.enabled)
@ApiProperty({ type: 'array', items: { type: 'string', format: 'uri' }, minItems: 1 }) @ApiProperty({ type: 'array', items: { type: 'string', format: 'uri' }, minItems: 1 })
urls!: string[]; urls!: string[];
@Type(() => MachineLearningAvailabilityChecksDto)
@ValidateNested()
@IsObject()
availabilityChecks!: MachineLearningAvailabilityChecksDto;
@Type(() => CLIPConfig) @Type(() => CLIPConfig)
@ValidateNested() @ValidateNested()
@IsObject() @IsObject()

View file

@ -142,6 +142,10 @@ export class LoggingRepository {
this.handleMessage(LogLevel.Fatal, message, details); this.handleMessage(LogLevel.Fatal, message, details);
} }
deprecate(message: string) {
this.warn(`[Deprecated] ${message}`);
}
private handleFunction(level: LogLevel, message: LogFunction, details: LogDetails[]) { private handleFunction(level: LogLevel, message: LogFunction, details: LogDetails[]) {
if (this.logger.isLevelEnabled(level)) { if (this.logger.isLevelEnabled(level)) {
this.handleMessage(level, message(), details); this.handleMessage(level, message(), details);

View file

@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Duration } from 'luxon';
import { readFile } from 'node:fs/promises'; 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 { CLIPConfig } from 'src/dtos/model-config.dto';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
@ -57,82 +58,100 @@ export type TextEncodingOptions = ModelOptions & { language?: string };
@Injectable() @Injectable()
export class MachineLearningRepository { export class MachineLearningRepository {
// Note that deleted URL's are not removed from this map (ie: they're leaked) private healthyMap: Record<string, boolean> = {};
// Cleaning them up is low priority since there should be very few over a private interval?: ReturnType<typeof setInterval>;
// typical server uptime cycle private _config?: MachineLearningConfig;
private urlAvailability: {
[url: string]: private get config(): MachineLearningConfig {
| { if (!this._config) {
active: boolean; throw new Error('Machine learning repository not been setup');
lastChecked: number; }
}
| undefined; return this._config;
}; }
constructor(private logger: LoggingRepository) { constructor(private logger: LoggingRepository) {
this.logger.setContext(MachineLearningRepository.name); this.logger.setContext(MachineLearningRepository.name);
this.urlAvailability = {};
} }
private setUrlAvailability(url: string, active: boolean) { setup(config: MachineLearningConfig) {
const current = this.urlAvailability[url]; this._config = config;
if (current?.active !== active) { this.teardown();
this.logger.verbose(`Setting ${url} ML server to ${active ? 'active' : 'inactive'}.`);
// delete old servers
for (const url of Object.keys(this.healthyMap)) {
if (!config.urls.includes(url)) {
delete this.healthyMap[url];
}
} }
this.urlAvailability[url] = {
active, if (!config.availabilityChecks.enabled) {
lastChecked: Date.now(), return;
}; }
this.tick();
this.interval = setInterval(
() => this.tick(),
Duration.fromObject({ milliseconds: config.availabilityChecks.interval }).as('milliseconds'),
);
} }
private async checkAvailability(url: string) { teardown() {
let active = false; 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 { try {
const response = await fetch(new URL('/ping', url), { 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 { } catch {
// nothing to do here // nothing to do here
} }
this.setUrlAvailability(url, active);
return active; this.setHealthy(url, healthy);
} }
private async shouldSkipUrl(url: string) { private setHealthy(url: string, healthy: boolean) {
const availability = this.urlAvailability[url]; if (this.healthyMap[url] !== healthy) {
if (availability === undefined) { this.logger.log(`Machine learning server became ${healthy ? 'healthy' : 'unhealthy'} (${url}).`);
// If this is a new endpoint, then check inline and skip if it fails
if (!(await this.checkAvailability(url))) {
return true;
}
return false;
} }
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 this.healthyMap[url] = healthy;
// 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. private isHealthy(url: string) {
void this.checkAvailability(url); if (!this.config.availabilityChecks.enabled) {
return true; return true;
} }
return false;
return this.healthyMap[url];
} }
private async predict<T>(urls: string[], payload: ModelPayload, config: MachineLearningRequest): Promise<T> { private async predict<T>(payload: ModelPayload, config: MachineLearningRequest): Promise<T> {
const formData = await this.getFormData(payload, config); 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 { try {
const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData }); const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData });
if (response.ok) { if (response.ok) {
this.setUrlAvailability(url, true); this.setHealthy(url, true);
return response.json(); return response.json();
} }
@ -144,20 +163,21 @@ export class MachineLearningRepository {
`Machine learning request to "${url}" failed: ${error instanceof Error ? error.message : error}`, `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`); 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 = { const request = {
[ModelTask.FACIAL_RECOGNITION]: { [ModelTask.FACIAL_RECOGNITION]: {
[ModelType.DETECTION]: { modelName, options: { minScore } }, [ModelType.DETECTION]: { modelName, options: { minScore } },
[ModelType.RECOGNITION]: { modelName }, [ModelType.RECOGNITION]: { modelName },
}, },
}; };
const response = await this.predict<FacialRecognitionResponse>(urls, { imagePath }, request); const response = await this.predict<FacialRecognitionResponse>({ imagePath }, request);
return { return {
imageHeight: response.imageHeight, imageHeight: response.imageHeight,
imageWidth: response.imageWidth, 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 request = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: { modelName } } };
const response = await this.predict<ClipVisualResponse>(urls, { imagePath }, request); const response = await this.predict<ClipVisualResponse>({ imagePath }, request);
return response[ModelTask.SEARCH]; 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 request = { [ModelTask.SEARCH]: { [ModelType.TEXTUAL]: { modelName, options: { language } } } };
const response = await this.predict<ClipTextualResponse>(urls, { text }, request); const response = await this.predict<ClipTextualResponse>({ text }, request);
return response[ModelTask.SEARCH]; return response[ModelTask.SEARCH];
} }

View file

@ -729,7 +729,6 @@ describe(PersonService.name, () => {
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] }); mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] });
await sut.handleDetectFaces({ id: assetStub.image.id }); await sut.handleDetectFaces({ id: assetStub.image.id });
expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith( expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith(
['http://immich-machine-learning:3003'],
'/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }), expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }),
); );

View file

@ -316,7 +316,6 @@ export class PersonService extends BaseService {
} }
const { imageHeight, imageWidth, faces } = await this.machineLearningRepository.detectFaces( const { imageHeight, imageWidth, faces } = await this.machineLearningRepository.detectFaces(
machineLearning.urls,
previewFile.path, previewFile.path,
machineLearning.facialRecognition, machineLearning.facialRecognition,
); );

View file

@ -211,7 +211,6 @@ describe(SearchService.name, () => {
await sut.searchSmart(authStub.user1, { query: 'test' }); await sut.searchSmart(authStub.user1, { query: 'test' });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith( expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
[expect.any(String)],
'test', 'test',
expect.objectContaining({ modelName: expect.any(String) }), expect.objectContaining({ modelName: expect.any(String) }),
); );
@ -225,7 +224,6 @@ describe(SearchService.name, () => {
await sut.searchSmart(authStub.user1, { query: 'test', page: 2, size: 50 }); await sut.searchSmart(authStub.user1, { query: 'test', page: 2, size: 50 });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith( expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
[expect.any(String)],
'test', 'test',
expect.objectContaining({ modelName: expect.any(String) }), expect.objectContaining({ modelName: expect.any(String) }),
); );
@ -243,7 +241,6 @@ describe(SearchService.name, () => {
await sut.searchSmart(authStub.user1, { query: 'test' }); await sut.searchSmart(authStub.user1, { query: 'test' });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith( expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
[expect.any(String)],
'test', 'test',
expect.objectContaining({ modelName: 'ViT-B-16-SigLIP__webli' }), expect.objectContaining({ modelName: 'ViT-B-16-SigLIP__webli' }),
); );
@ -253,7 +250,6 @@ describe(SearchService.name, () => {
await sut.searchSmart(authStub.user1, { query: 'test', language: 'de' }); await sut.searchSmart(authStub.user1, { query: 'test', language: 'de' });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith( expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
[expect.any(String)],
'test', 'test',
expect.objectContaining({ language: 'de' }), expect.objectContaining({ language: 'de' }),
); );

View file

@ -118,7 +118,7 @@ export class SearchService extends BaseService {
const key = machineLearning.clip.modelName + dto.query + dto.language; const key = machineLearning.clip.modelName + dto.query + dto.language;
embedding = this.embeddingCache.get(key); embedding = this.embeddingCache.get(key);
if (!embedding) { if (!embedding) {
embedding = await this.machineLearningRepository.encodeText(machineLearning.urls, dto.query, { embedding = await this.machineLearningRepository.encodeText(dto.query, {
modelName: machineLearning.clip.modelName, modelName: machineLearning.clip.modelName,
language: dto.language, language: dto.language,
}); });

View file

@ -205,7 +205,6 @@ describe(SmartInfoService.name, () => {
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.Success); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.Success);
expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith(
['http://immich-machine-learning:3003'],
'/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }), expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
); );
@ -242,7 +241,6 @@ describe(SmartInfoService.name, () => {
expect(mocks.database.wait).toHaveBeenCalledWith(512); expect(mocks.database.wait).toHaveBeenCalledWith(512);
expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith(
['http://immich-machine-learning:3003'],
'/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }), expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
); );

View file

@ -108,11 +108,7 @@ export class SmartInfoService extends BaseService {
return JobStatus.Skipped; return JobStatus.Skipped;
} }
const embedding = await this.machineLearningRepository.encodeImage( const embedding = await this.machineLearningRepository.encodeImage(asset.files[0].path, machineLearning.clip);
machineLearning.urls,
asset.files[0].path,
machineLearning.clip,
);
if (this.databaseRepository.isBusy(DatabaseLock.CLIPDimSize)) { if (this.databaseRepository.isBusy(DatabaseLock.CLIPDimSize)) {
this.logger.verbose(`Waiting for CLIP dimension size to be updated`); this.logger.verbose(`Waiting for CLIP dimension size to be updated`);

View file

@ -82,6 +82,11 @@ const updatedConfig = Object.freeze<SystemConfig>({
machineLearning: { machineLearning: {
enabled: true, enabled: true,
urls: ['http://immich-machine-learning:3003'], urls: ['http://immich-machine-learning:3003'],
availabilityChecks: {
enabled: true,
interval: 30_000,
timeout: 2000,
},
clip: { clip: {
enabled: true, enabled: true,
modelName: 'ViT-B-32__openai', modelName: 'ViT-B-32__openai',

View file

@ -16,6 +16,20 @@ export class SystemConfigService extends BaseService {
async onBootstrap() { async onBootstrap() {
const config = await this.getConfig({ withCache: false }); const config = await this.getConfig({ withCache: false });
await this.eventRepository.emit('ConfigInit', { newConfig: config }); 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<SystemConfigDto> { async getSystemConfig(): Promise<SystemConfigDto> {
@ -28,12 +42,14 @@ export class SystemConfigService extends BaseService {
} }
@OnEvent({ name: 'ConfigInit', priority: -100 }) @OnEvent({ name: 'ConfigInit', priority: -100 })
onConfigInit({ newConfig: { logging } }: ArgOf<'ConfigInit'>) { onConfigInit({ newConfig: { logging, machineLearning } }: ArgOf<'ConfigInit'>) {
const { logLevel: envLevel } = this.configRepository.getEnv(); const { logLevel: envLevel } = this.configRepository.getEnv();
const configLevel = logging.enabled ? logging.level : false; const configLevel = logging.enabled ? logging.level : false;
const level = envLevel ?? configLevel; const level = envLevel ?? configLevel;
this.logger.setLogLevel(level); this.logger.setLogLevel(level);
this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`);
this.machineLearningRepository.setup(machineLearning);
} }
@OnEvent({ name: 'ConfigUpdate', server: true }) @OnEvent({ name: 'ConfigUpdate', server: true })

View file

@ -9,7 +9,7 @@
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import type { SystemConfigDto } from '@immich/sdk'; import type { SystemConfigDto } from '@immich/sdk';
import { Button, IconButton } from '@immich/ui'; import { Button, IconButton } from '@immich/ui';
import { mdiMinusCircle } from '@mdi/js'; import { mdiPlus, mdiTrashCanOutline } from '@mdi/js';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@ -46,19 +46,6 @@
<div> <div>
{#each config.machineLearning.urls as _, i (i)} {#each config.machineLearning.urls as _, i (i)}
{#snippet removeButton()}
{#if config.machineLearning.urls.length > 1}
<IconButton
size="large"
shape="round"
color="danger"
aria-label=""
onclick={() => config.machineLearning.urls.splice(i, 1)}
icon={mdiMinusCircle}
/>
{/if}
{/snippet}
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={i === 0 ? $t('url') : undefined} label={i === 0 ? $t('url') : undefined}
@ -67,20 +54,69 @@
required={i === 0} required={i === 0}
disabled={disabled || !config.machineLearning.enabled} disabled={disabled || !config.machineLearning.enabled}
isEdited={i === 0 && !isEqual(config.machineLearning.urls, savedConfig.machineLearning.urls)} isEdited={i === 0 && !isEqual(config.machineLearning.urls, savedConfig.machineLearning.urls)}
trailingSnippet={removeButton} >
/> {#snippet trailingSnippet()}
{#if config.machineLearning.urls.length > 1}
<IconButton
aria-label=""
onclick={() => config.machineLearning.urls.splice(i, 1)}
icon={mdiTrashCanOutline}
color="danger"
/>
{/if}
{/snippet}
</SettingInputField>
{/each} {/each}
</div> </div>
<Button <div class="flex justify-end">
class="mb-2" <Button
size="small" class="mb-2"
shape="round" size="small"
onclick={() => config.machineLearning.urls.splice(0, 0, '')} shape="round"
disabled={disabled || !config.machineLearning.enabled}>{$t('add_url')}</Button leadingIcon={mdiPlus}
> onclick={() => config.machineLearning.urls.push('')}
disabled={disabled || !config.machineLearning.enabled}>{$t('add_url')}</Button
>
</div>
</div> </div>
<SettingAccordion
key="availability-checks"
title={$t('admin.machine_learning_availability_checks')}
subtitle={$t('admin.machine_learning_availability_checks_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.machine_learning_availability_checks_enabled')}
bind:checked={config.machineLearning.availabilityChecks.enabled}
disabled={disabled || !config.machineLearning.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_availability_checks_interval')}
bind:value={config.machineLearning.availabilityChecks.interval}
description={$t('admin.machine_learning_availability_checks_interval_description')}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.availabilityChecks.enabled}
isEdited={config.machineLearning.availabilityChecks.interval !==
savedConfig.machineLearning.availabilityChecks.interval}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_availability_checks_timeout')}
bind:value={config.machineLearning.availabilityChecks.timeout}
description={$t('admin.machine_learning_availability_checks_timeout_description')}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.availabilityChecks.enabled}
isEdited={config.machineLearning.availabilityChecks.timeout !==
savedConfig.machineLearning.availabilityChecks.timeout}
/>
</div>
</SettingAccordion>
<SettingAccordion <SettingAccordion
key="smart-search" key="smart-search"
title={$t('admin.machine_learning_smart_search')} title={$t('admin.machine_learning_smart_search')}

View file

@ -105,7 +105,7 @@
{/if} {/if}
{#if inputType !== SettingInputFieldType.PASSWORD} {#if inputType !== SettingInputFieldType.PASSWORD}
<div class="flex place-items-center place-content-center"> <div class="flex place-items-center place-content-center gap-2">
{#if inputType === SettingInputFieldType.COLOR} {#if inputType === SettingInputFieldType.COLOR}
<input <input
bind:this={input} bind:this={input}