1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-24 08:52:28 +02:00

feat(server): dynamic job concurrency (#2622)

* feat(server): dynamic job concurrency

* styling and add setting info to top of the job list

* regenerate api

* remove DETECT_OBJECT job

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen 2023-06-01 06:32:51 -04:00 committed by GitHub
parent 656dc08406
commit 2493dfaba3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 870 additions and 356 deletions

View File

@ -57,6 +57,7 @@ doc/JobCommand.md
doc/JobCommandDto.md doc/JobCommandDto.md
doc/JobCountsDto.md doc/JobCountsDto.md
doc/JobName.md doc/JobName.md
doc/JobSettingsDto.md
doc/JobStatusDto.md doc/JobStatusDto.md
doc/LoginCredentialDto.md doc/LoginCredentialDto.md
doc/LoginResponseDto.md doc/LoginResponseDto.md
@ -95,6 +96,7 @@ doc/SmartInfoResponseDto.md
doc/SystemConfigApi.md doc/SystemConfigApi.md
doc/SystemConfigDto.md doc/SystemConfigDto.md
doc/SystemConfigFFmpegDto.md doc/SystemConfigFFmpegDto.md
doc/SystemConfigJobDto.md
doc/SystemConfigOAuthDto.md doc/SystemConfigOAuthDto.md
doc/SystemConfigPasswordLoginDto.md doc/SystemConfigPasswordLoginDto.md
doc/SystemConfigStorageTemplateDto.md doc/SystemConfigStorageTemplateDto.md
@ -186,6 +188,7 @@ lib/model/job_command.dart
lib/model/job_command_dto.dart lib/model/job_command_dto.dart
lib/model/job_counts_dto.dart lib/model/job_counts_dto.dart
lib/model/job_name.dart lib/model/job_name.dart
lib/model/job_settings_dto.dart
lib/model/job_status_dto.dart lib/model/job_status_dto.dart
lib/model/login_credential_dto.dart lib/model/login_credential_dto.dart
lib/model/login_response_dto.dart lib/model/login_response_dto.dart
@ -217,6 +220,7 @@ lib/model/sign_up_dto.dart
lib/model/smart_info_response_dto.dart lib/model/smart_info_response_dto.dart
lib/model/system_config_dto.dart lib/model/system_config_dto.dart
lib/model/system_config_f_fmpeg_dto.dart lib/model/system_config_f_fmpeg_dto.dart
lib/model/system_config_job_dto.dart
lib/model/system_config_o_auth_dto.dart lib/model/system_config_o_auth_dto.dart
lib/model/system_config_password_login_dto.dart lib/model/system_config_password_login_dto.dart
lib/model/system_config_storage_template_dto.dart lib/model/system_config_storage_template_dto.dart
@ -288,6 +292,7 @@ test/job_command_dto_test.dart
test/job_command_test.dart test/job_command_test.dart
test/job_counts_dto_test.dart test/job_counts_dto_test.dart
test/job_name_test.dart test/job_name_test.dart
test/job_settings_dto_test.dart
test/job_status_dto_test.dart test/job_status_dto_test.dart
test/login_credential_dto_test.dart test/login_credential_dto_test.dart
test/login_response_dto_test.dart test/login_response_dto_test.dart
@ -326,6 +331,7 @@ test/smart_info_response_dto_test.dart
test/system_config_api_test.dart test/system_config_api_test.dart
test/system_config_dto_test.dart test/system_config_dto_test.dart
test/system_config_f_fmpeg_dto_test.dart test/system_config_f_fmpeg_dto_test.dart
test/system_config_job_dto_test.dart
test/system_config_o_auth_dto_test.dart test/system_config_o_auth_dto_test.dart
test/system_config_password_login_dto_test.dart test/system_config_password_login_dto_test.dart
test/system_config_storage_template_dto_test.dart test/system_config_storage_template_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/JobSettingsDto.md generated Normal file

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/SystemConfigJobDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -19,6 +19,6 @@ export class JobController {
@Put('/:jobId') @Put('/:jobId')
async sendJobCommand(@Param() { jobId }: JobIdDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> { async sendJobCommand(@Param() { jobId }: JobIdDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> {
await this.service.handleCommand(jobId, dto); await this.service.handleCommand(jobId, dto);
return await this.service.getJobStatus(jobId); return this.service.getJobStatus(jobId);
} }
} }

View File

@ -0,0 +1,75 @@
import {
FacialRecognitionService,
IDeleteFilesJob,
JobName,
JobService,
MediaService,
MetadataService,
PersonService,
SearchService,
SmartInfoService,
StorageService,
StorageTemplateService,
SystemConfigService,
UserService,
} from '@app/domain';
import { Injectable } from '@nestjs/common';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
@Injectable()
export class AppService {
constructor(
// TODO refactor to domain
private metadataProcessor: MetadataExtractionProcessor,
private facialRecognitionService: FacialRecognitionService,
private jobService: JobService,
private mediaService: MediaService,
private metadataService: MetadataService,
private personService: PersonService,
private searchService: SearchService,
private smartInfoService: SmartInfoService,
private storageTemplateService: StorageTemplateService,
private storageService: StorageService,
private systemConfigService: SystemConfigService,
private userService: UserService,
) {}
async init() {
await this.jobService.registerHandlers({
[JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data),
[JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(),
[JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data),
[JobName.QUEUE_OBJECT_TAGGING]: (data) => this.smartInfoService.handleQueueObjectTagging(data),
[JobName.CLASSIFY_IMAGE]: (data) => this.smartInfoService.handleClassifyImage(data),
[JobName.QUEUE_ENCODE_CLIP]: (data) => this.smartInfoService.handleQueueEncodeClip(data),
[JobName.ENCODE_CLIP]: (data) => this.smartInfoService.handleEncodeClip(data),
[JobName.SEARCH_INDEX_ALBUMS]: () => this.searchService.handleIndexAlbums(),
[JobName.SEARCH_INDEX_ASSETS]: () => this.searchService.handleIndexAssets(),
[JobName.SEARCH_INDEX_FACES]: () => this.searchService.handleIndexFaces(),
[JobName.SEARCH_INDEX_ALBUM]: (data) => this.searchService.handleIndexAlbum(data),
[JobName.SEARCH_INDEX_ASSET]: (data) => this.searchService.handleIndexAsset(data),
[JobName.SEARCH_INDEX_FACE]: (data) => this.searchService.handleIndexFace(data),
[JobName.SEARCH_REMOVE_ALBUM]: (data) => this.searchService.handleRemoveAlbum(data),
[JobName.SEARCH_REMOVE_ASSET]: (data) => this.searchService.handleRemoveAsset(data),
[JobName.SEARCH_REMOVE_FACE]: (data) => this.searchService.handleRemoveFace(data),
[JobName.STORAGE_TEMPLATE_MIGRATION]: () => this.storageTemplateService.handleMigration(),
[JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: (data) => this.storageTemplateService.handleMigrationSingle(data),
[JobName.SYSTEM_CONFIG_CHANGE]: () => this.systemConfigService.refreshConfig(),
[JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data),
[JobName.GENERATE_JPEG_THUMBNAIL]: (data) => this.mediaService.handleGenerateJpegThumbnail(data),
[JobName.GENERATE_WEBP_THUMBNAIL]: (data) => this.mediaService.handleGenerateWepbThumbnail(data),
[JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data),
[JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),
[JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleQueueMetadataExtraction(data),
[JobName.METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleMetadataExtraction(data),
[JobName.QUEUE_RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleQueueRecognizeFaces(data),
[JobName.RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleRecognizeFaces(data),
[JobName.GENERATE_FACE_THUMBNAIL]: (data) => this.facialRecognitionService.handleGenerateFaceThumbnail(data),
[JobName.PERSON_CLEANUP]: () => this.personService.handlePersonCleanup(),
[JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(data),
[JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data),
[JobName.SIDECAR_SYNC]: () => this.metadataService.handleSidecarSync(),
});
}
}

View File

@ -2,8 +2,8 @@ import { getLogLevels, SERVER_VERSION } from '@app/domain';
import { RedisIoAdapter } from '@app/infra'; import { RedisIoAdapter } from '@app/infra';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppService } from './app.service';
import { MicroservicesModule } from './microservices.module'; import { MicroservicesModule } from './microservices.module';
import { ProcessorService } from './processor.service';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor'; import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
const logger = new Logger('ImmichMicroservice'); const logger = new Logger('ImmichMicroservice');
@ -15,7 +15,7 @@ async function bootstrap() {
const listeningPort = Number(process.env.MICROSERVICES_PORT) || 3002; const listeningPort = Number(process.env.MICROSERVICES_PORT) || 3002;
await app.get(ProcessorService).init(); await app.get(AppService).init();
app.useWebSocketAdapter(new RedisIoAdapter(app)); app.useWebSocketAdapter(new RedisIoAdapter(app));

View File

@ -3,7 +3,7 @@ import { InfraModule } from '@app/infra';
import { ExifEntity } from '@app/infra/entities'; import { ExifEntity } from '@app/infra/entities';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ProcessorService } from './processor.service'; import { AppService } from './app.service';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor'; import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
@Module({ @Module({
@ -12,6 +12,6 @@ import { MetadataExtractionProcessor } from './processors/metadata-extraction.pr
DomainModule.register({ imports: [InfraModule] }), DomainModule.register({ imports: [InfraModule] }),
TypeOrmModule.forFeature([ExifEntity]), TypeOrmModule.forFeature([ExifEntity]),
], ],
providers: [MetadataExtractionProcessor, ProcessorService], providers: [MetadataExtractionProcessor, AppService],
}) })
export class MicroservicesModule {} export class MicroservicesModule {}

View File

@ -1,113 +0,0 @@
import {
FacialRecognitionService,
IDeleteFilesJob,
JobItem,
JobName,
JobService,
JOBS_TO_QUEUE,
MediaService,
MetadataService,
PersonService,
QueueName,
QUEUE_TO_CONCURRENCY,
SearchService,
SmartInfoService,
StorageService,
StorageTemplateService,
SystemConfigService,
UserService,
} from '@app/domain';
import { getQueueToken } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { Queue } from 'bull';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
type JobHandler<T = any> = (data: T) => boolean | Promise<boolean>;
@Injectable()
export class ProcessorService {
constructor(
private moduleRef: ModuleRef,
// TODO refactor to domain
private metadataProcessor: MetadataExtractionProcessor,
private facialRecognitionService: FacialRecognitionService,
private jobService: JobService,
private mediaService: MediaService,
private metadataService: MetadataService,
private personService: PersonService,
private searchService: SearchService,
private smartInfoService: SmartInfoService,
private storageTemplateService: StorageTemplateService,
private storageService: StorageService,
private systemConfigService: SystemConfigService,
private userService: UserService,
) {}
private logger = new Logger(ProcessorService.name);
private handlers: Record<JobName, JobHandler> = {
[JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data),
[JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(),
[JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data),
[JobName.QUEUE_OBJECT_TAGGING]: (data) => this.smartInfoService.handleQueueObjectTagging(data),
[JobName.CLASSIFY_IMAGE]: (data) => this.smartInfoService.handleClassifyImage(data),
[JobName.QUEUE_ENCODE_CLIP]: (data) => this.smartInfoService.handleQueueEncodeClip(data),
[JobName.ENCODE_CLIP]: (data) => this.smartInfoService.handleEncodeClip(data),
[JobName.SEARCH_INDEX_ALBUMS]: () => this.searchService.handleIndexAlbums(),
[JobName.SEARCH_INDEX_ASSETS]: () => this.searchService.handleIndexAssets(),
[JobName.SEARCH_INDEX_FACES]: () => this.searchService.handleIndexFaces(),
[JobName.SEARCH_INDEX_ALBUM]: (data) => this.searchService.handleIndexAlbum(data),
[JobName.SEARCH_INDEX_ASSET]: (data) => this.searchService.handleIndexAsset(data),
[JobName.SEARCH_INDEX_FACE]: (data) => this.searchService.handleIndexFace(data),
[JobName.SEARCH_REMOVE_ALBUM]: (data) => this.searchService.handleRemoveAlbum(data),
[JobName.SEARCH_REMOVE_ASSET]: (data) => this.searchService.handleRemoveAsset(data),
[JobName.SEARCH_REMOVE_FACE]: (data) => this.searchService.handleRemoveFace(data),
[JobName.STORAGE_TEMPLATE_MIGRATION]: () => this.storageTemplateService.handleMigration(),
[JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: (data) => this.storageTemplateService.handleMigrationSingle(data),
[JobName.SYSTEM_CONFIG_CHANGE]: () => this.systemConfigService.refreshConfig(),
[JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data),
[JobName.GENERATE_JPEG_THUMBNAIL]: (data) => this.mediaService.handleGenerateJpegThumbnail(data),
[JobName.GENERATE_WEBP_THUMBNAIL]: (data) => this.mediaService.handleGenerateWepbThumbnail(data),
[JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data),
[JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),
[JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleQueueMetadataExtraction(data),
[JobName.METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleMetadataExtraction(data),
[JobName.QUEUE_RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleQueueRecognizeFaces(data),
[JobName.RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleRecognizeFaces(data),
[JobName.GENERATE_FACE_THUMBNAIL]: (data) => this.facialRecognitionService.handleGenerateFaceThumbnail(data),
[JobName.PERSON_CLEANUP]: () => this.personService.handlePersonCleanup(),
[JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(data),
[JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data),
[JobName.SIDECAR_SYNC]: () => this.metadataService.handleSidecarSync(),
};
async init() {
const queueSeen: Partial<Record<QueueName, boolean>> = {};
for (const jobName of Object.values(JobName)) {
const handler = this.handlers[jobName];
const queueName = JOBS_TO_QUEUE[jobName];
const queue = this.moduleRef.get<Queue>(getQueueToken(queueName), { strict: false });
// only set concurrency on the first job for a queue, since concurrency stacks
const seen = queueSeen[queueName];
const concurrency = seen ? 0 : QUEUE_TO_CONCURRENCY[queueName];
queueSeen[queueName] = true;
await queue.isReady();
queue.process(jobName, concurrency, async (job): Promise<void> => {
try {
const success = await handler(job.data);
if (success) {
await this.jobService.onDone({ name: jobName, data: job.data } as JobItem);
}
} catch (error: Error | any) {
this.logger.error(`Unable to run job handler: ${error}`, error?.stack, job.data);
}
});
}
}
}

View File

@ -5106,63 +5106,63 @@
"AllJobStatusResponseDto": { "AllJobStatusResponseDto": {
"type": "object", "type": "object",
"properties": { "properties": {
"thumbnail-generation-queue": { "thumbnailGeneration": {
"$ref": "#/components/schemas/JobStatusDto" "$ref": "#/components/schemas/JobStatusDto"
}, },
"metadata-extraction-queue": { "metadataExtraction": {
"$ref": "#/components/schemas/JobStatusDto" "$ref": "#/components/schemas/JobStatusDto"
}, },
"video-conversion-queue": { "videoConversion": {
"$ref": "#/components/schemas/JobStatusDto" "$ref": "#/components/schemas/JobStatusDto"
}, },
"object-tagging-queue": { "objectTagging": {
"$ref": "#/components/schemas/JobStatusDto" "$ref": "#/components/schemas/JobStatusDto"
}, },
"clip-encoding-queue": { "clipEncoding": {
"$ref": "#/components/schemas/JobStatusDto" "$ref": "#/components/schemas/JobStatusDto"
}, },
"storage-template-migration-queue": { "storageTemplateMigration": {
"$ref": "#/components/schemas/JobStatusDto" "$ref": "#/components/schemas/JobStatusDto"
}, },
"background-task-queue": { "backgroundTask": {
"$ref": "#/components/schemas/JobStatusDto" "$ref": "#/components/schemas/JobStatusDto"
}, },
"search-queue": { "search": {
"$ref": "#/components/schemas/JobStatusDto" "$ref": "#/components/schemas/JobStatusDto"
}, },
"recognize-faces-queue": { "recognizeFaces": {
"$ref": "#/components/schemas/JobStatusDto" "$ref": "#/components/schemas/JobStatusDto"
}, },
"sidecar-queue": { "sidecar": {
"$ref": "#/components/schemas/JobStatusDto" "$ref": "#/components/schemas/JobStatusDto"
} }
}, },
"required": [ "required": [
"thumbnail-generation-queue", "thumbnailGeneration",
"metadata-extraction-queue", "metadataExtraction",
"video-conversion-queue", "videoConversion",
"object-tagging-queue", "objectTagging",
"clip-encoding-queue", "clipEncoding",
"storage-template-migration-queue", "storageTemplateMigration",
"background-task-queue", "backgroundTask",
"search-queue", "search",
"recognize-faces-queue", "recognizeFaces",
"sidecar-queue" "sidecar"
] ]
}, },
"JobName": { "JobName": {
"type": "string", "type": "string",
"enum": [ "enum": [
"thumbnail-generation-queue", "thumbnailGeneration",
"metadata-extraction-queue", "metadataExtraction",
"video-conversion-queue", "videoConversion",
"object-tagging-queue", "objectTagging",
"recognize-faces-queue", "recognizeFaces",
"clip-encoding-queue", "clipEncoding",
"background-task-queue", "backgroundTask",
"storage-template-migration-queue", "storageTemplateMigration",
"search-queue", "search",
"sidecar-queue" "sidecar"
] ]
}, },
"JobCommand": { "JobCommand": {
@ -5733,6 +5733,64 @@
"template" "template"
] ]
}, },
"JobSettingsDto": {
"type": "object",
"properties": {
"concurrency": {
"type": "integer"
}
},
"required": [
"concurrency"
]
},
"SystemConfigJobDto": {
"type": "object",
"properties": {
"thumbnailGeneration": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"metadataExtraction": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"videoConversion": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"objectTagging": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"clipEncoding": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"storageTemplateMigration": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"backgroundTask": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"search": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"recognizeFaces": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"sidecar": {
"$ref": "#/components/schemas/JobSettingsDto"
}
},
"required": [
"thumbnailGeneration",
"metadataExtraction",
"videoConversion",
"objectTagging",
"clipEncoding",
"storageTemplateMigration",
"backgroundTask",
"search",
"recognizeFaces",
"sidecar"
]
},
"SystemConfigDto": { "SystemConfigDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -5747,13 +5805,17 @@
}, },
"storageTemplate": { "storageTemplate": {
"$ref": "#/components/schemas/SystemConfigStorageTemplateDto" "$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
},
"job": {
"$ref": "#/components/schemas/SystemConfigJobDto"
} }
}, },
"required": [ "required": [
"ffmpeg", "ffmpeg",
"oauth", "oauth",
"passwordLogin", "passwordLogin",
"storageTemplate" "storageTemplate",
"job"
] ]
}, },
"SystemConfigTemplateStorageOptionDto": { "SystemConfigTemplateStorageOptionDto": {

View File

@ -1,14 +1,14 @@
export enum QueueName { export enum QueueName {
THUMBNAIL_GENERATION = 'thumbnail-generation-queue', THUMBNAIL_GENERATION = 'thumbnailGeneration',
METADATA_EXTRACTION = 'metadata-extraction-queue', METADATA_EXTRACTION = 'metadataExtraction',
VIDEO_CONVERSION = 'video-conversion-queue', VIDEO_CONVERSION = 'videoConversion',
OBJECT_TAGGING = 'object-tagging-queue', OBJECT_TAGGING = 'objectTagging',
RECOGNIZE_FACES = 'recognize-faces-queue', RECOGNIZE_FACES = 'recognizeFaces',
CLIP_ENCODING = 'clip-encoding-queue', CLIP_ENCODING = 'clipEncoding',
BACKGROUND_TASK = 'background-task-queue', BACKGROUND_TASK = 'backgroundTask',
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue', STORAGE_TEMPLATE_MIGRATION = 'storageTemplateMigration',
SEARCH = 'search-queue', SEARCH = 'search',
SIDECAR = 'sidecar-queue', SIDECAR = 'sidecar',
} }
export enum JobCommand { export enum JobCommand {
@ -135,17 +135,3 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[JobName.SIDECAR_DISCOVERY]: QueueName.SIDECAR, [JobName.SIDECAR_DISCOVERY]: QueueName.SIDECAR,
[JobName.SIDECAR_SYNC]: QueueName.SIDECAR, [JobName.SIDECAR_SYNC]: QueueName.SIDECAR,
}; };
// max concurrency for each queue (total concurrency across all jobs)
export const QUEUE_TO_CONCURRENCY: Record<QueueName, number> = {
[QueueName.BACKGROUND_TASK]: 5,
[QueueName.CLIP_ENCODING]: 2,
[QueueName.METADATA_EXTRACTION]: 5,
[QueueName.OBJECT_TAGGING]: 2,
[QueueName.RECOGNIZE_FACES]: 2,
[QueueName.SEARCH]: 5,
[QueueName.SIDECAR]: 5,
[QueueName.STORAGE_TEMPLATE_MIGRATION]: 5,
[QueueName.THUMBNAIL_GENERATION]: 5,
[QueueName.VIDEO_CONVERSION]: 1,
};

View File

@ -33,13 +33,13 @@ export type JobItem =
| { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IEntityJob } | { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IEntityJob }
// User Deletion // User Deletion
| { name: JobName.USER_DELETE_CHECK } | { name: JobName.USER_DELETE_CHECK; data?: IBaseJob }
| { name: JobName.USER_DELETION; data: IEntityJob } | { name: JobName.USER_DELETION; data: IEntityJob }
// Storage Template // Storage Template
| { name: JobName.STORAGE_TEMPLATE_MIGRATION } | { name: JobName.STORAGE_TEMPLATE_MIGRATION; data?: IBaseJob }
| { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE; data: IEntityJob } | { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE; data: IEntityJob }
| { name: JobName.SYSTEM_CONFIG_CHANGE } | { name: JobName.SYSTEM_CONFIG_CHANGE; data?: IBaseJob }
// Metadata Extraction // Metadata Extraction
| { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob } | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
@ -67,22 +67,26 @@ export type JobItem =
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob } | { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
// Asset Deletion // Asset Deletion
| { name: JobName.PERSON_CLEANUP } | { name: JobName.PERSON_CLEANUP; data?: IBaseJob }
// Search // Search
| { name: JobName.SEARCH_INDEX_ASSETS } | { name: JobName.SEARCH_INDEX_ASSETS; data?: IBaseJob }
| { name: JobName.SEARCH_INDEX_ASSET; data: IBulkEntityJob } | { name: JobName.SEARCH_INDEX_ASSET; data: IBulkEntityJob }
| { name: JobName.SEARCH_INDEX_FACES } | { name: JobName.SEARCH_INDEX_FACES; data?: IBaseJob }
| { name: JobName.SEARCH_INDEX_FACE; data: IAssetFaceJob } | { name: JobName.SEARCH_INDEX_FACE; data: IAssetFaceJob }
| { name: JobName.SEARCH_INDEX_ALBUMS } | { name: JobName.SEARCH_INDEX_ALBUMS; data?: IBaseJob }
| { name: JobName.SEARCH_INDEX_ALBUM; data: IBulkEntityJob } | { name: JobName.SEARCH_INDEX_ALBUM; data: IBulkEntityJob }
| { name: JobName.SEARCH_REMOVE_ASSET; data: IBulkEntityJob } | { name: JobName.SEARCH_REMOVE_ASSET; data: IBulkEntityJob }
| { name: JobName.SEARCH_REMOVE_ALBUM; data: IBulkEntityJob } | { name: JobName.SEARCH_REMOVE_ALBUM; data: IBulkEntityJob }
| { name: JobName.SEARCH_REMOVE_FACE; data: IAssetFaceJob }; | { name: JobName.SEARCH_REMOVE_FACE; data: IAssetFaceJob };
export type JobHandler<T = any> = (data: T) => boolean | Promise<boolean>;
export const IJobRepository = 'IJobRepository'; export const IJobRepository = 'IJobRepository';
export interface IJobRepository { export interface IJobRepository {
addHandler(queueName: QueueName, concurrency: number, handler: (job: JobItem) => Promise<void>): void;
setConcurrency(queueName: QueueName, concurrency: number): void;
queue(item: JobItem): Promise<void>; queue(item: JobItem): Promise<void>;
pause(name: QueueName): Promise<void>; pause(name: QueueName): Promise<void>;
resume(name: QueueName): Promise<void>; resume(name: QueueName): Promise<void>;

View File

@ -1,20 +1,28 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { newAssetRepositoryMock, newCommunicationRepositoryMock, newJobRepositoryMock } from '../../test'; import {
newAssetRepositoryMock,
newCommunicationRepositoryMock,
newJobRepositoryMock,
newSystemConfigRepositoryMock,
} from '../../test';
import { IAssetRepository } from '../asset'; import { IAssetRepository } from '../asset';
import { ICommunicationRepository } from '../communication'; import { ICommunicationRepository } from '../communication';
import { IJobRepository, JobCommand, JobName, JobService, QueueName } from '../job'; import { IJobRepository, JobCommand, JobHandler, JobName, JobService, QueueName } from '../job';
import { ISystemConfigRepository } from '../system-config';
describe(JobService.name, () => { describe(JobService.name, () => {
let sut: JobService; let sut: JobService;
let assetMock: jest.Mocked<IAssetRepository>; let assetMock: jest.Mocked<IAssetRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
let communicationMock: jest.Mocked<ICommunicationRepository>; let communicationMock: jest.Mocked<ICommunicationRepository>;
let jobMock: jest.Mocked<IJobRepository>; let jobMock: jest.Mocked<IJobRepository>;
beforeEach(async () => { beforeEach(async () => {
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock();
communicationMock = newCommunicationRepositoryMock(); communicationMock = newCommunicationRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
sut = new JobService(assetMock, communicationMock, jobMock); sut = new JobService(assetMock, communicationMock, jobMock, configMock);
}); });
it('should work', () => { it('should work', () => {
@ -64,16 +72,16 @@ describe(JobService.name, () => {
}; };
await expect(sut.getAllJobsStatus()).resolves.toEqual({ await expect(sut.getAllJobsStatus()).resolves.toEqual({
'background-task-queue': expectedJobStatus, [QueueName.BACKGROUND_TASK]: expectedJobStatus,
'clip-encoding-queue': expectedJobStatus, [QueueName.CLIP_ENCODING]: expectedJobStatus,
'metadata-extraction-queue': expectedJobStatus, [QueueName.METADATA_EXTRACTION]: expectedJobStatus,
'object-tagging-queue': expectedJobStatus, [QueueName.OBJECT_TAGGING]: expectedJobStatus,
'search-queue': expectedJobStatus, [QueueName.SEARCH]: expectedJobStatus,
'storage-template-migration-queue': expectedJobStatus, [QueueName.STORAGE_TEMPLATE_MIGRATION]: expectedJobStatus,
'thumbnail-generation-queue': expectedJobStatus, [QueueName.THUMBNAIL_GENERATION]: expectedJobStatus,
'video-conversion-queue': expectedJobStatus, [QueueName.VIDEO_CONVERSION]: expectedJobStatus,
'recognize-faces-queue': expectedJobStatus, [QueueName.RECOGNIZE_FACES]: expectedJobStatus,
'sidecar-queue': expectedJobStatus, [QueueName.SIDECAR]: expectedJobStatus,
}); });
}); });
}); });
@ -147,6 +155,14 @@ describe(JobService.name, () => {
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force: false } }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force: false } });
}); });
it('should handle a start sidecar command', async () => {
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.SIDECAR, { command: JobCommand.START, force: false });
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SIDECAR, data: { force: false } });
});
it('should handle a start thumbnail generation command', async () => { it('should handle a start thumbnail generation command', async () => {
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
@ -155,6 +171,14 @@ describe(JobService.name, () => {
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } });
}); });
it('should handle a start recognize faces command', async () => {
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.RECOGNIZE_FACES, { command: JobCommand.START, force: false });
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_RECOGNIZE_FACES, data: { force: false } });
});
it('should throw a bad request when an invalid queue is used', async () => { it('should throw a bad request when an invalid queue is used', async () => {
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
@ -165,4 +189,19 @@ describe(JobService.name, () => {
expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
}); });
}); });
describe('registerHandlers', () => {
it('should register a handler for each queue', async () => {
const mock = jest.fn();
const handlers = Object.values(JobName).reduce((map, jobName) => ({ ...map, [jobName]: mock }), {}) as Record<
JobName,
JobHandler
>;
await sut.registerHandlers(handlers);
expect(configMock.load).toHaveBeenCalled();
expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length);
});
});
}); });

