mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00:00
feat: availability checks (#22185)
This commit is contained in:
parent
52363cf0fb
commit
3f2e0780d5
25 changed files with 233 additions and 119 deletions
|
|
@ -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 |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/machine_learning_availability_checks_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/machine_learning_availability_checks_dto.dart
generated
Normal file
Binary file not shown.
Binary file not shown.
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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' }),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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' }),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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' }),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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')}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue