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/JobCountsDto.md
doc/JobName.md
doc/JobSettingsDto.md
doc/JobStatusDto.md
doc/LoginCredentialDto.md
doc/LoginResponseDto.md
@ -95,6 +96,7 @@ doc/SmartInfoResponseDto.md
doc/SystemConfigApi.md
doc/SystemConfigDto.md
doc/SystemConfigFFmpegDto.md
doc/SystemConfigJobDto.md
doc/SystemConfigOAuthDto.md
doc/SystemConfigPasswordLoginDto.md
doc/SystemConfigStorageTemplateDto.md
@ -186,6 +188,7 @@ lib/model/job_command.dart
lib/model/job_command_dto.dart
lib/model/job_counts_dto.dart
lib/model/job_name.dart
lib/model/job_settings_dto.dart
lib/model/job_status_dto.dart
lib/model/login_credential_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/system_config_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_password_login_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_counts_dto_test.dart
test/job_name_test.dart
test/job_settings_dto_test.dart
test/job_status_dto_test.dart
test/login_credential_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_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_password_login_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')
async sendJobCommand(@Param() { jobId }: JobIdDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> {
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 { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppService } from './app.service';
import { MicroservicesModule } from './microservices.module';
import { ProcessorService } from './processor.service';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
const logger = new Logger('ImmichMicroservice');
@ -15,7 +15,7 @@ async function bootstrap() {
const listeningPort = Number(process.env.MICROSERVICES_PORT) || 3002;
await app.get(ProcessorService).init();
await app.get(AppService).init();
app.useWebSocketAdapter(new RedisIoAdapter(app));

View File

@ -3,7 +3,7 @@ import { InfraModule } from '@app/infra';
import { ExifEntity } from '@app/infra/entities';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProcessorService } from './processor.service';
import { AppService } from './app.service';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
@Module({
@ -12,6 +12,6 @@ import { MetadataExtractionProcessor } from './processors/metadata-extraction.pr
DomainModule.register({ imports: [InfraModule] }),
TypeOrmModule.forFeature([ExifEntity]),
],
providers: [MetadataExtractionProcessor, ProcessorService],
providers: [MetadataExtractionProcessor, AppService],
})
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": {
"type": "object",
"properties": {
"thumbnail-generation-queue": {
"thumbnailGeneration": {
"$ref": "#/components/schemas/JobStatusDto"
},
"metadata-extraction-queue": {
"metadataExtraction": {
"$ref": "#/components/schemas/JobStatusDto"
},
"video-conversion-queue": {
"videoConversion": {
"$ref": "#/components/schemas/JobStatusDto"
},
"object-tagging-queue": {
"objectTagging": {
"$ref": "#/components/schemas/JobStatusDto"
},
"clip-encoding-queue": {
"clipEncoding": {
"$ref": "#/components/schemas/JobStatusDto"
},
"storage-template-migration-queue": {
"storageTemplateMigration": {
"$ref": "#/components/schemas/JobStatusDto"
},
"background-task-queue": {
"backgroundTask": {
"$ref": "#/components/schemas/JobStatusDto"
},
"search-queue": {
"search": {
"$ref": "#/components/schemas/JobStatusDto"
},
"recognize-faces-queue": {
"recognizeFaces": {
"$ref": "#/components/schemas/JobStatusDto"
},
"sidecar-queue": {
"sidecar": {
"$ref": "#/components/schemas/JobStatusDto"
}
},
"required": [
"thumbnail-generation-queue",
"metadata-extraction-queue",
"video-conversion-queue",
"object-tagging-queue",
"clip-encoding-queue",
"storage-template-migration-queue",
"background-task-queue",
"search-queue",
"recognize-faces-queue",
"sidecar-queue"
"thumbnailGeneration",
"metadataExtraction",
"videoConversion",
"objectTagging",
"clipEncoding",
"storageTemplateMigration",
"backgroundTask",
"search",
"recognizeFaces",
"sidecar"
]
},
"JobName": {
"type": "string",
"enum": [
"thumbnail-generation-queue",
"metadata-extraction-queue",
"video-conversion-queue",
"object-tagging-queue",
"recognize-faces-queue",
"clip-encoding-queue",
"background-task-queue",
"storage-template-migration-queue",
"search-queue",
"sidecar-queue"
"thumbnailGeneration",
"metadataExtraction",
"videoConversion",
"objectTagging",
"recognizeFaces",
"clipEncoding",
"backgroundTask",
"storageTemplateMigration",
"search",
"sidecar"
]
},
"JobCommand": {
@ -5733,6 +5733,64 @@
"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": {
"type": "object",
"properties": {
@ -5747,13 +5805,17 @@
},
"storageTemplate": {
"$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
},
"job": {
"$ref": "#/components/schemas/SystemConfigJobDto"
}
},
"required": [
"ffmpeg",
"oauth",
"passwordLogin",
"storageTemplate"
"storageTemplate",
"job"
]
},
"SystemConfigTemplateStorageOptionDto": {

View File

@ -1,14 +1,14 @@
export enum QueueName {
THUMBNAIL_GENERATION = 'thumbnail-generation-queue',
METADATA_EXTRACTION = 'metadata-extraction-queue',
VIDEO_CONVERSION = 'video-conversion-queue',
OBJECT_TAGGING = 'object-tagging-queue',
RECOGNIZE_FACES = 'recognize-faces-queue',
CLIP_ENCODING = 'clip-encoding-queue',
BACKGROUND_TASK = 'background-task-queue',
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue',
SEARCH = 'search-queue',
SIDECAR = 'sidecar-queue',
THUMBNAIL_GENERATION = 'thumbnailGeneration',
METADATA_EXTRACTION = 'metadataExtraction',
VIDEO_CONVERSION = 'videoConversion',
OBJECT_TAGGING = 'objectTagging',
RECOGNIZE_FACES = 'recognizeFaces',
CLIP_ENCODING = 'clipEncoding',
BACKGROUND_TASK = 'backgroundTask',
STORAGE_TEMPLATE_MIGRATION = 'storageTemplateMigration',
SEARCH = 'search',
SIDECAR = 'sidecar',
}
export enum JobCommand {
@ -135,17 +135,3 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[JobName.SIDECAR_DISCOVERY]: 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 }
// User Deletion
| { name: JobName.USER_DELETE_CHECK }
| { name: JobName.USER_DELETE_CHECK; data?: IBaseJob }
| { name: JobName.USER_DELETION; data: IEntityJob }
// Storage Template
| { name: JobName.STORAGE_TEMPLATE_MIGRATION }
| { name: JobName.STORAGE_TEMPLATE_MIGRATION; data?: IBaseJob }
| { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE; data: IEntityJob }
| { name: JobName.SYSTEM_CONFIG_CHANGE }
| { name: JobName.SYSTEM_CONFIG_CHANGE; data?: IBaseJob }
// Metadata Extraction
| { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
@ -67,22 +67,26 @@ export type JobItem =
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
// Asset Deletion
| { name: JobName.PERSON_CLEANUP }
| { name: JobName.PERSON_CLEANUP; data?: IBaseJob }
// Search
| { name: JobName.SEARCH_INDEX_ASSETS }
| { name: JobName.SEARCH_INDEX_ASSETS; data?: IBaseJob }
| { 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_ALBUMS }
| { name: JobName.SEARCH_INDEX_ALBUMS; data?: IBaseJob }
| { name: JobName.SEARCH_INDEX_ALBUM; data: IBulkEntityJob }
| { name: JobName.SEARCH_REMOVE_ASSET; data: IBulkEntityJob }
| { name: JobName.SEARCH_REMOVE_ALBUM; data: IBulkEntityJob }
| { name: JobName.SEARCH_REMOVE_FACE; data: IAssetFaceJob };
export type JobHandler<T = any> = (data: T) => boolean | Promise<boolean>;
export const IJobRepository = '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>;
pause(name: QueueName): Promise<void>;
resume(name: QueueName): Promise<void>;

View File

@ -1,20 +1,28 @@
import { BadRequestException } from '@nestjs/common';
import { newAssetRepositoryMock, newCommunicationRepositoryMock, newJobRepositoryMock } from '../../test';
import {
newAssetRepositoryMock,
newCommunicationRepositoryMock,
newJobRepositoryMock,
newSystemConfigRepositoryMock,
} from '../../test';
import { IAssetRepository } from '../asset';
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, () => {
let sut: JobService;
let assetMock: jest.Mocked<IAssetRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
let communicationMock: jest.Mocked<ICommunicationRepository>;
let jobMock: jest.Mocked<IJobRepository>;
beforeEach(async () => {
assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock();
communicationMock = newCommunicationRepositoryMock();
jobMock = newJobRepositoryMock();
sut = new JobService(assetMock, communicationMock, jobMock);
sut = new JobService(assetMock, communicationMock, jobMock, configMock);
});
it('should work', () => {
@ -64,16 +72,16 @@ describe(JobService.name, () => {
};
await expect(sut.getAllJobsStatus()).resolves.toEqual({
'background-task-queue': expectedJobStatus,
'clip-encoding-queue': expectedJobStatus,
'metadata-extraction-queue': expectedJobStatus,
'object-tagging-queue': expectedJobStatus,
'search-queue': expectedJobStatus,
'storage-template-migration-queue': expectedJobStatus,
'thumbnail-generation-queue': expectedJobStatus,
'video-conversion-queue': expectedJobStatus,
'recognize-faces-queue': expectedJobStatus,
'sidecar-queue': expectedJobStatus,
[QueueName.BACKGROUND_TASK]: expectedJobStatus,
[QueueName.CLIP_ENCODING]: expectedJobStatus,
[QueueName.METADATA_EXTRACTION]: expectedJobStatus,
[QueueName.OBJECT_TAGGING]: expectedJobStatus,
[QueueName.SEARCH]: expectedJobStatus,
[QueueName.STORAGE_TEMPLATE_MIGRATION]: expectedJobStatus,
[QueueName.THUMBNAIL_GENERATION]: expectedJobStatus,
[QueueName.VIDEO_CONVERSION]: expectedJobStatus,
[QueueName.RECOGNIZE_FACES]: expectedJobStatus,
[QueueName.SIDECAR]: expectedJobStatus,
});
});
});
@ -147,6 +155,14 @@ describe(JobService.name, () => {
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 () => {
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 } });
});
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 () => {
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
@ -165,4 +189,19 @@ describe(JobService.name, () => {
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 { CommunicationEvent, ICommunicationRepository } from '../communication';
import { assertMachineLearningEnabled } from '../domain.constant';
import { ISystemConfigRepository } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core';
import { JobCommandDto } from './dto';
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';
@Injectable()
export class JobService {
private logger = new Logger(JobService.name);
private configCore: SystemConfigCore;
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {}
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
) {
this.configCore = new SystemConfigCore(configRepository);
}
handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> {
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() {
await this.jobRepository.queue({ name: JobName.USER_DELETE_CHECK });
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 { Type } from 'class-transformer';
import { IsObject, ValidateNested } from 'class-validator';
import { SystemConfigJobDto } from './system-config-job.dto';
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
import { SystemConfigOAuthDto } from './system-config-oauth.dto';
import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto';
@ -26,6 +27,11 @@ export class SystemConfigDto {
@ValidateNested()
@IsObject()
storageTemplate!: SystemConfigStorageTemplateDto;
@Type(() => SystemConfigJobDto)
@ValidateNested()
@IsObject()
job!: SystemConfigJobDto;
}
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 * as _ from 'lodash';
import { Subject } from 'rxjs';
import { DeepPartial } from 'typeorm';
import { QueueName } from '../job/job.constants';
import { ISystemConfigRepository } from './system-config.repository';
export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
const defaults: SystemConfig = Object.freeze({
const defaults = Object.freeze<SystemConfig>({
ffmpeg: {
crf: 23,
threads: 0,
@ -19,6 +26,18 @@ const defaults: SystemConfig = Object.freeze({
twoPass: false,
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: {
enabled: false,
issuerUrl: '',
@ -85,7 +104,7 @@ export class SystemConfigCore {
for (const key of Object.values(SystemConfigKey)) {
// 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 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 { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '../../test';
import { IJobRepository, JobName } from '../job';
import { IJobRepository, JobName, QueueName } from '../job';
import { SystemConfigValidator } from './system-config.core';
import { ISystemConfigRepository } from './system-config.repository';
import { SystemConfigService } from './system-config.service';
@ -11,7 +11,19 @@ const updates: SystemConfigEntity[] = [
{ 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: {
crf: 30,
threads: 0,

View File

@ -23,6 +23,7 @@ import {
AuthUserDto,
ExifResponseDto,
mapUser,
QueueName,
SearchResult,
SharedLinkResponseDto,
TagResponseDto,
@ -531,6 +532,18 @@ export const systemConfigStub = {
twoPass: false,
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: {
autoLaunch: false,
autoRegister: true,

View File

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

View File

@ -1,7 +1,8 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
import { QueueName } from '../../../domain/src';
@Entity('system_config')
export class SystemConfigEntity<T = string | boolean | number> {
export class SystemConfigEntity<T = SystemConfigValue> {
@PrimaryColumn()
key!: SystemConfigKey;
@ -9,7 +10,7 @@ export class SystemConfigEntity<T = string | boolean | number> {
value!: T;
}
export type SystemConfigValue = any;
export type SystemConfigValue = string | number | boolean;
// dot notation matches path in `SystemConfig`
export enum SystemConfigKey {
@ -22,6 +23,18 @@ export enum SystemConfigKey {
FFMPEG_MAX_BITRATE = 'ffmpeg.maxBitrate',
FFMPEG_TWO_PASS = 'ffmpeg.twoPass',
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_ISSUER_URL = 'oauth.issuerUrl',
OAUTH_CLIENT_ID = 'oauth.clientId',
@ -32,7 +45,9 @@ export enum SystemConfigKey {
OAUTH_AUTO_REGISTER = 'oauth.autoRegister',
OAUTH_MOBILE_OVERRIDE_ENABLED = 'oauth.mobileOverrideEnabled',
OAUTH_MOBILE_REDIRECT_URI = 'oauth.mobileRedirectUri',
PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled',
STORAGE_TEMPLATE = 'storageTemplate.template',
}
@ -55,6 +70,7 @@ export interface SystemConfig {
twoPass: boolean;
transcode: TranscodePreset;
};
job: Record<QueueName, { concurrency: number }>;
oauth: {
enabled: boolean;
issuerUrl: string;

View File

@ -1,5 +1,6 @@
import { QueueName } from '@app/domain';
import { BullModuleOptions } from '@nestjs/bull';
import { RegisterQueueOptions } from '@nestjs/bullmq';
import { QueueOptions } from 'bullmq';
import { RedisOptions } from 'ioredis';
import { InitOptions } from 'local-reverse-geocoder';
import { ConfigurationOptions } from 'typesense/lib/Typesense/Configuration';
@ -26,9 +27,9 @@ function parseRedisConfig(): RedisOptions {
export const redisConfig: RedisOptions = parseRedisConfig();
export const bullConfig: BullModuleOptions = {
export const bullConfig: QueueOptions = {
prefix: 'immich_bull',
redis: redisConfig,
connection: redisConfig,
defaultJobOptions: {
attempts: 3,
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 {
const typesenseURL = process.env.TYPESENSE_URL;

View File

@ -21,7 +21,7 @@ import {
IUserRepository,
IUserTokenRepository,
} from '@app/domain';
import { BullModule } from '@nestjs/bull';
import { BullModule } from '@nestjs/bullmq';
import { Global, Module, Provider } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
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 { getQueueToken } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import { getQueueToken } from '@nestjs/bullmq';
import { Injectable, Logger } from '@nestjs/common';
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()
export class JobRepository implements IJobRepository {
private workers: Partial<Record<QueueName, Worker>> = {};
private logger = new Logger(JobRepository.name);
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> {
const queue = this.getQueue(name);
@ -26,13 +46,18 @@ export class JobRepository implements IJobRepository {
}
empty(name: QueueName) {
return this.getQueue(name).empty();
return this.getQueue(name).drain();
}
getJobCounts(name: QueueName): Promise<JobCounts> {
// Typecast needed because the `paused` key is missing from Bull's
// type definition. Can be removed once fixed upstream.
return this.getQueue(name).getJobCounts() as Promise<BullJobCounts & { paused: number }>;
return this.getQueue(name).getJobCounts(
'active',
'completed',
'failed',
'delayed',
'waiting',
'paused',
) as unknown as Promise<JobCounts>;
}
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);
}
private getJobOptions(item: JobItem): JobOptions | null {
private getJobOptions(item: JobItem): JobsOptions | null {
switch (item.name) {
case JobName.GENERATE_FACE_THUMBNAIL:
return { priority: 1 };

View File

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

204
server/package-lock.json generated
View File

@ -10,7 +10,7 @@
"license": "UNLICENSED",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@nestjs/bull": "^0.6.2",
"@nestjs/bullmq": "^1.1.0",
"@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.2.1",
@ -24,7 +24,7 @@
"archiver": "^5.3.1",
"axios": "^0.26.0",
"bcrypt": "^5.0.1",
"bull": "^4.10.2",
"bullmq": "^3.14.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
@ -1507,20 +1507,6 @@
"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": {
"version": "0.1.3",
"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"
}
},
"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": {
"version": "9.4.2",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.4.2.tgz",
@ -4232,30 +4232,56 @@
"node": ">=0.2.0"
}
},
"node_modules/bull": {
"version": "4.10.4",
"resolved": "https://registry.npmjs.org/bull/-/bull-4.10.4.tgz",
"integrity": "sha512-o9m/7HjS/Or3vqRd59evBlWCXd9Lp+ALppKseoSKHaykK46SmRjAilX98PgmOz1yeVaurt8D5UtvEt4bUjM3eA==",
"node_modules/bullmq": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-3.14.1.tgz",
"integrity": "sha512-Fom78UKljYsnJmwbROVPx3eFLuVfQjQbw9KCnVupLzT31RQHhFHV2xd/4J4oWl4u34bZ1JmEUfNnqNBz+IOJuA==",
"dependencies": {
"cron-parser": "^4.2.1",
"debuglog": "^1.0.0",
"get-port": "^5.1.1",
"ioredis": "^5.0.0",
"cron-parser": "^4.6.0",
"glob": "^8.0.3",
"ioredis": "^5.3.2",
"lodash": "^4.17.21",
"msgpackr": "^1.5.2",
"semver": "^7.3.2",
"uuid": "^8.3.0"
"msgpackr": "^1.6.2",
"semver": "^7.3.7",
"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": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/bull/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
"node_modules/bullmq/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"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": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
@ -6422,17 +6440,6 @@
"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": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@ -8429,9 +8436,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/msgpackr": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.1.tgz",
"integrity": "sha512-jJdrNH8tzfCtT0rjPFryBXjRDQE7rqfLkah4/8B4gYa7NNZYFBcGxqWBtfQpGC+oYyBwlkj3fARk4aooKNPHxg==",
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.2.tgz",
"integrity": "sha512-xtDgI3Xv0AAiZWLRGDchyzBwU6aq0rwJ+W+5Y4CZhEWtkl/hJtFFLc+3JtGTw7nz1yquxs7nL8q/yA2aqpflIQ==",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
@ -13122,15 +13129,6 @@
"integrity": "sha512-O+6Gs8UeDbyFpbSh2CPEz/UOrrdWPTBYNblZK5CxxLisYt4kGX3Sc+czffFonyjiGSq3jWLwJS/CCJc7tBr4sQ==",
"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": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-0.1.3.tgz",
@ -13139,6 +13137,15 @@
"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": {
"version": "9.4.2",
"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",
"integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="
},
"bull": {
"version": "4.10.4",
"resolved": "https://registry.npmjs.org/bull/-/bull-4.10.4.tgz",
"integrity": "sha512-o9m/7HjS/Or3vqRd59evBlWCXd9Lp+ALppKseoSKHaykK46SmRjAilX98PgmOz1yeVaurt8D5UtvEt4bUjM3eA==",
"bullmq": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-3.14.1.tgz",
"integrity": "sha512-Fom78UKljYsnJmwbROVPx3eFLuVfQjQbw9KCnVupLzT31RQHhFHV2xd/4J4oWl4u34bZ1JmEUfNnqNBz+IOJuA==",
"requires": {
"cron-parser": "^4.2.1",
"debuglog": "^1.0.0",
"get-port": "^5.1.1",
"ioredis": "^5.0.0",
"cron-parser": "^4.6.0",
"glob": "^8.0.3",
"ioredis": "^5.3.2",
"lodash": "^4.17.21",
"msgpackr": "^1.5.2",
"semver": "^7.3.2",
"uuid": "^8.3.0"
"msgpackr": "^1.6.2",
"semver": "^7.3.7",
"tslib": "^2.0.0",
"uuid": "^9.0.0"
},
"dependencies": {
"uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
"brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"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"
}
},
"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": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
@ -16867,11 +16892,6 @@
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
"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": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@ -18386,9 +18406,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"msgpackr": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.1.tgz",
"integrity": "sha512-jJdrNH8tzfCtT0rjPFryBXjRDQE7rqfLkah4/8B4gYa7NNZYFBcGxqWBtfQpGC+oYyBwlkj3fARk4aooKNPHxg==",
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.2.tgz",
"integrity": "sha512-xtDgI3Xv0AAiZWLRGDchyzBwU6aq0rwJ+W+5Y4CZhEWtkl/hJtFFLc+3JtGTw7nz1yquxs7nL8q/yA2aqpflIQ==",
"requires": {
"msgpackr-extract": "^3.0.2"
}

View File

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

View File

@ -15,7 +15,8 @@ import {
ShareApi,
SystemConfigApi,
UserApi,
UserApiFp
UserApiFp,
JobName
} from './open-api';
import { BASE_PATH } from './open-api/base';
import { DUMMY_BASE_URL, toPathString } from './open-api/common';
@ -106,6 +107,23 @@ export class ImmichApi {
const path = `/person/${personId}/thumbnail`;
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' });

View File

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

View File

@ -30,7 +30,7 @@
</script>
<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">
{#if queueStatus.isPaused}

View File

@ -9,15 +9,17 @@
import Icon from 'svelte-material-icons/DotsVertical.svelte';
import FaceRecognition from 'svelte-material-icons/FaceRecognition.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 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 VectorCircle from 'svelte-material-icons/VectorCircle.svelte';
import Video from 'svelte-material-icons/Video.svelte';
import ConfirmDialogue from '../../shared-components/confirm-dialogue.svelte';
import JobTile from './job-tile.svelte';
import StorageMigrationDescription from './storage-migration-description.svelte';
import { AppRoute } from '$lib/constants';
export let jobs: AllJobStatusResponseDto;
@ -45,52 +47,52 @@
const onFaceConfirm = () => {
faceConfirm = false;
handleCommand(JobName.RecognizeFacesQueue, { command: JobCommand.Start, force: true });
handleCommand(JobName.RecognizeFaces, { command: JobCommand.Start, force: true });
};
const jobDetails: Partial<Record<JobName, JobDetails>> = {
[JobName.ThumbnailGenerationQueue]: {
[JobName.ThumbnailGeneration]: {
icon: FileJpgBox,
title: 'Generate Thumbnails',
title: api.getJobName(JobName.ThumbnailGeneration),
subtitle: 'Regenerate JPEG and WebP thumbnails'
},
[JobName.MetadataExtractionQueue]: {
[JobName.MetadataExtraction]: {
icon: Table,
title: 'Extract Metadata',
title: api.getJobName(JobName.MetadataExtraction),
subtitle: 'Extract metadata information i.e. GPS, resolution...etc'
},
[JobName.SidecarQueue]: {
title: 'Sidecar Metadata',
[JobName.Sidecar]: {
title: api.getJobName(JobName.Sidecar),
icon: FileXmlBox,
subtitle: 'Discover or synchronize sidecar metadata from the filesystem',
allText: 'SYNC',
missingText: 'DISCOVER'
},
[JobName.ObjectTaggingQueue]: {
[JobName.ObjectTagging]: {
icon: TagMultiple,
title: 'Tag Objects',
title: api.getJobName(JobName.ObjectTagging),
subtitle:
'Run machine learning to tag objects\nNote that some assets may not have any objects detected'
},
[JobName.ClipEncodingQueue]: {
[JobName.ClipEncoding]: {
icon: VectorCircle,
title: 'Encode Clip',
title: api.getJobName(JobName.ClipEncoding),
subtitle: 'Run machine learning to generate clip embeddings'
},
[JobName.RecognizeFacesQueue]: {
[JobName.RecognizeFaces]: {
icon: FaceRecognition,
title: 'Recognize Faces',
title: api.getJobName(JobName.RecognizeFaces),
subtitle: 'Run machine learning to recognize faces',
handleCommand: handleFaceCommand
},
[JobName.VideoConversionQueue]: {
[JobName.VideoConversion]: {
icon: Video,
title: 'Transcode Videos',
title: api.getJobName(JobName.VideoConversion),
subtitle: 'Transcode videos not in the desired format'
},
[JobName.StorageTemplateMigrationQueue]: {
[JobName.StorageTemplateMigration]: {
icon: FolderMove,
title: 'Storage Template Migration',
title: api.getJobName(JobName.StorageTemplateMigration),
allowForceCommand: false,
component: StorageMigrationDescription
}
@ -128,6 +130,17 @@
{/if}
<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 }]}
{@const { jobCounts, queueStatus } = jobs[jobName]}
<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) => {
value = (e.target as HTMLInputElement).value;
if (inputType === SettingInputFieldType.NUMBER) {
value = Number(value) || 0;
}
};
</script>

View File

@ -9,7 +9,7 @@ export function handleError(error: unknown, message: string) {
let serverMessage = (error as ApiError)?.response?.data?.message;
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({

View File

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