View File

@ -2,20 +2,26 @@ import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'
import { IAssetRepository, mapAsset } from '../asset'; import { IAssetRepository, mapAsset } from '../asset';
import { CommunicationEvent, ICommunicationRepository } from '../communication'; import { CommunicationEvent, ICommunicationRepository } from '../communication';
import { assertMachineLearningEnabled } from '../domain.constant'; import { assertMachineLearningEnabled } from '../domain.constant';
import { ISystemConfigRepository } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core';
import { JobCommandDto } from './dto'; import { JobCommandDto } from './dto';
import { JobCommand, JobName, QueueName } from './job.constants'; import { JobCommand, JobName, QueueName } from './job.constants';
import { IJobRepository, JobItem } from './job.repository'; import { IJobRepository, JobHandler, JobItem } from './job.repository';
import { AllJobStatusResponseDto, JobStatusDto } from './response-dto'; import { AllJobStatusResponseDto, JobStatusDto } from './response-dto';
@Injectable() @Injectable()
export class JobService { export class JobService {
private logger = new Logger(JobService.name); private logger = new Logger(JobService.name);
private configCore: SystemConfigCore;
constructor( constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
) {} @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
) {
this.configCore = new SystemConfigCore(configRepository);
}
handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> { handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> {
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`); this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
@ -90,6 +96,36 @@ export class JobService {
} }
} }
async registerHandlers(jobHandlers: Record<JobName, JobHandler>) {
const config = await this.configCore.getConfig();
for (const queueName of Object.values(QueueName)) {
const concurrency = config.job[queueName].concurrency;
this.logger.debug(`Registering ${queueName} with a concurrency of ${concurrency}`);
this.jobRepository.addHandler(queueName, concurrency, async (item: JobItem): Promise<void> => {
const { name, data } = item;
try {
const handler = jobHandlers[name];
const success = await handler(data);
if (success) {
await this.onDone(item);
}
} catch (error: Error | any) {
this.logger.error(`Unable to run job handler: ${error}`, error?.stack, data);
}
});
}
this.configCore.config$.subscribe((config) => {
this.logger.log(`Updating queue concurrency settings`);
for (const queueName of Object.values(QueueName)) {
const concurrency = config.job[queueName].concurrency;
this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`);
this.jobRepository.setConcurrency(queueName, concurrency);
}
});
}
async handleNightlyJobs() { async handleNightlyJobs() {
await this.jobRepository.queue({ name: JobName.USER_DELETE_CHECK }); await this.jobRepository.queue({ name: JobName.USER_DELETE_CHECK });
await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP }); await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP });

