mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-12-07 19:59:07 +00:00
refactor(server): jobs (#2023)
* refactor: job to domain * chore: regenerate open api * chore: tests * fix: missing breaks * fix: get asset with missing exif data --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
2
server/libs/domain/src/job/dto/index.ts
Normal file
2
server/libs/domain/src/job/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './job-command.dto';
|
||||
export * from './job-id.dto';
|
||||
14
server/libs/domain/src/job/dto/job-command.dto.ts
Normal file
14
server/libs/domain/src/job/dto/job-command.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsEnum, IsNotEmpty, IsOptional } from 'class-validator';
|
||||
import { JobCommand } from '../job.constants';
|
||||
|
||||
export class JobCommandDto {
|
||||
@IsNotEmpty()
|
||||
@IsEnum(JobCommand)
|
||||
@ApiProperty({ type: 'string', enum: JobCommand, enumName: 'JobCommand' })
|
||||
command!: JobCommand;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
force!: boolean;
|
||||
}
|
||||
10
server/libs/domain/src/job/dto/job-id.dto.ts
Normal file
10
server/libs/domain/src/job/dto/job-id.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty } from 'class-validator';
|
||||
import { QueueName } from '../job.constants';
|
||||
|
||||
export class JobIdDto {
|
||||
@IsNotEmpty()
|
||||
@IsEnum(QueueName)
|
||||
@ApiProperty({ type: String, enum: QueueName, enumName: 'JobName' })
|
||||
jobId!: QueueName;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
export * from './dto';
|
||||
export * from './job.constants';
|
||||
export * from './job.interface';
|
||||
export * from './job.repository';
|
||||
export * from './job.service';
|
||||
export * from './response-dto';
|
||||
|
||||
@@ -2,32 +2,63 @@ export enum QueueName {
|
||||
THUMBNAIL_GENERATION = 'thumbnail-generation-queue',
|
||||
METADATA_EXTRACTION = 'metadata-extraction-queue',
|
||||
VIDEO_CONVERSION = 'video-conversion-queue',
|
||||
MACHINE_LEARNING = 'machine-learning-queue',
|
||||
BACKGROUND_TASK = 'background-task',
|
||||
OBJECT_TAGGING = 'object-tagging-queue',
|
||||
CLIP_ENCODING = 'clip-encoding-queue',
|
||||
BACKGROUND_TASK = 'background-task-queue',
|
||||
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue',
|
||||
SEARCH = 'search-queue',
|
||||
}
|
||||
|
||||
export enum JobCommand {
|
||||
START = 'start',
|
||||
PAUSE = 'pause',
|
||||
EMPTY = 'empty',
|
||||
}
|
||||
|
||||
export enum JobName {
|
||||
// upload
|
||||
ASSET_UPLOADED = 'asset-uploaded',
|
||||
VIDEO_CONVERSION = 'mp4-conversion',
|
||||
|
||||
// conversion
|
||||
QUEUE_VIDEO_CONVERSION = 'queue-video-conversion',
|
||||
VIDEO_CONVERSION = 'video-conversion',
|
||||
|
||||
// thumbnails
|
||||
QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails',
|
||||
GENERATE_JPEG_THUMBNAIL = 'generate-jpeg-thumbnail',
|
||||
GENERATE_WEBP_THUMBNAIL = 'generate-webp-thumbnail',
|
||||
|
||||
// metadata
|
||||
QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction',
|
||||
EXIF_EXTRACTION = 'exif-extraction',
|
||||
EXTRACT_VIDEO_METADATA = 'extract-video-metadata',
|
||||
REVERSE_GEOCODING = 'reverse-geocoding',
|
||||
|
||||
// user deletion
|
||||
USER_DELETION = 'user-deletion',
|
||||
USER_DELETE_CHECK = 'user-delete-check',
|
||||
|
||||
// storage template
|
||||
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration',
|
||||
SYSTEM_CONFIG_CHANGE = 'system-config-change',
|
||||
OBJECT_DETECTION = 'detect-object',
|
||||
IMAGE_TAGGING = 'tag-image',
|
||||
|
||||
// object tagging
|
||||
QUEUE_OBJECT_TAGGING = 'queue-object-tagging',
|
||||
DETECT_OBJECTS = 'detect-objects',
|
||||
CLASSIFY_IMAGE = 'classify-image',
|
||||
|
||||
// cleanup
|
||||
DELETE_FILES = 'delete-files',
|
||||
|
||||
// search
|
||||
SEARCH_INDEX_ASSETS = 'search-index-assets',
|
||||
SEARCH_INDEX_ASSET = 'search-index-asset',
|
||||
SEARCH_INDEX_ALBUMS = 'search-index-albums',
|
||||
SEARCH_INDEX_ALBUM = 'search-index-album',
|
||||
SEARCH_REMOVE_ALBUM = 'search-remove-album',
|
||||
SEARCH_REMOVE_ASSET = 'search-remove-asset',
|
||||
|
||||
// clip
|
||||
QUEUE_ENCODE_CLIP = 'queue-clip-encode',
|
||||
ENCODE_CLIP = 'clip-encode',
|
||||
}
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/db/entities';
|
||||
|
||||
export interface IAlbumJob {
|
||||
export interface IBaseJob {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface IAlbumJob extends IBaseJob {
|
||||
album: AlbumEntity;
|
||||
}
|
||||
|
||||
export interface IAssetJob {
|
||||
export interface IAssetJob extends IBaseJob {
|
||||
asset: AssetEntity;
|
||||
}
|
||||
|
||||
export interface IBulkEntityJob {
|
||||
export interface IBulkEntityJob extends IBaseJob {
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
export interface IAssetUploadedJob {
|
||||
export interface IAssetUploadedJob extends IBaseJob {
|
||||
asset: AssetEntity;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface IDeleteFilesJob {
|
||||
export interface IDeleteFilesJob extends IBaseJob {
|
||||
files: Array<string | null | undefined>;
|
||||
}
|
||||
|
||||
export interface IUserDeletionJob {
|
||||
export interface IUserDeletionJob extends IBaseJob {
|
||||
user: UserEntity;
|
||||
}
|
||||
|
||||
export interface IReverseGeocodingJob {
|
||||
export interface IReverseGeocodingJob extends IBaseJob {
|
||||
assetId: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { JobName, QueueName } from './job.constants';
|
||||
import {
|
||||
IAssetJob,
|
||||
IAssetUploadedJob,
|
||||
IBaseJob,
|
||||
IBulkEntityJob,
|
||||
IDeleteFilesJob,
|
||||
IReverseGeocodingJob,
|
||||
@@ -17,21 +18,45 @@ export interface JobCounts {
|
||||
}
|
||||
|
||||
export type JobItem =
|
||||
// Asset Upload
|
||||
| { name: JobName.ASSET_UPLOADED; data: IAssetUploadedJob }
|
||||
|
||||
// Transcoding
|
||||
| { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob }
|
||||
| { name: JobName.VIDEO_CONVERSION; data: IAssetJob }
|
||||
|
||||
// Thumbnails
|
||||
| { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob }
|
||||
| { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IAssetJob }
|
||||
| { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IAssetJob }
|
||||
| { name: JobName.EXIF_EXTRACTION; data: IAssetUploadedJob }
|
||||
| { name: JobName.REVERSE_GEOCODING; data: IReverseGeocodingJob }
|
||||
|
||||
// User Deletion
|
||||
| { name: JobName.USER_DELETE_CHECK }
|
||||
| { name: JobName.USER_DELETION; data: IUserDeletionJob }
|
||||
|
||||
// Storage Template
|
||||
| { name: JobName.STORAGE_TEMPLATE_MIGRATION }
|
||||
| { name: JobName.SYSTEM_CONFIG_CHANGE }
|
||||
|
||||
// Metadata Extraction
|
||||
| { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
|
||||
| { name: JobName.EXIF_EXTRACTION; data: IAssetUploadedJob }
|
||||
| { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetUploadedJob }
|
||||
| { name: JobName.OBJECT_DETECTION; data: IAssetJob }
|
||||
| { name: JobName.IMAGE_TAGGING; data: IAssetJob }
|
||||
| { name: JobName.REVERSE_GEOCODING; data: IReverseGeocodingJob }
|
||||
|
||||
// Object Tagging
|
||||
| { name: JobName.QUEUE_OBJECT_TAGGING; data: IBaseJob }
|
||||
| { name: JobName.DETECT_OBJECTS; data: IAssetJob }
|
||||
| { name: JobName.CLASSIFY_IMAGE; data: IAssetJob }
|
||||
|
||||
// Clip Embedding
|
||||
| { name: JobName.QUEUE_ENCODE_CLIP; data: IBaseJob }
|
||||
| { name: JobName.ENCODE_CLIP; data: IAssetJob }
|
||||
|
||||
// Filesystem
|
||||
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
|
||||
|
||||
// Search
|
||||
| { name: JobName.SEARCH_INDEX_ASSETS }
|
||||
| { name: JobName.SEARCH_INDEX_ASSET; data: IBulkEntityJob }
|
||||
| { name: JobName.SEARCH_INDEX_ALBUMS }
|
||||
@@ -43,6 +68,7 @@ export const IJobRepository = 'IJobRepository';
|
||||
|
||||
export interface IJobRepository {
|
||||
queue(item: JobItem): Promise<void>;
|
||||
pause(name: QueueName): Promise<void>;
|
||||
empty(name: QueueName): Promise<void>;
|
||||
isActive(name: QueueName): Promise<boolean>;
|
||||
getJobCounts(name: QueueName): Promise<JobCounts>;
|
||||
|
||||
170
server/libs/domain/src/job/job.service.spec.ts
Normal file
170
server/libs/domain/src/job/job.service.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { newJobRepositoryMock } from '../../test';
|
||||
import { IJobRepository, JobCommand, JobName, JobService, QueueName } from '../job';
|
||||
|
||||
describe(JobService.name, () => {
|
||||
let sut: JobService;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
jobMock = newJobRepositoryMock();
|
||||
sut = new JobService(jobMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getAllJobStatus', () => {
|
||||
it('should get all job statuses', async () => {
|
||||
jobMock.getJobCounts.mockResolvedValue({
|
||||
active: 1,
|
||||
completed: 1,
|
||||
failed: 1,
|
||||
delayed: 1,
|
||||
waiting: 1,
|
||||
});
|
||||
|
||||
await expect(sut.getAllJobsStatus()).resolves.toEqual({
|
||||
'background-task-queue': {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
},
|
||||
'clip-encoding-queue': {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
},
|
||||
'metadata-extraction-queue': {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
},
|
||||
'object-tagging-queue': {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
},
|
||||
'search-queue': {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
},
|
||||
'storage-template-migration-queue': {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
},
|
||||
'thumbnail-generation-queue': {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
},
|
||||
'video-conversion-queue': {
|
||||
active: 1,
|
||||
completed: 1,
|
||||
delayed: 1,
|
||||
failed: 1,
|
||||
waiting: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCommand', () => {
|
||||
it('should handle a pause command', async () => {
|
||||
await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.PAUSE, force: false });
|
||||
|
||||
expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
|
||||
});
|
||||
|
||||
it('should handle an empty command', async () => {
|
||||
await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.EMPTY, force: false });
|
||||
|
||||
expect(jobMock.empty).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
|
||||
});
|
||||
|
||||
it('should not start a job that is already running', async () => {
|
||||
jobMock.isActive.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle a start video conversion command', async () => {
|
||||
jobMock.isActive.mockResolvedValue(false);
|
||||
|
||||
await sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force: false } });
|
||||
});
|
||||
|
||||
it('should handle a start storage template migration command', async () => {
|
||||
jobMock.isActive.mockResolvedValue(false);
|
||||
|
||||
await sut.handleCommand(QueueName.STORAGE_TEMPLATE_MIGRATION, { command: JobCommand.START, force: false });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.STORAGE_TEMPLATE_MIGRATION });
|
||||
});
|
||||
|
||||
it('should handle a start object tagging command', async () => {
|
||||
jobMock.isActive.mockResolvedValue(false);
|
||||
|
||||
await sut.handleCommand(QueueName.OBJECT_TAGGING, { command: JobCommand.START, force: false });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_OBJECT_TAGGING, data: { force: false } });
|
||||
});
|
||||
|
||||
it('should handle a start clip encoding command', async () => {
|
||||
jobMock.isActive.mockResolvedValue(false);
|
||||
|
||||
await sut.handleCommand(QueueName.CLIP_ENCODING, { command: JobCommand.START, force: false });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_ENCODE_CLIP, data: { force: false } });
|
||||
});
|
||||
|
||||
it('should handle a start metadata extraction command', async () => {
|
||||
jobMock.isActive.mockResolvedValue(false);
|
||||
|
||||
await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.START, force: false });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force: false } });
|
||||
});
|
||||
|
||||
it('should handle a start thumbnail generation command', async () => {
|
||||
jobMock.isActive.mockResolvedValue(false);
|
||||
|
||||
await sut.handleCommand(QueueName.THUMBNAIL_GENERATION, { command: JobCommand.START, force: false });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } });
|
||||
});
|
||||
|
||||
it('should throw a bad request when an invalid queue is used', async () => {
|
||||
jobMock.isActive.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
sut.handleCommand(QueueName.BACKGROUND_TASK, { command: JobCommand.START, force: false }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
68
server/libs/domain/src/job/job.service.ts
Normal file
68
server/libs/domain/src/job/job.service.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { assertMachineLearningEnabled } from '@app/common';
|
||||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { JobCommandDto } from './dto';
|
||||
import { JobCommand, JobName, QueueName } from './job.constants';
|
||||
import { IJobRepository } from './job.repository';
|
||||
import { AllJobStatusResponseDto } from './response-dto';
|
||||
|
||||
@Injectable()
|
||||
export class JobService {
|
||||
private logger = new Logger(JobService.name);
|
||||
|
||||
constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {}
|
||||
|
||||
handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> {
|
||||
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
|
||||
|
||||
switch (dto.command) {
|
||||
case JobCommand.START:
|
||||
return this.start(queueName, dto);
|
||||
|
||||
case JobCommand.PAUSE:
|
||||
return this.jobRepository.pause(queueName);
|
||||
|
||||
case JobCommand.EMPTY:
|
||||
return this.jobRepository.empty(queueName);
|
||||
}
|
||||
}
|
||||
|
||||
async getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
|
||||
const response = new AllJobStatusResponseDto();
|
||||
for (const queueName of Object.values(QueueName)) {
|
||||
response[queueName] = await this.jobRepository.getJobCounts(queueName);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private async start(name: QueueName, { force }: JobCommandDto): Promise<void> {
|
||||
const isActive = await this.jobRepository.isActive(name);
|
||||
if (isActive) {
|
||||
throw new BadRequestException(`Job is already running`);
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case QueueName.VIDEO_CONVERSION:
|
||||
return this.jobRepository.queue({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force } });
|
||||
|
||||
case QueueName.STORAGE_TEMPLATE_MIGRATION:
|
||||
return this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION });
|
||||
|
||||
case QueueName.OBJECT_TAGGING:
|
||||
assertMachineLearningEnabled();
|
||||
return this.jobRepository.queue({ name: JobName.QUEUE_OBJECT_TAGGING, data: { force } });
|
||||
|
||||
case QueueName.CLIP_ENCODING:
|
||||
assertMachineLearningEnabled();
|
||||
return this.jobRepository.queue({ name: JobName.QUEUE_ENCODE_CLIP, data: { force } });
|
||||
|
||||
case QueueName.METADATA_EXTRACTION:
|
||||
return this.jobRepository.queue({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force } });
|
||||
|
||||
case QueueName.THUMBNAIL_GENERATION:
|
||||
return this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force } });
|
||||
|
||||
default:
|
||||
throw new BadRequestException(`Invalid job name: ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { QueueName } from '../job.constants';
|
||||
|
||||
export class JobCountsDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
active!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
completed!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
failed!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
delayed!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
waiting!: number;
|
||||
}
|
||||
|
||||
export class AllJobStatusResponseDto implements Record<QueueName, JobCountsDto> {
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
[QueueName.THUMBNAIL_GENERATION]!: JobCountsDto;
|
||||
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
[QueueName.METADATA_EXTRACTION]!: JobCountsDto;
|
||||
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
[QueueName.VIDEO_CONVERSION]!: JobCountsDto;
|
||||
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
[QueueName.OBJECT_TAGGING]!: JobCountsDto;
|
||||
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
[QueueName.CLIP_ENCODING]!: JobCountsDto;
|
||||
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
[QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobCountsDto;
|
||||
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
[QueueName.BACKGROUND_TASK]!: JobCountsDto;
|
||||
|
||||
@ApiProperty({ type: JobCountsDto })
|
||||
[QueueName.SEARCH]!: JobCountsDto;
|
||||
}
|
||||
1
server/libs/domain/src/job/response-dto/index.ts
Normal file
1
server/libs/domain/src/job/response-dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './all-job-status-response.dto';
|
||||
Reference in New Issue
Block a user