View File

@ -0,0 +1,73 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator';
import { QueueName } from '../../job';
export class JobSettingsDto {
@IsInt()
@IsPositive()
@ApiProperty({ type: 'integer' })
concurrency!: number;
}
export class SystemConfigJobDto implements Record<QueueName, JobSettingsDto> {
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.THUMBNAIL_GENERATION]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.METADATA_EXTRACTION]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.VIDEO_CONVERSION]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.OBJECT_TAGGING]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.CLIP_ENCODING]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.BACKGROUND_TASK]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.SEARCH]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.RECOGNIZE_FACES]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.SIDECAR]!: JobSettingsDto;
}

View File

@ -1,6 +1,7 @@
import { SystemConfig } from '@app/infra/entities'; import { SystemConfig } from '@app/infra/entities';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsObject, ValidateNested } from 'class-validator'; import { IsObject, ValidateNested } from 'class-validator';
import { SystemConfigJobDto } from './system-config-job.dto';
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto'; import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
import { SystemConfigOAuthDto } from './system-config-oauth.dto'; import { SystemConfigOAuthDto } from './system-config-oauth.dto';
import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto'; import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto';
@ -26,6 +27,11 @@ export class SystemConfigDto {
@ValidateNested() @ValidateNested()
@IsObject() @IsObject()
storageTemplate!: SystemConfigStorageTemplateDto; storageTemplate!: SystemConfigStorageTemplateDto;
@Type(() => SystemConfigJobDto)
@ValidateNested()
@IsObject()
job!: SystemConfigJobDto;
} }
export function mapConfig(config: SystemConfig): SystemConfigDto { export function mapConfig(config: SystemConfig): SystemConfigDto {

View File

@ -1,13 +1,20 @@
import { SystemConfig, SystemConfigEntity, SystemConfigKey, TranscodePreset } from '@app/infra/entities'; import {
SystemConfig,
SystemConfigEntity,
SystemConfigKey,
SystemConfigValue,
TranscodePreset,
} from '@app/infra/entities';
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import * as _ from 'lodash'; import * as _ from 'lodash';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { DeepPartial } from 'typeorm'; import { DeepPartial } from 'typeorm';
import { QueueName } from '../job/job.constants';
import { ISystemConfigRepository } from './system-config.repository'; import { ISystemConfigRepository } from './system-config.repository';
export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>; export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
const defaults: SystemConfig = Object.freeze({ const defaults = Object.freeze<SystemConfig>({
ffmpeg: { ffmpeg: {
crf: 23, crf: 23,
threads: 0, threads: 0,
@ -19,6 +26,18 @@ const defaults: SystemConfig = Object.freeze({
twoPass: false, twoPass: false,
transcode: TranscodePreset.REQUIRED, transcode: TranscodePreset.REQUIRED,
}, },
job: {
[QueueName.BACKGROUND_TASK]: { concurrency: 5 },
[QueueName.CLIP_ENCODING]: { concurrency: 2 },
[QueueName.METADATA_EXTRACTION]: { concurrency: 5 },
[QueueName.OBJECT_TAGGING]: { concurrency: 2 },
[QueueName.RECOGNIZE_FACES]: { concurrency: 2 },
[QueueName.SEARCH]: { concurrency: 5 },
[QueueName.SIDECAR]: { concurrency: 5 },
[QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 },
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
},
oauth: { oauth: {
enabled: false, enabled: false,
issuerUrl: '', issuerUrl: '',
@ -85,7 +104,7 @@ export class SystemConfigCore {
for (const key of Object.values(SystemConfigKey)) { for (const key of Object.values(SystemConfigKey)) {
// get via dot notation // get via dot notation
const item = { key, value: _.get(config, key) }; const item = { key, value: _.get(config, key) as SystemConfigValue };
const defaultValue = _.get(defaults, key); const defaultValue = _.get(defaults, key);
const isMissing = !_.has(config, key); const isMissing = !_.has(config, key);

View File

@ -1,7 +1,7 @@
import { SystemConfigEntity, SystemConfigKey, TranscodePreset } from '@app/infra/entities'; import { SystemConfig, SystemConfigEntity, SystemConfigKey, TranscodePreset } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '../../test'; import { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '../../test';
import { IJobRepository, JobName } from '../job'; import { IJobRepository, JobName, QueueName } from '../job';
import { SystemConfigValidator } from './system-config.core'; import { SystemConfigValidator } from './system-config.core';
import { ISystemConfigRepository } from './system-config.repository'; import { ISystemConfigRepository } from './system-config.repository';
import { SystemConfigService } from './system-config.service'; import { SystemConfigService } from './system-config.service';
@ -11,7 +11,19 @@ const updates: SystemConfigEntity[] = [
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true }, { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
]; ];
const updatedConfig = Object.freeze({ const updatedConfig = Object.freeze<SystemConfig>({
job: {
[QueueName.BACKGROUND_TASK]: { concurrency: 5 },
[QueueName.CLIP_ENCODING]: { concurrency: 2 },
[QueueName.METADATA_EXTRACTION]: { concurrency: 5 },
[QueueName.OBJECT_TAGGING]: { concurrency: 2 },
[QueueName.RECOGNIZE_FACES]: { concurrency: 2 },
[QueueName.SEARCH]: { concurrency: 5 },
[QueueName.SIDECAR]: { concurrency: 5 },
[QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 },
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
},
ffmpeg: { ffmpeg: {
crf: 30, crf: 30,
threads: 0, threads: 0,

View File

@ -23,6 +23,7 @@ import {
AuthUserDto, AuthUserDto,
ExifResponseDto, ExifResponseDto,
mapUser, mapUser,
QueueName,
SearchResult, SearchResult,
SharedLinkResponseDto, SharedLinkResponseDto,
TagResponseDto, TagResponseDto,
@ -531,6 +532,18 @@ export const systemConfigStub = {
twoPass: false, twoPass: false,
transcode: TranscodePreset.REQUIRED, transcode: TranscodePreset.REQUIRED,
}, },
job: {
[QueueName.BACKGROUND_TASK]: { concurrency: 5 },
[QueueName.CLIP_ENCODING]: { concurrency: 2 },
[QueueName.METADATA_EXTRACTION]: { concurrency: 5 },
[QueueName.OBJECT_TAGGING]: { concurrency: 2 },
[QueueName.RECOGNIZE_FACES]: { concurrency: 2 },
[QueueName.SEARCH]: { concurrency: 5 },
[QueueName.SIDECAR]: { concurrency: 5 },
[QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 },
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
},
oauth: { oauth: {
autoLaunch: false, autoLaunch: false,
autoRegister: true, autoRegister: true,

View File

@ -2,6 +2,8 @@ import { IJobRepository } from '../src';
export const newJobRepositoryMock = (): jest.Mocked<IJobRepository> => { export const newJobRepositoryMock = (): jest.Mocked<IJobRepository> => {
return { return {
addHandler: jest.fn(),
setConcurrency: jest.fn(),
empty: jest.fn(), empty: jest.fn(),
pause: jest.fn(), pause: jest.fn(),
resume: jest.fn(), resume: jest.fn(),

View File

@ -1,7 +1,8 @@
import { Column, Entity, PrimaryColumn } from 'typeorm'; import { Column, Entity, PrimaryColumn } from 'typeorm';
import { QueueName } from '../../../domain/src';
@Entity('system_config') @Entity('system_config')
export class SystemConfigEntity<T = string | boolean | number> { export class SystemConfigEntity<T = SystemConfigValue> {
@PrimaryColumn() @PrimaryColumn()
key!: SystemConfigKey; key!: SystemConfigKey;
@ -9,7 +10,7 @@ export class SystemConfigEntity<T = string | boolean | number> {
value!: T; value!: T;
} }
export type SystemConfigValue = any; export type SystemConfigValue = string | number | boolean;
// dot notation matches path in `SystemConfig` // dot notation matches path in `SystemConfig`
export enum SystemConfigKey { export enum SystemConfigKey {
@ -22,6 +23,18 @@ export enum SystemConfigKey {
FFMPEG_MAX_BITRATE = 'ffmpeg.maxBitrate', FFMPEG_MAX_BITRATE = 'ffmpeg.maxBitrate',
FFMPEG_TWO_PASS = 'ffmpeg.twoPass', FFMPEG_TWO_PASS = 'ffmpeg.twoPass',
FFMPEG_TRANSCODE = 'ffmpeg.transcode', FFMPEG_TRANSCODE = 'ffmpeg.transcode',
JOB_THUMBNAIL_GENERATION_CONCURRENCY = 'job.thumbnailGeneration.concurrency',
JOB_METADATA_EXTRACTION_CONCURRENCY = 'job.metadataExtraction.concurrency',
JOB_VIDEO_CONVERSION_CONCURRENCY = 'job.videoConversion.concurrency',
JOB_OBJECT_TAGGING_CONCURRENCY = 'job.objectTagging.concurrency',
JOB_RECOGNIZE_FACES_CONCURRENCY = 'job.recognizeFaces.concurrency',
JOB_CLIP_ENCODING_CONCURRENCY = 'job.clipEncoding.concurrency',
JOB_BACKGROUND_TASK_CONCURRENCY = 'job.backgroundTask.concurrency',
JOB_STORAGE_TEMPLATE_MIGRATION_CONCURRENCY = 'job.storageTemplateMigration.concurrency',
JOB_SEARCH_CONCURRENCY = 'job.search.concurrency',
JOB_SIDECAR_CONCURRENCY = 'job.sidecar.concurrency',
OAUTH_ENABLED = 'oauth.enabled', OAUTH_ENABLED = 'oauth.enabled',
OAUTH_ISSUER_URL = 'oauth.issuerUrl', OAUTH_ISSUER_URL = 'oauth.issuerUrl',
OAUTH_CLIENT_ID = 'oauth.clientId', OAUTH_CLIENT_ID = 'oauth.clientId',
@ -32,7 +45,9 @@ export enum SystemConfigKey {
OAUTH_AUTO_REGISTER = 'oauth.autoRegister', OAUTH_AUTO_REGISTER = 'oauth.autoRegister',
OAUTH_MOBILE_OVERRIDE_ENABLED = 'oauth.mobileOverrideEnabled', OAUTH_MOBILE_OVERRIDE_ENABLED = 'oauth.mobileOverrideEnabled',
OAUTH_MOBILE_REDIRECT_URI = 'oauth.mobileRedirectUri', OAUTH_MOBILE_REDIRECT_URI = 'oauth.mobileRedirectUri',
PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled', PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled',
STORAGE_TEMPLATE = 'storageTemplate.template', STORAGE_TEMPLATE = 'storageTemplate.template',
} }
@ -55,6 +70,7 @@ export interface SystemConfig {
twoPass: boolean; twoPass: boolean;
transcode: TranscodePreset; transcode: TranscodePreset;
}; };
job: Record<QueueName, { concurrency: number }>;
oauth: { oauth: {
enabled: boolean; enabled: boolean;
issuerUrl: string; issuerUrl: string;

View File

@ -1,5 +1,6 @@
import { QueueName } from '@app/domain'; import { QueueName } from '@app/domain';
import { BullModuleOptions } from '@nestjs/bull'; import { RegisterQueueOptions } from '@nestjs/bullmq';
import { QueueOptions } from 'bullmq';
import { RedisOptions } from 'ioredis'; import { RedisOptions } from 'ioredis';
import { InitOptions } from 'local-reverse-geocoder'; import { InitOptions } from 'local-reverse-geocoder';
import { ConfigurationOptions } from 'typesense/lib/Typesense/Configuration'; import { ConfigurationOptions } from 'typesense/lib/Typesense/Configuration';
@ -26,9 +27,9 @@ function parseRedisConfig(): RedisOptions {
export const redisConfig: RedisOptions = parseRedisConfig(); export const redisConfig: RedisOptions = parseRedisConfig();
export const bullConfig: BullModuleOptions = { export const bullConfig: QueueOptions = {
prefix: 'immich_bull', prefix: 'immich_bull',
redis: redisConfig, connection: redisConfig,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@ -36,7 +37,7 @@ export const bullConfig: BullModuleOptions = {
}, },
}; };
export const bullQueues: BullModuleOptions[] = Object.values(QueueName).map((name) => ({ name })); export const bullQueues: RegisterQueueOptions[] = Object.values(QueueName).map((name) => ({ name }));
function parseTypeSenseConfig(): ConfigurationOptions { function parseTypeSenseConfig(): ConfigurationOptions {
const typesenseURL = process.env.TYPESENSE_URL; const typesenseURL = process.env.TYPESENSE_URL;

View File

@ -21,7 +21,7 @@ import {
IUserRepository, IUserRepository,
IUserTokenRepository, IUserTokenRepository,
} from '@app/domain'; } from '@app/domain';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bullmq';
import { Global, Module, Provider } from '@nestjs/common'; import { Global, Module, Provider } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';

View File

@ -1,13 +1,33 @@
import { IJobRepository, JobCounts, JobItem, JobName, JOBS_TO_QUEUE, QueueName, QueueStatus } from '@app/domain'; import { IJobRepository, JobCounts, JobItem, JobName, JOBS_TO_QUEUE, QueueName, QueueStatus } from '@app/domain';
import { getQueueToken } from '@nestjs/bull'; import { getQueueToken } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { JobOptions, Queue, type JobCounts as BullJobCounts } from 'bull'; import { Job, JobsOptions, Processor, Queue, Worker, WorkerOptions } from 'bullmq';
import { bullConfig } from '../infra.config';
@Injectable() @Injectable()
export class JobRepository implements IJobRepository { export class JobRepository implements IJobRepository {
private workers: Partial<Record<QueueName, Worker>> = {};
private logger = new Logger(JobRepository.name);
constructor(private moduleRef: ModuleRef) {} constructor(private moduleRef: ModuleRef) {}
addHandler(queueName: QueueName, concurrency: number, handler: (item: JobItem) => Promise<void>) {
const workerHandler: Processor = async (job: Job) => handler(job as JobItem);
const workerOptions: WorkerOptions = { ...bullConfig, concurrency };
this.workers[queueName] = new Worker(queueName, workerHandler, workerOptions);
}
setConcurrency(queueName: QueueName, concurrency: number) {
const worker = this.workers[queueName];
if (!worker) {
this.logger.warn(`Unable to set queue concurrency, worker not found: '${queueName}'`);
return;
}
worker.concurrency = concurrency;
}
async getQueueStatus(name: QueueName): Promise<QueueStatus> { async getQueueStatus(name: QueueName): Promise<QueueStatus> {
const queue = this.getQueue(name); const queue = this.getQueue(name);
@ -26,13 +46,18 @@ export class JobRepository implements IJobRepository {
} }
empty(name: QueueName) { empty(name: QueueName) {
return this.getQueue(name).empty(); return this.getQueue(name).drain();
} }
getJobCounts(name: QueueName): Promise<JobCounts> { getJobCounts(name: QueueName): Promise<JobCounts> {
// Typecast needed because the `paused` key is missing from Bull's return this.getQueue(name).getJobCounts(
// type definition. Can be removed once fixed upstream. 'active',
return this.getQueue(name).getJobCounts() as Promise<BullJobCounts & { paused: number }>; 'completed',
'failed',
'delayed',
'waiting',
'paused',
) as unknown as Promise<JobCounts>;
} }
async queue(item: JobItem): Promise<void> { async queue(item: JobItem): Promise<void> {
@ -43,7 +68,7 @@ export class JobRepository implements IJobRepository {
await this.getQueue(JOBS_TO_QUEUE[jobName]).add(jobName, jobData, jobOptions); await this.getQueue(JOBS_TO_QUEUE[jobName]).add(jobName, jobData, jobOptions);
} }
private getJobOptions(item: JobItem): JobOptions | null { private getJobOptions(item: JobItem): JobsOptions | null {
switch (item.name) { switch (item.name) {
case JobName.GENERATE_FACE_THUMBNAIL: case JobName.GENERATE_FACE_THUMBNAIL:
return { priority: 1 }; return { priority: 1 };

View File

@ -9,7 +9,7 @@ export class SystemConfigRepository implements ISystemConfigRepository {
private repository: Repository<SystemConfigEntity>, private repository: Repository<SystemConfigEntity>,
) {} ) {}
load(): Promise<SystemConfigEntity<string | boolean | number>[]> { load(): Promise<SystemConfigEntity[]> {
return this.repository.find(); return this.repository.find();
} }

204
server/package-lock.json generated
View File

@ -10,7 +10,7 @@
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.20.13", "@babel/runtime": "^7.20.13",
"@nestjs/bull": "^0.6.2", "@nestjs/bullmq": "^1.1.0",
"@nestjs/common": "^9.2.1", "@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0", "@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.2.1", "@nestjs/core": "^9.2.1",
@ -24,7 +24,7 @@
"archiver": "^5.3.1", "archiver": "^5.3.1",
"axios": "^0.26.0", "axios": "^0.26.0",
"bcrypt": "^5.0.1", "bcrypt": "^5.0.1",
"bull": "^4.10.2", "bullmq": "^3.14.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
@ -1507,20 +1507,6 @@
"win32" "win32"
] ]
}, },
"node_modules/@nestjs/bull": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-0.6.3.tgz",
"integrity": "sha512-CckH9O3t9qSiO4RCzdYvtFSaaMfIhTXMYagV/rtmVvI1SX5XNnxEaQXvtjxDBXF9DB1JE/5AejIl6ICym+MJIw==",
"dependencies": {
"@nestjs/bull-shared": "^0.1.3",
"tslib": "2.5.0"
},
"peerDependencies": {
"@nestjs/common": "^6.10.11 || ^7.0.0 || ^8.0.0 || ^9.0.0",
"@nestjs/core": "^6.10.11 || ^7.0.0 || ^8.0.0 || ^9.0.0",
"bull": "^3.3 || ^4.0.0"
}
},
"node_modules/@nestjs/bull-shared": { "node_modules/@nestjs/bull-shared": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-0.1.3.tgz", "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-0.1.3.tgz",
@ -1533,6 +1519,20 @@
"@nestjs/core": "^6.10.11 || ^7.0.0 || ^8.0.0 || ^9.0.0" "@nestjs/core": "^6.10.11 || ^7.0.0 || ^8.0.0 || ^9.0.0"
} }
}, },
"node_modules/@nestjs/bullmq": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-1.1.0.tgz",
"integrity": "sha512-XloO39ACm9TuB8XOX53iMUaCW5BTQAnZADtX4a9kczJZU/5/cQVBfSCyfzq9L6dfi5EX6w/1Ayyv+5qBQ5yrzw==",
"dependencies": {
"@nestjs/bull-shared": "^0.1.3",
"tslib": "2.5.0"
},
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0",
"bullmq": "^3.0.0"
}
},
"node_modules/@nestjs/cli": { "node_modules/@nestjs/cli": {
"version": "9.4.2", "version": "9.4.2",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.4.2.tgz", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.4.2.tgz",
@ -4232,30 +4232,56 @@
"node": ">=0.2.0" "node": ">=0.2.0"
} }
}, },
"node_modules/bull": { "node_modules/bullmq": {
"version": "4.10.4", "version": "3.14.1",
"resolved": "https://registry.npmjs.org/bull/-/bull-4.10.4.tgz", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-3.14.1.tgz",
"integrity": "sha512-o9m/7HjS/Or3vqRd59evBlWCXd9Lp+ALppKseoSKHaykK46SmRjAilX98PgmOz1yeVaurt8D5UtvEt4bUjM3eA==", "integrity": "sha512-Fom78UKljYsnJmwbROVPx3eFLuVfQjQbw9KCnVupLzT31RQHhFHV2xd/4J4oWl4u34bZ1JmEUfNnqNBz+IOJuA==",
"dependencies": { "dependencies": {
"cron-parser": "^4.2.1", "cron-parser": "^4.6.0",
"debuglog": "^1.0.0", "glob": "^8.0.3",
"get-port": "^5.1.1", "ioredis": "^5.3.2",
"ioredis": "^5.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"msgpackr": "^1.5.2", "msgpackr": "^1.6.2",
"semver": "^7.3.2", "semver": "^7.3.7",
"uuid": "^8.3.0" "tslib": "^2.0.0",
"uuid": "^9.0.0"
}
},
"node_modules/bullmq/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/bullmq/node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/bull/node_modules/uuid": { "node_modules/bullmq/node_modules/minimatch": {
"version": "8.3.2", "version": "5.1.6",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"bin": { "dependencies": {
"uuid": "dist/bin/uuid" "brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
} }
}, },
"node_modules/busboy": { "node_modules/busboy": {
@ -5013,14 +5039,6 @@
} }
} }
}, },
"node_modules/debuglog": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz",
"integrity": "sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw==",
"engines": {
"node": "*"
}
},
"node_modules/decimal.js": { "node_modules/decimal.js": {
"version": "10.4.3", "version": "10.4.3",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
@ -6422,17 +6440,6 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/get-port": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz",
"integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-stream": { "node_modules/get-stream": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@ -8429,9 +8436,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}, },
"node_modules/msgpackr": { "node_modules/msgpackr": {
"version": "1.9.1", "version": "1.9.2",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.1.tgz", "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.2.tgz",
"integrity": "sha512-jJdrNH8tzfCtT0rjPFryBXjRDQE7rqfLkah4/8B4gYa7NNZYFBcGxqWBtfQpGC+oYyBwlkj3fARk4aooKNPHxg==", "integrity": "sha512-xtDgI3Xv0AAiZWLRGDchyzBwU6aq0rwJ+W+5Y4CZhEWtkl/hJtFFLc+3JtGTw7nz1yquxs7nL8q/yA2aqpflIQ==",
"optionalDependencies": { "optionalDependencies": {
"msgpackr-extract": "^3.0.2" "msgpackr-extract": "^3.0.2"
} }
@ -13122,15 +13129,6 @@
"integrity": "sha512-O+6Gs8UeDbyFpbSh2CPEz/UOrrdWPTBYNblZK5CxxLisYt4kGX3Sc+czffFonyjiGSq3jWLwJS/CCJc7tBr4sQ==", "integrity": "sha512-O+6Gs8UeDbyFpbSh2CPEz/UOrrdWPTBYNblZK5CxxLisYt4kGX3Sc+czffFonyjiGSq3jWLwJS/CCJc7tBr4sQ==",
"optional": true "optional": true
}, },
"@nestjs/bull": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-0.6.3.tgz",
"integrity": "sha512-CckH9O3t9qSiO4RCzdYvtFSaaMfIhTXMYagV/rtmVvI1SX5XNnxEaQXvtjxDBXF9DB1JE/5AejIl6ICym+MJIw==",
"requires": {
"@nestjs/bull-shared": "^0.1.3",
"tslib": "2.5.0"
}
},
"@nestjs/bull-shared": { "@nestjs/bull-shared": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-0.1.3.tgz", "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-0.1.3.tgz",
@ -13139,6 +13137,15 @@
"tslib": "2.5.0" "tslib": "2.5.0"
} }
}, },
"@nestjs/bullmq": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-1.1.0.tgz",
"integrity": "sha512-XloO39ACm9TuB8XOX53iMUaCW5BTQAnZADtX4a9kczJZU/5/cQVBfSCyfzq9L6dfi5EX6w/1Ayyv+5qBQ5yrzw==",
"requires": {
"@nestjs/bull-shared": "^0.1.3",
"tslib": "2.5.0"
}
},
"@nestjs/cli": { "@nestjs/cli": {
"version": "9.4.2", "version": "9.4.2",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.4.2.tgz", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.4.2.tgz",
@ -15212,25 +15219,48 @@
"resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz",
"integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==" "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="
}, },
"bull": { "bullmq": {
"version": "4.10.4", "version": "3.14.1",
"resolved": "https://registry.npmjs.org/bull/-/bull-4.10.4.tgz", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-3.14.1.tgz",
"integrity": "sha512-o9m/7HjS/Or3vqRd59evBlWCXd9Lp+ALppKseoSKHaykK46SmRjAilX98PgmOz1yeVaurt8D5UtvEt4bUjM3eA==", "integrity": "sha512-Fom78UKljYsnJmwbROVPx3eFLuVfQjQbw9KCnVupLzT31RQHhFHV2xd/4J4oWl4u34bZ1JmEUfNnqNBz+IOJuA==",
"requires": { "requires": {
"cron-parser": "^4.2.1", "cron-parser": "^4.6.0",
"debuglog": "^1.0.0", "glob": "^8.0.3",
"get-port": "^5.1.1", "ioredis": "^5.3.2",
"ioredis": "^5.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"msgpackr": "^1.5.2", "msgpackr": "^1.6.2",
"semver": "^7.3.2", "semver": "^7.3.7",
"uuid": "^8.3.0" "tslib": "^2.0.0",
"uuid": "^9.0.0"
}, },
"dependencies": { "dependencies": {
"uuid": { "brace-expansion": {
"version": "8.3.2", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"requires": {
"balanced-match": "^1.0.0"
}
},
"glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
}
},
"minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"requires": {
"brace-expansion": "^2.0.1"
}
} }
} }
}, },
@ -15800,11 +15830,6 @@
"ms": "2.1.2" "ms": "2.1.2"
} }
}, },
"debuglog": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz",
"integrity": "sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw=="
},
"decimal.js": { "decimal.js": {
"version": "10.4.3", "version": "10.4.3",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
@ -16867,11 +16892,6 @@
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
"dev": true "dev": true
}, },
"get-port": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz",
"integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ=="
},
"get-stream": { "get-stream": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@ -18386,9 +18406,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}, },
"msgpackr": { "msgpackr": {
"version": "1.9.1", "version": "1.9.2",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.1.tgz", "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.2.tgz",
"integrity": "sha512-jJdrNH8tzfCtT0rjPFryBXjRDQE7rqfLkah4/8B4gYa7NNZYFBcGxqWBtfQpGC+oYyBwlkj3fARk4aooKNPHxg==", "integrity": "sha512-xtDgI3Xv0AAiZWLRGDchyzBwU6aq0rwJ+W+5Y4CZhEWtkl/hJtFFLc+3JtGTw7nz1yquxs7nL8q/yA2aqpflIQ==",
"requires": { "requires": {
"msgpackr-extract": "^3.0.2" "msgpackr-extract": "^3.0.2"
} }

View File

@ -41,7 +41,7 @@
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.20.13", "@babel/runtime": "^7.20.13",
"@nestjs/bull": "^0.6.2", "@nestjs/bullmq": "^1.1.0",
"@nestjs/common": "^9.2.1", "@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0", "@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.2.1", "@nestjs/core": "^9.2.1",
@ -55,7 +55,7 @@
"archiver": "^5.3.1", "archiver": "^5.3.1",
"axios": "^0.26.0", "axios": "^0.26.0",
"bcrypt": "^5.0.1", "bcrypt": "^5.0.1",
"bull": "^4.10.2", "bullmq": "^3.14.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
@ -140,9 +140,9 @@
"coverageThreshold": { "coverageThreshold": {
"./libs/domain/": { "./libs/domain/": {
"branches": 80, "branches": 80,
"functions": 85, "functions": 80,
"lines": 93, "lines": 90,
"statements": 93 "statements": 90
} }
}, },
"setupFilesAfterEnv": [ "setupFilesAfterEnv": [

View File

@ -15,7 +15,8 @@ import {
ShareApi, ShareApi,
SystemConfigApi, SystemConfigApi,
UserApi, UserApi,
UserApiFp UserApiFp,
JobName
} from './open-api'; } from './open-api';
import { BASE_PATH } from './open-api/base'; import { BASE_PATH } from './open-api/base';
import { DUMMY_BASE_URL, toPathString } from './open-api/common'; import { DUMMY_BASE_URL, toPathString } from './open-api/common';
@ -106,6 +107,23 @@ export class ImmichApi {
const path = `/person/${personId}/thumbnail`; const path = `/person/${personId}/thumbnail`;
return this.createUrl(path); return this.createUrl(path);
} }
public getJobName(jobName: JobName) {
const names: Record<JobName, string> = {
[JobName.ThumbnailGeneration]: 'Generate Thumbnails',
[JobName.MetadataExtraction]: 'Extract Metadata',
[JobName.Sidecar]: 'Sidecar Metadata',
[JobName.ObjectTagging]: 'Tag Objects',
[JobName.ClipEncoding]: 'Encode Clip',
[JobName.RecognizeFaces]: 'Recognize Faces',
[JobName.VideoConversion]: 'Transcode Videos',
[JobName.StorageTemplateMigration]: 'Storage Template Migration',
[JobName.BackgroundTask]: 'Background Tasks',
[JobName.Search]: 'Search'
};
return names[jobName];
}
} }
export const api = new ImmichApi({ basePath: '/api' }); export const api = new ImmichApi({ basePath: '/api' });

View File

@ -296,61 +296,61 @@ export interface AllJobStatusResponseDto {
* @type {JobStatusDto} * @type {JobStatusDto}
* @memberof AllJobStatusResponseDto * @memberof AllJobStatusResponseDto
*/ */
'thumbnail-generation-queue': JobStatusDto; 'thumbnailGeneration': JobStatusDto;
/** /**
* *
* @type {JobStatusDto} * @type {JobStatusDto}
* @memberof AllJobStatusResponseDto * @memberof AllJobStatusResponseDto
*/ */
'metadata-extraction-queue': JobStatusDto; 'metadataExtraction': JobStatusDto;
/** /**
* *
* @type {JobStatusDto} * @type {JobStatusDto}
* @memberof AllJobStatusResponseDto * @memberof AllJobStatusResponseDto
*/ */
'video-conversion-queue': JobStatusDto; 'videoConversion': JobStatusDto;
/** /**
* *
* @type {JobStatusDto} * @type {JobStatusDto}
* @memberof AllJobStatusResponseDto * @memberof AllJobStatusResponseDto
*/ */
'object-tagging-queue': JobStatusDto; 'objectTagging': JobStatusDto;
/** /**
* *
* @type {JobStatusDto} * @type {JobStatusDto}
* @memberof AllJobStatusResponseDto * @memberof AllJobStatusResponseDto
*/ */
'clip-encoding-queue': JobStatusDto; 'clipEncoding': JobStatusDto;
/** /**
* *
* @type {JobStatusDto} * @type {JobStatusDto}
* @memberof AllJobStatusResponseDto * @memberof AllJobStatusResponseDto
*/ */
'storage-template-migration-queue': JobStatusDto; 'storageTemplateMigration': JobStatusDto;
/** /**
* *
* @type {JobStatusDto} * @type {JobStatusDto}
* @memberof AllJobStatusResponseDto * @memberof AllJobStatusResponseDto
*/ */
'background-task-queue': JobStatusDto; 'backgroundTask': JobStatusDto;
/** /**
* *
* @type {JobStatusDto} * @type {JobStatusDto}
* @memberof AllJobStatusResponseDto * @memberof AllJobStatusResponseDto
*/ */
'search-queue': JobStatusDto; 'search': JobStatusDto;
/** /**
* *
* @type {JobStatusDto} * @type {JobStatusDto}
* @memberof AllJobStatusResponseDto * @memberof AllJobStatusResponseDto
*/ */
'recognize-faces-queue': JobStatusDto; 'recognizeFaces': JobStatusDto;
/** /**
* *
* @type {JobStatusDto} * @type {JobStatusDto}
* @memberof AllJobStatusResponseDto * @memberof AllJobStatusResponseDto
*/ */
'sidecar-queue': JobStatusDto; 'sidecar': JobStatusDto;
} }
/** /**
* *
@ -1486,21 +1486,34 @@ export interface JobCountsDto {
*/ */
export const JobName = { export const JobName = {
ThumbnailGenerationQueue: 'thumbnail-generation-queue', ThumbnailGeneration: 'thumbnailGeneration',
MetadataExtractionQueue: 'metadata-extraction-queue', MetadataExtraction: 'metadataExtraction',
VideoConversionQueue: 'video-conversion-queue', VideoConversion: 'videoConversion',
ObjectTaggingQueue: 'object-tagging-queue', ObjectTagging: 'objectTagging',
RecognizeFacesQueue: 'recognize-faces-queue', RecognizeFaces: 'recognizeFaces',
ClipEncodingQueue: 'clip-encoding-queue', ClipEncoding: 'clipEncoding',
BackgroundTaskQueue: 'background-task-queue', BackgroundTask: 'backgroundTask',
StorageTemplateMigrationQueue: 'storage-template-migration-queue', StorageTemplateMigration: 'storageTemplateMigration',
SearchQueue: 'search-queue', Search: 'search',
SidecarQueue: 'sidecar-queue' Sidecar: 'sidecar'
} as const; } as const;
export type JobName = typeof JobName[keyof typeof JobName]; export type JobName = typeof JobName[keyof typeof JobName];
/**
*
* @export
* @interface JobSettingsDto
*/
export interface JobSettingsDto {
/**
*
* @type {number}
* @memberof JobSettingsDto
*/
'concurrency': number;
}
/** /**
* *
* @export * @export
@ -2247,6 +2260,12 @@ export interface SystemConfigDto {
* @memberof SystemConfigDto * @memberof SystemConfigDto
*/ */
'storageTemplate': SystemConfigStorageTemplateDto; 'storageTemplate': SystemConfigStorageTemplateDto;
/**
*
* @type {SystemConfigJobDto}
* @memberof SystemConfigDto
*/
'job': SystemConfigJobDto;
} }
/** /**
* *
@ -2319,6 +2338,73 @@ export const SystemConfigFFmpegDtoTranscodeEnum = {
export type SystemConfigFFmpegDtoTranscodeEnum = typeof SystemConfigFFmpegDtoTranscodeEnum[keyof typeof SystemConfigFFmpegDtoTranscodeEnum]; export type SystemConfigFFmpegDtoTranscodeEnum = typeof SystemConfigFFmpegDtoTranscodeEnum[keyof typeof SystemConfigFFmpegDtoTranscodeEnum];
/**
*
* @export
* @interface SystemConfigJobDto
*/
export interface SystemConfigJobDto {
/**
*
* @type {JobSettingsDto}
* @memberof SystemConfigJobDto
*/
'thumbnailGeneration': JobSettingsDto;
/**
*
* @type {JobSettingsDto}
* @memberof SystemConfigJobDto
*/
'metadataExtraction': JobSettingsDto;
/**
*
* @type {JobSettingsDto}
* @memberof SystemConfigJobDto
*/
'videoConversion': JobSettingsDto;
/**
*
* @type {JobSettingsDto}
* @memberof SystemConfigJobDto
*/
'objectTagging': JobSettingsDto;
/**
*
* @type {JobSettingsDto}
* @memberof SystemConfigJobDto
*/
'clipEncoding': JobSettingsDto;
/**
*
* @type {JobSettingsDto}
* @memberof SystemConfigJobDto
*/
'storageTemplateMigration': JobSettingsDto;
/**
*
* @type {JobSettingsDto}
* @memberof SystemConfigJobDto
*/
'backgroundTask': JobSettingsDto;
/**
*
* @type {JobSettingsDto}
* @memberof SystemConfigJobDto
*/
'search': JobSettingsDto;
/**
*
* @type {JobSettingsDto}
* @memberof SystemConfigJobDto
*/
'recognizeFaces': JobSettingsDto;
/**
*
* @type {JobSettingsDto}
* @memberof SystemConfigJobDto
*/
'sidecar': JobSettingsDto;
}
/** /**
* *
* @export * @export

View File

@ -30,7 +30,7 @@
</script> </script>
<div <div
class="flex sm:flex-row flex-col bg-gray-100 dark:bg-immich-dark-gray rounded-3xl overflow-hidden" class="flex sm:flex-row flex-col bg-gray-100 dark:bg-immich-dark-gray rounded-[35px] overflow-hidden"
> >
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
{#if queueStatus.isPaused} {#if queueStatus.isPaused}

View File

@ -9,15 +9,17 @@
import Icon from 'svelte-material-icons/DotsVertical.svelte'; import Icon from 'svelte-material-icons/DotsVertical.svelte';
import FaceRecognition from 'svelte-material-icons/FaceRecognition.svelte'; import FaceRecognition from 'svelte-material-icons/FaceRecognition.svelte';
import FileJpgBox from 'svelte-material-icons/FileJpgBox.svelte'; import FileJpgBox from 'svelte-material-icons/FileJpgBox.svelte';
import FolderMove from 'svelte-material-icons/FolderMove.svelte';
import Table from 'svelte-material-icons/Table.svelte';
import FileXmlBox from 'svelte-material-icons/FileXmlBox.svelte'; import FileXmlBox from 'svelte-material-icons/FileXmlBox.svelte';
import FolderMove from 'svelte-material-icons/FolderMove.svelte';
import Information from 'svelte-material-icons/Information.svelte';
import Table from 'svelte-material-icons/Table.svelte';
import TagMultiple from 'svelte-material-icons/TagMultiple.svelte'; import TagMultiple from 'svelte-material-icons/TagMultiple.svelte';
import VectorCircle from 'svelte-material-icons/VectorCircle.svelte'; import VectorCircle from 'svelte-material-icons/VectorCircle.svelte';
import Video from 'svelte-material-icons/Video.svelte'; import Video from 'svelte-material-icons/Video.svelte';
import ConfirmDialogue from '../../shared-components/confirm-dialogue.svelte'; import ConfirmDialogue from '../../shared-components/confirm-dialogue.svelte';
import JobTile from './job-tile.svelte'; import JobTile from './job-tile.svelte';
import StorageMigrationDescription from './storage-migration-description.svelte'; import StorageMigrationDescription from './storage-migration-description.svelte';
import { AppRoute } from '$lib/constants';
export let jobs: AllJobStatusResponseDto; export let jobs: AllJobStatusResponseDto;
@ -45,52 +47,52 @@
const onFaceConfirm = () => { const onFaceConfirm = () => {
faceConfirm = false; faceConfirm = false;
handleCommand(JobName.RecognizeFacesQueue, { command: JobCommand.Start, force: true }); handleCommand(JobName.RecognizeFaces, { command: JobCommand.Start, force: true });
}; };
const jobDetails: Partial<Record<JobName, JobDetails>> = { const jobDetails: Partial<Record<JobName, JobDetails>> = {
[JobName.ThumbnailGenerationQueue]: { [JobName.ThumbnailGeneration]: {
icon: FileJpgBox, icon: FileJpgBox,
title: 'Generate Thumbnails', title: api.getJobName(JobName.ThumbnailGeneration),
subtitle: 'Regenerate JPEG and WebP thumbnails' subtitle: 'Regenerate JPEG and WebP thumbnails'
}, },
[JobName.MetadataExtractionQueue]: { [JobName.MetadataExtraction]: {
icon: Table, icon: Table,
title: 'Extract Metadata', title: api.getJobName(JobName.MetadataExtraction),
subtitle: 'Extract metadata information i.e. GPS, resolution...etc' subtitle: 'Extract metadata information i.e. GPS, resolution...etc'
}, },
[JobName.SidecarQueue]: { [JobName.Sidecar]: {
title: 'Sidecar Metadata', title: api.getJobName(JobName.Sidecar),
icon: FileXmlBox, icon: FileXmlBox,
subtitle: 'Discover or synchronize sidecar metadata from the filesystem', subtitle: 'Discover or synchronize sidecar metadata from the filesystem',
allText: 'SYNC', allText: 'SYNC',
missingText: 'DISCOVER' missingText: 'DISCOVER'
}, },
[JobName.ObjectTaggingQueue]: { [JobName.ObjectTagging]: {
icon: TagMultiple, icon: TagMultiple,
title: 'Tag Objects', title: api.getJobName(JobName.ObjectTagging),
subtitle: subtitle:
'Run machine learning to tag objects\nNote that some assets may not have any objects detected' 'Run machine learning to tag objects\nNote that some assets may not have any objects detected'
}, },
[JobName.ClipEncodingQueue]: { [JobName.ClipEncoding]: {
icon: VectorCircle, icon: VectorCircle,
title: 'Encode Clip', title: api.getJobName(JobName.ClipEncoding),
subtitle: 'Run machine learning to generate clip embeddings' subtitle: 'Run machine learning to generate clip embeddings'
}, },
[JobName.RecognizeFacesQueue]: { [JobName.RecognizeFaces]: {
icon: FaceRecognition, icon: FaceRecognition,
title: 'Recognize Faces', title: api.getJobName(JobName.RecognizeFaces),
subtitle: 'Run machine learning to recognize faces', subtitle: 'Run machine learning to recognize faces',
handleCommand: handleFaceCommand handleCommand: handleFaceCommand
}, },
[JobName.VideoConversionQueue]: { [JobName.VideoConversion]: {
icon: Video, icon: Video,
title: 'Transcode Videos', title: api.getJobName(JobName.VideoConversion),
subtitle: 'Transcode videos not in the desired format' subtitle: 'Transcode videos not in the desired format'
}, },
[JobName.StorageTemplateMigrationQueue]: { [JobName.StorageTemplateMigration]: {
icon: FolderMove, icon: FolderMove,
title: 'Storage Template Migration', title: api.getJobName(JobName.StorageTemplateMigration),
allowForceCommand: false, allowForceCommand: false,
component: StorageMigrationDescription component: StorageMigrationDescription
} }
@ -128,6 +130,17 @@
{/if} {/if}
<div class="flex flex-col gap-7"> <div class="flex flex-col gap-7">
<div class="flex dark:text-white text-black gap-2 bg-gray-200 dark:bg-gray-700 p-6 rounded-full">
<Information />
<p class="text-xs">
MANAGE JOB CURRENCENCY LEVEL IN
<a
href={`${AppRoute.ADMIN_SETTINGS}?open=job-settings`}
class="text-immich-primary dark:text-immich-dark-primary font-medium">JOB SETTINGS</a
>
</p>
</div>
{#each jobDetailsArray as [jobName, { title, subtitle, allText, missingText, allowForceCommand, icon, component, handleCommand: handleCommandOverride }]} {#each jobDetailsArray as [jobName, { title, subtitle, allText, missingText, allowForceCommand, icon, component, handleCommand: handleCommandOverride }]}
{@const { jobCounts, queueStatus } = jobs[jobName]} {@const { jobCounts, queueStatus } = jobs[jobName]}
<JobTile <JobTile

View File

@ -0,0 +1,103 @@
<script lang="ts">
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { api, JobName, SystemConfigJobDto } from '@api';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import { handleError } from '../../../../utils/handle-error';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
export let jobConfig: SystemConfigJobDto; // this is the config that is being edited
let savedConfig: SystemConfigJobDto;
let defaultConfig: SystemConfigJobDto;
const ignoredJobs = [JobName.BackgroundTask, JobName.Search] as JobName[];
const jobNames = Object.values(JobName).filter(
(jobName) => !ignoredJobs.includes(jobName as JobName)
);
async function getConfigs() {
[savedConfig, defaultConfig] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.job),
api.systemConfigApi.getDefaults().then((res) => res.data.job)
]);
}
async function saveSetting() {
try {
const { data: configs } = await api.systemConfigApi.getConfig();
const result = await api.systemConfigApi.updateConfig({
systemConfigDto: {
...configs,
job: jobConfig
}
});
jobConfig = { ...result.data.job };
savedConfig = { ...result.data.job };
notificationController.show({ message: 'Job settings saved', type: NotificationType.Info });
} catch (error) {
handleError(error, 'Unable to save settings');
}
}
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
jobConfig = { ...resetConfig.job };
savedConfig = { ...resetConfig.job };
notificationController.show({
message: 'Reset Job settings to the recent saved settings',
type: NotificationType.Info
});
}
async function resetToDefault() {
const { data: configs } = await api.systemConfigApi.getDefaults();
jobConfig = { ...configs.job };
defaultConfig = { ...configs.job };
notificationController.show({
message: 'Reset Job settings to default',
type: NotificationType.Info
});
}
</script>
<div>
{#await getConfigs() then}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
{#each jobNames as jobName}
<div class="flex flex-col gap-4 ml-4 mt-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="{api.getJobName(jobName)} Concurrency"
desc=""
bind:value={jobConfig[jobName].concurrency}
required={true}
isEdited={!(jobConfig[jobName].concurrency == savedConfig[jobName].concurrency)}
/>
</div>
{/each}
<div class="ml-4">
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
/>
</div>
</form>
</div>
{/await}
</div>

View File

@ -21,6 +21,9 @@
const handleInput = (e: Event) => { const handleInput = (e: Event) => {
value = (e.target as HTMLInputElement).value; value = (e.target as HTMLInputElement).value;
if (inputType === SettingInputFieldType.NUMBER) {
value = Number(value) || 0;
}
}; };
</script> </script>

View File

@ -9,7 +9,7 @@ export function handleError(error: unknown, message: string) {
let serverMessage = (error as ApiError)?.response?.data?.message; let serverMessage = (error as ApiError)?.response?.data?.message;
if (serverMessage) { if (serverMessage) {
serverMessage = `${String(serverMessage).slice(0, 50)}\n<i>(Immich Server Error)<i>`; serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`;
} }
notificationController.show({ notificationController.show({

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte'; import FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte';
import JobSettings from '$lib/components/admin-page/settings/job-settings/job-settings.svelte';
import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-settings.svelte'; import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-settings.svelte';
import PasswordLoginSettings from '$lib/components/admin-page/settings/password-login/password-login-settings.svelte'; import PasswordLoginSettings from '$lib/components/admin-page/settings/password-login/password-login-settings.svelte';
import SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte'; import SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte';
@ -28,6 +29,14 @@
<FFmpegSettings ffmpegConfig={configs.ffmpeg} /> <FFmpegSettings ffmpegConfig={configs.ffmpeg} />
</SettingAccordion> </SettingAccordion>
<SettingAccordion
title="Job Settings"
subtitle="Manage job concurrency"
isOpen={$page.url.searchParams.get('open') === 'job-settings'}
>
<JobSettings jobConfig={configs.job} />
</SettingAccordion>
<SettingAccordion <SettingAccordion
title="Password Authentication" title="Password Authentication"
subtitle="Manage login with password settings" subtitle="Manage login with password settings"