1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02: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:
Jason Rasmussen 2023-03-20 11:55:28 -04:00 committed by GitHub
parent db6b14361d
commit 386eef046d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
68 changed files with 1094 additions and 691 deletions

View File

@ -51,8 +51,8 @@ doc/GetAssetCountByTimeBucketDto.md
doc/JobApi.md
doc/JobCommand.md
doc/JobCommandDto.md
doc/JobCounts.md
doc/JobId.md
doc/JobCountsDto.md
doc/JobName.md
doc/LoginCredentialDto.md
doc/LoginResponseDto.md
doc/LogoutResponseDto.md
@ -168,8 +168,8 @@ lib/model/get_asset_by_time_bucket_dto.dart
lib/model/get_asset_count_by_time_bucket_dto.dart
lib/model/job_command.dart
lib/model/job_command_dto.dart
lib/model/job_counts.dart
lib/model/job_id.dart
lib/model/job_counts_dto.dart
lib/model/job_name.dart
lib/model/login_credential_dto.dart
lib/model/login_response_dto.dart
lib/model/logout_response_dto.dart
@ -262,8 +262,8 @@ test/get_asset_count_by_time_bucket_dto_test.dart
test/job_api_test.dart
test/job_command_dto_test.dart
test/job_command_test.dart
test/job_counts_test.dart
test/job_id_test.dart
test/job_counts_dto_test.dart
test/job_name_test.dart
test/login_credential_dto_test.dart
test/login_response_dto_test.dart
test/logout_response_dto_test.dart

BIN
mobile/openapi/README.md generated

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.

Binary file not shown.

BIN
mobile/openapi/lib/model/job_name.dart generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -38,10 +38,6 @@ export interface IAssetRepository {
getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
getAssetWithNoThumbnail(): Promise<AssetEntity[]>;
getAssetWithNoEncodedVideo(): Promise<AssetEntity[]>;
getAssetWithNoEXIF(): Promise<AssetEntity[]>;
getAssetWithNoSmartInfo(): Promise<AssetEntity[]>;
getExistingAssets(
userId: string,
checkDuplicateAssetDto: CheckExistingAssetsDto,
@ -76,45 +72,6 @@ export class AssetRepository implements IAssetRepository {
});
}
async getAssetWithNoSmartInfo(): Promise<AssetEntity[]> {
return await this.assetRepository
.createQueryBuilder('asset')
.leftJoinAndSelect('asset.smartInfo', 'si')
.where('asset.resizePath IS NOT NULL')
.andWhere('si.assetId IS NULL')
.andWhere('asset.isVisible = true')
.getMany();
}
async getAssetWithNoThumbnail(): Promise<AssetEntity[]> {
return await this.assetRepository.find({
where: [
{ resizePath: IsNull(), isVisible: true },
{ resizePath: '', isVisible: true },
{ webpPath: IsNull(), isVisible: true },
{ webpPath: '', isVisible: true },
],
});
}
async getAssetWithNoEncodedVideo(): Promise<AssetEntity[]> {
return await this.assetRepository.find({
where: [
{ type: AssetType.VIDEO, encodedVideoPath: IsNull() },
{ type: AssetType.VIDEO, encodedVideoPath: '' },
],
});
}
async getAssetWithNoEXIF(): Promise<AssetEntity[]> {
return await this.assetRepository
.createQueryBuilder('asset')
.leftJoinAndSelect('asset.exifInfo', 'ei')
.where('ei."assetId" IS NULL')
.andWhere('asset.isVisible = true')
.getMany();
}
async getAssetCountByUserId(ownerId: string): Promise<AssetCountByUserIdResponseDto> {
// Get asset count by AssetType
const items = await this.assetRepository

View File

@ -146,10 +146,6 @@ describe('AssetService', () => {
getAssetByTimeBucket: jest.fn(),
getAssetByChecksum: jest.fn(),
getAssetCountByUserId: jest.fn(),
getAssetWithNoEXIF: jest.fn(),
getAssetWithNoThumbnail: jest.fn(),
getAssetWithNoSmartInfo: jest.fn(),
getAssetWithNoEncodedVideo: jest.fn(),
getExistingAssets: jest.fn(),
countByIdAndUser: jest.fn(),
};

View File

@ -63,7 +63,7 @@ import { AssetSearchDto } from './dto/asset-search.dto';
import { AddAssetsDto } from '../album/dto/add-assets.dto';
import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
import path from 'path';
import { getFileNameWithoutExtension } from '../../utils/file-name.util';
import { getFileNameWithoutExtension } from '@app/domain';
const fileInfo = promisify(stat);

View File

@ -1,23 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty } from 'class-validator';
export enum JobId {
THUMBNAIL_GENERATION = 'thumbnail-generation',
METADATA_EXTRACTION = 'metadata-extraction',
VIDEO_CONVERSION = 'video-conversion',
MACHINE_LEARNING = 'machine-learning',
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration',
}
export class GetJobDto {
@IsNotEmpty()
@IsEnum(JobId, {
message: `params must be one of ${Object.values(JobId).join()}`,
})
@ApiProperty({
type: String,
enum: JobId,
enumName: 'JobId',
})
jobId!: JobId;
}

View File

@ -1,16 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsIn, IsNotEmpty, IsOptional } from 'class-validator';
export class JobCommandDto {
@IsNotEmpty()
@IsIn(['start', 'stop'])
@ApiProperty({
enum: ['start', 'stop'],
enumName: 'JobCommand',
})
command!: string;
@IsOptional()
@IsBoolean()
includeAllAssets!: boolean;
}

View File

@ -1,33 +0,0 @@
import { Body, Controller, Get, Param, Put, ValidationPipe } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { AllJobStatusResponseDto } from './response-dto/all-job-status-response.dto';
import { GetJobDto } from './dto/get-job.dto';
import { JobService } from './job.service';
import { JobCommandDto } from './dto/job-command.dto';
@Authenticated({ admin: true })
@ApiTags('Job')
@Controller('jobs')
export class JobController {
constructor(private readonly jobService: JobService) {}
@Get()
getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
return this.jobService.getAllJobsStatus();
}
@Put('/:jobId')
async sendJobCommand(
@Param(ValidationPipe) params: GetJobDto,
@Body(ValidationPipe) dto: JobCommandDto,
): Promise<number> {
if (dto.command === 'start') {
return await this.jobService.start(params.jobId, dto.includeAllAssets);
}
if (dto.command === 'stop') {
return await this.jobService.stop(params.jobId);
}
return 0;
}
}

View File

@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { JobService } from './job.service';
import { JobController } from './job.controller';
import { AssetModule } from '../asset/asset.module';
@Module({
imports: [AssetModule],
controllers: [JobController],
providers: [JobService],
})
export class JobModule {}

View File

@ -1,142 +0,0 @@
import { JobName, IJobRepository, QueueName } from '@app/domain';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AllJobStatusResponseDto } from './response-dto/all-job-status-response.dto';
import { IAssetRepository } from '../asset/asset-repository';
import { AssetType } from '@app/infra';
import { JobId } from './dto/get-job.dto';
import { MACHINE_LEARNING_ENABLED } from '@app/common';
import { getFileNameWithoutExtension } from '../../utils/file-name.util';
const jobIds = Object.values(JobId) as JobId[];
@Injectable()
export class JobService {
constructor(
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {
for (const jobId of jobIds) {
this.jobRepository.empty(this.asQueueName(jobId));
}
}
start(jobId: JobId, includeAllAssets: boolean): Promise<number> {
return this.run(this.asQueueName(jobId), includeAllAssets);
}
async stop(jobId: JobId): Promise<number> {
await this.jobRepository.empty(this.asQueueName(jobId));
return 0;
}
async getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
const response = new AllJobStatusResponseDto();
for (const jobId of jobIds) {
response[jobId] = await this.jobRepository.getJobCounts(this.asQueueName(jobId));
}
return response;
}
private async run(name: QueueName, includeAllAssets: boolean): Promise<number> {
const isActive = await this.jobRepository.isActive(name);
if (isActive) {
throw new BadRequestException(`Job is already running`);
}
switch (name) {
case QueueName.VIDEO_CONVERSION: {
const assets = includeAllAssets
? await this._assetRepository.getAllVideos()
: await this._assetRepository.getAssetWithNoEncodedVideo();
for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { asset } });
}
return assets.length;
}
case QueueName.STORAGE_TEMPLATE_MIGRATION:
await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION });
return 1;
case QueueName.MACHINE_LEARNING: {
if (!MACHINE_LEARNING_ENABLED) {
throw new BadRequestException('Machine learning is not enabled.');
}
const assets = includeAllAssets
? await this._assetRepository.getAll()
: await this._assetRepository.getAssetWithNoSmartInfo();
for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.IMAGE_TAGGING, data: { asset } });
await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, data: { asset } });
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
}
return assets.length;
}
case QueueName.METADATA_EXTRACTION: {
const assets = includeAllAssets
? await this._assetRepository.getAll()
: await this._assetRepository.getAssetWithNoEXIF();
for (const asset of assets) {
if (asset.type === AssetType.VIDEO) {
await this.jobRepository.queue({
name: JobName.EXTRACT_VIDEO_METADATA,
data: {
asset,
fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath),
},
});
} else {
await this.jobRepository.queue({
name: JobName.EXIF_EXTRACTION,
data: {
asset,
fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath),
},
});
}
}
return assets.length;
}
case QueueName.THUMBNAIL_GENERATION: {
const assets = includeAllAssets
? await this._assetRepository.getAll()
: await this._assetRepository.getAssetWithNoThumbnail();
for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } });
}
return assets.length;
}
default:
return 0;
}
}
private asQueueName(jobId: JobId) {
switch (jobId) {
case JobId.THUMBNAIL_GENERATION:
return QueueName.THUMBNAIL_GENERATION;
case JobId.METADATA_EXTRACTION:
return QueueName.METADATA_EXTRACTION;
case JobId.VIDEO_CONVERSION:
return QueueName.VIDEO_CONVERSION;
case JobId.STORAGE_TEMPLATE_MIGRATION:
return QueueName.STORAGE_TEMPLATE_MIGRATION;
case JobId.MACHINE_LEARNING:
return QueueName.MACHINE_LEARNING;
default:
throw new BadRequestException(`Invalid job id: ${jobId}`);
}
}
}

View File

@ -1,32 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { JobId } from '../dto/get-job.dto';
export class JobCounts {
@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 {
@ApiProperty({ type: JobCounts })
[JobId.THUMBNAIL_GENERATION]!: JobCounts;
@ApiProperty({ type: JobCounts })
[JobId.METADATA_EXTRACTION]!: JobCounts;
@ApiProperty({ type: JobCounts })
[JobId.VIDEO_CONVERSION]!: JobCounts;
@ApiProperty({ type: JobCounts })
[JobId.MACHINE_LEARNING]!: JobCounts;
@ApiProperty({ type: JobCounts })
[JobId.STORAGE_TEMPLATE_MIGRATION]!: JobCounts;
}

View File

@ -7,7 +7,6 @@ import { AlbumModule } from './api-v1/album/album.module';
import { AppController } from './app.controller';
import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
import { JobModule } from './api-v1/job/job.module';
import { TagModule } from './api-v1/tag/tag.module';
import { DomainModule, SearchService } from '@app/domain';
import { InfraModule } from '@app/infra';
@ -15,6 +14,7 @@ import {
APIKeyController,
AuthController,
DeviceInfoController,
JobController,
OAuthController,
SearchController,
ShareController,
@ -42,8 +42,6 @@ import { AuthGuard } from './middlewares/auth.guard';
ScheduleTasksModule,
JobModule,
TagModule,
],
controllers: [
@ -51,6 +49,7 @@ import { AuthGuard } from './middlewares/auth.guard';
APIKeyController,
AuthController,
DeviceInfoController,
JobController,
OAuthController,
SearchController,
ShareController,

View File

@ -1,6 +1,7 @@
export * from './api-key.controller';
export * from './auth.controller';
export * from './device-info.controller';
export * from './job.controller';
export * from './oauth.controller';
export * from './search.controller';
export * from './share.controller';

View File

@ -0,0 +1,21 @@
import { AllJobStatusResponseDto, JobCommandDto, JobIdDto, JobService } from '@app/domain';
import { Body, Controller, Get, Param, Put, ValidationPipe } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../decorators/authenticated.decorator';
@Authenticated({ admin: true })
@ApiTags('Job')
@Controller('jobs')
export class JobController {
constructor(private readonly jobService: JobService) {}
@Get()
getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
return this.jobService.getAllJobsStatus();
}
@Put('/:jobId')
sendJobCommand(@Param(ValidationPipe) { jobId }: JobIdDto, @Body(ValidationPipe) dto: JobCommandDto): Promise<void> {
return this.jobService.handleCommand(jobId, dto);
}
}

View File

@ -6,7 +6,8 @@ import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import {
BackgroundTaskProcessor,
MachineLearningProcessor,
ClipEncodingProcessor,
ObjectTaggingProcessor,
SearchIndexProcessor,
StorageTemplateMigrationProcessor,
ThumbnailGeneratorProcessor,
@ -24,7 +25,8 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
ThumbnailGeneratorProcessor,
MetadataExtractionProcessor,
VideoTranscodeProcessor,
MachineLearningProcessor,
ObjectTaggingProcessor,
ClipEncodingProcessor,
StorageTemplateMigrationProcessor,
BackgroundTaskProcessor,
SearchIndexProcessor,

View File

@ -2,6 +2,7 @@ import {
AssetService,
IAssetJob,
IAssetUploadedJob,
IBaseJob,
IBulkEntityJob,
IDeleteFilesJob,
IUserDeletionJob,
@ -48,20 +49,35 @@ export class BackgroundTaskProcessor {
}
}
@Processor(QueueName.MACHINE_LEARNING)
export class MachineLearningProcessor {
@Processor(QueueName.OBJECT_TAGGING)
export class ObjectTaggingProcessor {
constructor(private smartInfoService: SmartInfoService) {}
@Process({ name: JobName.IMAGE_TAGGING, concurrency: 1 })
async onTagImage(job: Job<IAssetJob>) {
await this.smartInfoService.handleTagImage(job.data);
@Process({ name: JobName.QUEUE_OBJECT_TAGGING, concurrency: 1 })
async onQueueObjectTagging(job: Job<IBaseJob>) {
await this.smartInfoService.handleQueueObjectTagging(job.data);
}
@Process({ name: JobName.OBJECT_DETECTION, concurrency: 1 })
async onDetectObject(job: Job<IAssetJob>) {
@Process({ name: JobName.DETECT_OBJECTS, concurrency: 1 })
async onDetectObjects(job: Job<IAssetJob>) {
await this.smartInfoService.handleDetectObjects(job.data);
}
@Process({ name: JobName.CLASSIFY_IMAGE, concurrency: 1 })
async onClassifyImage(job: Job<IAssetJob>) {
await this.smartInfoService.handleClassifyImage(job.data);
}
}
@Processor(QueueName.CLIP_ENCODING)
export class ClipEncodingProcessor {
constructor(private smartInfoService: SmartInfoService) {}
@Process({ name: JobName.QUEUE_ENCODE_CLIP, concurrency: 1 })
async onQueueClipEncoding(job: Job<IBaseJob>) {
await this.smartInfoService.handleQueueEncodeClip(job.data);
}
@Process({ name: JobName.ENCODE_CLIP, concurrency: 1 })
async onEncodeClip(job: Job<IAssetJob>) {
await this.smartInfoService.handleEncodeClip(job.data);
@ -117,6 +133,11 @@ export class StorageTemplateMigrationProcessor {
export class ThumbnailGeneratorProcessor {
constructor(private mediaService: MediaService) {}
@Process({ name: JobName.QUEUE_GENERATE_THUMBNAILS, concurrency: 1 })
async handleQueueGenerateThumbnails(job: Job<IBaseJob>) {
await this.mediaService.handleQueueGenerateThumbnails(job.data);
}
@Process({ name: JobName.GENERATE_JPEG_THUMBNAIL, concurrency: 3 })
async handleGenerateJpegThumbnail(job: Job<IAssetJob>) {
await this.mediaService.handleGenerateJpegThumbnail(job.data);

View File

@ -1,11 +1,14 @@
import {
AssetCore,
getFileNameWithoutExtension,
IAssetRepository,
IAssetUploadedJob,
IBaseJob,
IJobRepository,
IReverseGeocodingJob,
JobName,
QueueName,
WithoutProperty,
} from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra';
import { Process, Processor } from '@nestjs/bull';
@ -85,8 +88,8 @@ export class MetadataExtractionProcessor {
private assetCore: AssetCore;
constructor(
@Inject(IAssetRepository) assetRepository: IAssetRepository,
@Inject(IJobRepository) jobRepository: IJobRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>,
@ -148,6 +151,24 @@ export class MetadataExtractionProcessor {
return { country, state, city };
}
@Process(JobName.QUEUE_METADATA_EXTRACTION)
async handleQueueMetadataExtraction(job: Job<IBaseJob>) {
try {
const { force } = job.data;
const assets = force
? await this.assetRepository.getAll()
: await this.assetRepository.getWithout(WithoutProperty.EXIF);
for (const asset of assets) {
const fileName = asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath);
const name = asset.type === AssetType.VIDEO ? JobName.EXTRACT_VIDEO_METADATA : JobName.EXIF_EXTRACTION;
await this.jobRepository.queue({ name, data: { asset, fileName } });
}
} catch (error: any) {
this.logger.error(`Unable to queue metadata extraction`, error?.stack);
}
}
@Process(JobName.EXIF_EXTRACTION)
async extractExifInfo(job: Job<IAssetUploadedJob>) {
try {

View File

@ -1,6 +1,15 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { AssetEntity } from '@app/infra';
import { IAssetJob, IAssetRepository, JobName, QueueName, SystemConfigService } from '@app/domain';
import { AssetEntity, AssetType } from '@app/infra';
import {
IAssetJob,
IAssetRepository,
IBaseJob,
IJobRepository,
JobName,
QueueName,
SystemConfigService,
WithoutProperty,
} from '@app/domain';
import { Process, Processor } from '@nestjs/bull';
import { Inject, Logger } from '@nestjs/common';
import { Job } from 'bull';
@ -12,11 +21,27 @@ export class VideoTranscodeProcessor {
readonly logger = new Logger(VideoTranscodeProcessor.name);
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
private systemConfigService: SystemConfigService,
) {}
@Process({ name: JobName.QUEUE_VIDEO_CONVERSION, concurrency: 1 })
async handleQueueVideoConversion(job: Job<IBaseJob>): Promise<void> {
try {
const { force } = job.data;
const assets = force
? await this.assetRepository.getAll({ type: AssetType.VIDEO })
: await this.assetRepository.getWithout(WithoutProperty.ENCODED_VIDEO);
for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { asset } });
}
} catch (error: any) {
this.logger.error('Failed to queue video conversions', error.stack);
}
}
@Process({ name: JobName.VIDEO_CONVERSION, concurrency: 2 })
async videoConversion(job: Job<IAssetJob>) {
async handleVideoConversion(job: Job<IAssetJob>) {
const { asset } = job.data;
const basePath = APP_UPLOAD_LOCATION;
const encodedVideoPath = `${basePath}/${asset.ownerId}/encoded-video`;

View File

@ -395,6 +395,78 @@
]
}
},
"/jobs": {
"get": {
"operationId": "getAllJobsStatus",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AllJobStatusResponseDto"
}
}
}
}
},
"tags": [
"Job"
],
"security": [
{
"bearer": []
},
{
"cookie": []
}
]
}
},
"/jobs/{jobId}": {
"put": {
"operationId": "sendJobCommand",
"description": "",
"parameters": [
{
"name": "jobId",
"required": true,
"in": "path",
"schema": {
"$ref": "#/components/schemas/JobName"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/JobCommandDto"
}
}
}
},
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Job"
],
"security": [
{
"bearer": []
},
{
"cookie": []
}
]
}
},
"/oauth/mobile-redirect": {
"get": {
"operationId": "mobileRedirect",
@ -3169,85 +3241,6 @@
}
]
}
},
"/jobs": {
"get": {
"operationId": "getAllJobsStatus",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AllJobStatusResponseDto"
}
}
}
}
},
"tags": [
"Job"
],
"security": [
{
"bearer": []
},
{
"cookie": []
}
]
}
},
"/jobs/{jobId}": {
"put": {
"operationId": "sendJobCommand",
"description": "",
"parameters": [
{
"name": "jobId",
"required": true,
"in": "path",
"schema": {
"$ref": "#/components/schemas/JobId"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/JobCommandDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "number"
}
}
}
}
},
"tags": [
"Job"
],
"security": [
{
"bearer": []
},
{
"cookie": []
}
]
}
}
},
"info": {
@ -3604,6 +3597,108 @@
"isAutoBackup"
]
},
"JobCountsDto": {
"type": "object",
"properties": {
"active": {
"type": "integer"
},
"completed": {
"type": "integer"
},
"failed": {
"type": "integer"
},
"delayed": {
"type": "integer"
},
"waiting": {
"type": "integer"
}
},
"required": [
"active",
"completed",
"failed",
"delayed",
"waiting"
]
},
"AllJobStatusResponseDto": {
"type": "object",
"properties": {
"thumbnail-generation-queue": {
"$ref": "#/components/schemas/JobCountsDto"
},
"metadata-extraction-queue": {
"$ref": "#/components/schemas/JobCountsDto"
},
"video-conversion-queue": {
"$ref": "#/components/schemas/JobCountsDto"
},
"object-tagging-queue": {
"$ref": "#/components/schemas/JobCountsDto"
},
"clip-encoding-queue": {
"$ref": "#/components/schemas/JobCountsDto"
},
"storage-template-migration-queue": {
"$ref": "#/components/schemas/JobCountsDto"
},
"background-task-queue": {
"$ref": "#/components/schemas/JobCountsDto"
},
"search-queue": {
"$ref": "#/components/schemas/JobCountsDto"
}
},
"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"
]
},
"JobName": {
"type": "string",
"enum": [
"thumbnail-generation-queue",
"metadata-extraction-queue",
"video-conversion-queue",
"object-tagging-queue",
"clip-encoding-queue",
"background-task-queue",
"storage-template-migration-queue",
"search-queue"
]
},
"JobCommand": {
"type": "string",
"enum": [
"start",
"pause",
"empty"
]
},
"JobCommandDto": {
"type": "object",
"properties": {
"command": {
"$ref": "#/components/schemas/JobCommand"
},
"force": {
"type": "boolean"
}
},
"required": [
"command",
"force"
]
},
"OAuthConfigDto": {
"type": "object",
"properties": {
@ -5193,92 +5288,6 @@
"usage",
"usageByUser"
]
},
"JobCounts": {
"type": "object",
"properties": {
"active": {
"type": "integer"
},
"completed": {
"type": "integer"
},
"failed": {
"type": "integer"
},
"delayed": {
"type": "integer"
},
"waiting": {
"type": "integer"
}
},
"required": [
"active",
"completed",
"failed",
"delayed",
"waiting"
]
},
"AllJobStatusResponseDto": {
"type": "object",
"properties": {
"thumbnail-generation": {
"$ref": "#/components/schemas/JobCounts"
},
"metadata-extraction": {
"$ref": "#/components/schemas/JobCounts"
},
"video-conversion": {
"$ref": "#/components/schemas/JobCounts"
},
"machine-learning": {
"$ref": "#/components/schemas/JobCounts"
},
"storage-template-migration": {
"$ref": "#/components/schemas/JobCounts"
}
},
"required": [
"thumbnail-generation",
"metadata-extraction",
"video-conversion",
"machine-learning",
"storage-template-migration"
]
},
"JobId": {
"type": "string",
"enum": [
"thumbnail-generation",
"metadata-extraction",
"video-conversion",
"machine-learning",
"storage-template-migration"
]
},
"JobCommand": {
"type": "string",
"enum": [
"start",
"stop"
]
},
"JobCommandDto": {
"type": "object",
"properties": {
"command": {
"$ref": "#/components/schemas/JobCommand"
},
"includeAllAssets": {
"type": "boolean"
}
},
"required": [
"command",
"includeAllAssets"
]
}
}
}

View File

@ -1,4 +1,12 @@
import { BadRequestException } from '@nestjs/common';
export * from './upload_location.constant';
export const MACHINE_LEARNING_URL = process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003';
export const MACHINE_LEARNING_ENABLED = MACHINE_LEARNING_URL !== 'false';
export function assertMachineLearningEnabled() {
if (!MACHINE_LEARNING_ENABLED) {
throw new BadRequestException('Machine learning is not enabled.');
}
}

View File

@ -2,12 +2,22 @@ import { AssetEntity, AssetType } from '@app/infra/db/entities';
export interface AssetSearchOptions {
isVisible?: boolean;
type?: AssetType;
}
export enum WithoutProperty {
THUMBNAIL = 'thumbnail',
ENCODED_VIDEO = 'encoded-video',
EXIF = 'exif',
CLIP_ENCODING = 'clip-embedding',
OBJECT_TAGS = 'object-tags',
}
export const IAssetRepository = 'IAssetRepository';
export interface IAssetRepository {
getByIds(ids: string[]): Promise<AssetEntity[]>;
getWithout(property: WithoutProperty): Promise<AssetEntity[]>;
deleteAll(ownerId: string): Promise<void>;
getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>;
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;

View File

@ -3,6 +3,7 @@ import { APIKeyService } from './api-key';
import { AssetService } from './asset';
import { AuthService } from './auth';
import { DeviceInfoService } from './device-info';
import { JobService } from './job';
import { MediaService } from './media';
import { OAuthService } from './oauth';
import { SearchService } from './search';
@ -18,6 +19,7 @@ const providers: Provider[] = [
APIKeyService,
AuthService,
DeviceInfoService,
JobService,
MediaService,
OAuthService,
SmartInfoService,

View File

@ -18,3 +18,4 @@ export * from './system-config';
export * from './tag';
export * from './user';
export * from './user-token';
export * from './util';

View File

@ -0,0 +1,2 @@
export * from './job-command.dto';
export * from './job-id.dto';

View 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;
}

View 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;
}

View File

@ -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';

View File

@ -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',
}

View File

@ -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;

View File

@ -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>;

View 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();
});
});
});

View 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}`);
}
}
}

View File

@ -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;
}

View File

@ -0,0 +1 @@
export * from './all-job-status-response.dto';

View File

@ -3,9 +3,9 @@ import { AssetType } from '@app/infra/db/entities';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { join } from 'path';
import sanitize from 'sanitize-filename';
import { IAssetRepository, mapAsset } from '../asset';
import { IAssetRepository, mapAsset, WithoutProperty } from '../asset';
import { CommunicationEvent, ICommunicationRepository } from '../communication';
import { IAssetJob, IJobRepository, JobName } from '../job';
import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job';
import { IStorageRepository } from '../storage';
import { IMediaRepository } from './media.repository';
@ -21,6 +21,22 @@ export class MediaService {
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {}
async handleQueueGenerateThumbnails(job: IBaseJob): Promise<void> {
try {
const { force } = job;
const assets = force
? await this.assetRepository.getAll()
: await this.assetRepository.getWithout(WithoutProperty.THUMBNAIL);
for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } });
}
} catch (error: any) {
this.logger.error('Failed to queue generate thumbnail jobs', error.stack);
}
}
async handleGenerateJpegThumbnail(data: IAssetJob): Promise<void> {
const { asset } = data;
@ -52,8 +68,8 @@ export class MediaService {
asset.resizePath = jpegThumbnailPath;
await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } });
await this.jobRepository.queue({ name: JobName.IMAGE_TAGGING, data: { asset } });
await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, data: { asset } });
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { asset } });
await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } });
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
@ -71,8 +87,8 @@ export class MediaService {
asset.resizePath = jpegThumbnailPath;
await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } });
await this.jobRepository.queue({ name: JobName.IMAGE_TAGGING, data: { asset } });
await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, data: { asset } });
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { asset } });
await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } });
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));

View File

@ -5,7 +5,7 @@ export interface MachineLearningInput {
}
export interface IMachineLearningRepository {
tagImage(input: MachineLearningInput): Promise<string[]>;
classifyImage(input: MachineLearningInput): Promise<string[]>;
detectObjects(input: MachineLearningInput): Promise<string[]>;
encodeImage(input: MachineLearningInput): Promise<number[]>;
encodeText(input: string): Promise<number[]>;

View File

@ -1,6 +1,13 @@
import { AssetEntity } from '@app/infra/db/entities';
import { newJobRepositoryMock, newMachineLearningRepositoryMock, newSmartInfoRepositoryMock } from '../../test';
import { IJobRepository } from '../job';
import {
assetEntityStub,
newAssetRepositoryMock,
newJobRepositoryMock,
newMachineLearningRepositoryMock,
newSmartInfoRepositoryMock,
} from '../../test';
import { IAssetRepository, WithoutProperty } from '../asset';
import { IJobRepository, JobName } from '../job';
import { IMachineLearningRepository } from './machine-learning.interface';
import { ISmartInfoRepository } from './smart-info.repository';
import { SmartInfoService } from './smart-info.service';
@ -12,35 +19,63 @@ const asset = {
describe(SmartInfoService.name, () => {
let sut: SmartInfoService;
let assetMock: jest.Mocked<IAssetRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let smartMock: jest.Mocked<ISmartInfoRepository>;
let machineMock: jest.Mocked<IMachineLearningRepository>;
beforeEach(async () => {
assetMock = newAssetRepositoryMock();
smartMock = newSmartInfoRepositoryMock();
jobMock = newJobRepositoryMock();
machineMock = newMachineLearningRepositoryMock();
sut = new SmartInfoService(jobMock, smartMock, machineMock);
sut = new SmartInfoService(assetMock, jobMock, smartMock, machineMock);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('handleQueueObjectTagging', () => {
it('should queue the assets without tags', async () => {
assetMock.getWithout.mockResolvedValue([assetEntityStub.image]);
await sut.handleQueueObjectTagging({ force: false });
expect(jobMock.queue.mock.calls).toEqual([
[{ name: JobName.CLASSIFY_IMAGE, data: { asset: assetEntityStub.image } }],
[{ name: JobName.DETECT_OBJECTS, data: { asset: assetEntityStub.image } }],
]);
expect(assetMock.getWithout).toHaveBeenCalledWith(WithoutProperty.OBJECT_TAGS);
});
it('should queue all the assets', async () => {
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
await sut.handleQueueObjectTagging({ force: true });
expect(jobMock.queue.mock.calls).toEqual([
[{ name: JobName.CLASSIFY_IMAGE, data: { asset: assetEntityStub.image } }],
[{ name: JobName.DETECT_OBJECTS, data: { asset: assetEntityStub.image } }],
]);
expect(assetMock.getAll).toHaveBeenCalled();
});
});
describe('handleTagImage', () => {
it('should skip assets without a resize path', async () => {
await sut.handleTagImage({ asset: { resizePath: '' } as AssetEntity });
await sut.handleClassifyImage({ asset: { resizePath: '' } as AssetEntity });
expect(smartMock.upsert).not.toHaveBeenCalled();
expect(machineMock.tagImage).not.toHaveBeenCalled();
expect(machineMock.classifyImage).not.toHaveBeenCalled();
});
it('should save the returned tags', async () => {
machineMock.tagImage.mockResolvedValue(['tag1', 'tag2', 'tag3']);
machineMock.classifyImage.mockResolvedValue(['tag1', 'tag2', 'tag3']);
await sut.handleTagImage({ asset });
await sut.handleClassifyImage({ asset });
expect(machineMock.tagImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' });
expect(machineMock.classifyImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' });
expect(smartMock.upsert).toHaveBeenCalledWith({
assetId: 'asset-1',
tags: ['tag1', 'tag2', 'tag3'],
@ -48,19 +83,19 @@ describe(SmartInfoService.name, () => {
});
it('should handle an error with the machine learning pipeline', async () => {
machineMock.tagImage.mockRejectedValue(new Error('Unable to read thumbnail'));
machineMock.classifyImage.mockRejectedValue(new Error('Unable to read thumbnail'));
await sut.handleTagImage({ asset });
await sut.handleClassifyImage({ asset });
expect(smartMock.upsert).not.toHaveBeenCalled();
});
it('should no update the smart info if no tags were returned', async () => {
machineMock.tagImage.mockResolvedValue([]);
machineMock.classifyImage.mockResolvedValue([]);
await sut.handleTagImage({ asset });
await sut.handleClassifyImage({ asset });
expect(machineMock.tagImage).toHaveBeenCalled();
expect(machineMock.classifyImage).toHaveBeenCalled();
expect(smartMock.upsert).not.toHaveBeenCalled();
});
});
@ -102,4 +137,53 @@ describe(SmartInfoService.name, () => {
expect(smartMock.upsert).not.toHaveBeenCalled();
});
});
describe('handleQueueEncodeClip', () => {
it('should queue the assets without clip embeddings', async () => {
assetMock.getWithout.mockResolvedValue([assetEntityStub.image]);
await sut.handleQueueEncodeClip({ force: false });
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { asset: assetEntityStub.image } });
expect(assetMock.getWithout).toHaveBeenCalledWith(WithoutProperty.CLIP_ENCODING);
});
it('should queue all the assets', async () => {
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
await sut.handleQueueEncodeClip({ force: true });
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { asset: assetEntityStub.image } });
expect(assetMock.getAll).toHaveBeenCalled();
});
});
describe('handleEncodeClip', () => {
it('should skip assets without a resize path', async () => {
await sut.handleEncodeClip({ asset: { resizePath: '' } as AssetEntity });
expect(smartMock.upsert).not.toHaveBeenCalled();
expect(machineMock.encodeImage).not.toHaveBeenCalled();
});
it('should save the returned objects', async () => {
machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
await sut.handleEncodeClip({ asset });
expect(machineMock.encodeImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' });
expect(smartMock.upsert).toHaveBeenCalledWith({
assetId: 'asset-1',
clipEmbedding: [0.01, 0.02, 0.03],
});
});
it('should handle an error with the machine learning pipeline', async () => {
machineMock.encodeImage.mockRejectedValue(new Error('Unable to read thumbnail'));
await sut.handleEncodeClip({ asset });
expect(smartMock.upsert).not.toHaveBeenCalled();
});
});
});

View File

@ -1,6 +1,7 @@
import { MACHINE_LEARNING_ENABLED } from '@app/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { IAssetJob, IJobRepository, JobName } from '../job';
import { IAssetRepository, WithoutProperty } from '../asset';
import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job';
import { IMachineLearningRepository } from './machine-learning.interface';
import { ISmartInfoRepository } from './smart-info.repository';
@ -9,26 +10,24 @@ export class SmartInfoService {
private logger = new Logger(SmartInfoService.name);
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISmartInfoRepository) private repository: ISmartInfoRepository,
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
) {}
async handleTagImage(data: IAssetJob) {
const { asset } = data;
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
return;
}
async handleQueueObjectTagging({ force }: IBaseJob) {
try {
const tags = await this.machineLearning.tagImage({ thumbnailPath: asset.resizePath });
if (tags.length > 0) {
await this.repository.upsert({ assetId: asset.id, tags });
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
const assets = force
? await this.assetRepository.getAll()
: await this.assetRepository.getWithout(WithoutProperty.OBJECT_TAGS);
for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { asset } });
await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } });
}
} catch (error: any) {
this.logger.error(`Unable to run image tagging pipeline: ${asset.id}`, error?.stack);
this.logger.error(`Unable to queue object tagging`, error?.stack);
}
}
@ -50,6 +49,38 @@ export class SmartInfoService {
}
}
async handleClassifyImage(data: IAssetJob) {
const { asset } = data;
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
return;
}
try {
const tags = await this.machineLearning.classifyImage({ thumbnailPath: asset.resizePath });
if (tags.length > 0) {
await this.repository.upsert({ assetId: asset.id, tags });
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
}
} catch (error: any) {
this.logger.error(`Unable to run image tagging pipeline: ${asset.id}`, error?.stack);
}
}
async handleQueueEncodeClip({ force }: IBaseJob) {
try {
const assets = force
? await this.assetRepository.getAll()
: await this.assetRepository.getWithout(WithoutProperty.CLIP_ENCODING);
for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
}
} catch (error: any) {
this.logger.error(`Unable to queue clip encoding`, error?.stack);
}
}
async handleEncodeClip(data: IAssetJob) {
const { asset } = data;

View File

@ -3,6 +3,7 @@ import { IAssetRepository } from '../src';
export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
return {
getByIds: jest.fn(),
getWithout: jest.fn(),
getAll: jest.fn(),
deleteAll: jest.fn(),
save: jest.fn(),

View File

@ -3,6 +3,7 @@ import { IJobRepository } from '../src';
export const newJobRepositoryMock = (): jest.Mocked<IJobRepository> => {
return {
empty: jest.fn(),
pause: jest.fn(),
queue: jest.fn().mockImplementation(() => Promise.resolve()),
isActive: jest.fn(),
getJobCounts: jest.fn(),

View File

@ -2,7 +2,7 @@ import { IMachineLearningRepository } from '../src';
export const newMachineLearningRepositoryMock = (): jest.Mocked<IMachineLearningRepository> => {
return {
tagImage: jest.fn(),
classifyImage: jest.fn(),
detectObjects: jest.fn(),
encodeImage: jest.fn(),
encodeText: jest.fn(),

View File

@ -1,7 +1,7 @@
import { AssetSearchOptions, IAssetRepository } from '@app/domain';
import { AssetSearchOptions, IAssetRepository, WithoutProperty } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Not, Repository } from 'typeorm';
import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm';
import { AssetEntity, AssetType } from '../entities';
@Injectable()
@ -65,4 +65,73 @@ export class AssetRepository implements IAssetRepository {
},
});
}
getWithout(property: WithoutProperty): Promise<AssetEntity[]> {
let relations: FindOptionsRelations<AssetEntity> = {};
let where: FindOptionsWhere<AssetEntity> | FindOptionsWhere<AssetEntity>[] = {};
switch (property) {
case WithoutProperty.THUMBNAIL:
where = [
{ resizePath: IsNull(), isVisible: true },
{ resizePath: '', isVisible: true },
{ webpPath: IsNull(), isVisible: true },
{ webpPath: '', isVisible: true },
];
break;
case WithoutProperty.ENCODED_VIDEO:
where = [
{ type: AssetType.VIDEO, encodedVideoPath: IsNull() },
{ type: AssetType.VIDEO, encodedVideoPath: '' },
];
break;
case WithoutProperty.EXIF:
relations = {
exifInfo: true,
};
where = {
isVisible: true,
resizePath: Not(IsNull()),
exifInfo: {
assetId: IsNull(),
},
};
break;
case WithoutProperty.CLIP_ENCODING:
relations = {
smartInfo: true,
};
where = {
isVisible: true,
smartInfo: {
clipEmbedding: IsNull(),
},
};
break;
case WithoutProperty.OBJECT_TAGS:
relations = {
smartInfo: true,
};
where = {
resizePath: IsNull(),
isVisible: true,
smartInfo: {
tags: IsNull(),
},
};
break;
default:
throw new Error(`Invalid getWithout property: ${property}`);
}
return this.repository.find({
relations,
where,
});
}
}

View File

@ -1,18 +1,38 @@
import { IAssetJob, IJobRepository, IMetadataExtractionJob, JobCounts, JobItem, JobName, QueueName } from '@app/domain';
import {
IAssetJob,
IBaseJob,
IJobRepository,
IMetadataExtractionJob,
JobCounts,
JobItem,
JobName,
QueueName,
} from '@app/domain';
import { InjectQueue } from '@nestjs/bull';
import { BadRequestException, Logger } from '@nestjs/common';
import { Logger } from '@nestjs/common';
import { Queue } from 'bull';
export class JobRepository implements IJobRepository {
private logger = new Logger(JobRepository.name);
private queueMap: Record<QueueName, Queue> = {
[QueueName.STORAGE_TEMPLATE_MIGRATION]: this.storageTemplateMigration,
[QueueName.THUMBNAIL_GENERATION]: this.generateThumbnail,
[QueueName.METADATA_EXTRACTION]: this.metadataExtraction,
[QueueName.OBJECT_TAGGING]: this.objectTagging,
[QueueName.CLIP_ENCODING]: this.clipEmbedding,
[QueueName.VIDEO_CONVERSION]: this.videoTranscode,
[QueueName.BACKGROUND_TASK]: this.backgroundTask,
[QueueName.SEARCH]: this.searchIndex,
};
constructor(
@InjectQueue(QueueName.BACKGROUND_TASK) private backgroundTask: Queue,
@InjectQueue(QueueName.MACHINE_LEARNING) private machineLearning: Queue<IAssetJob>,
@InjectQueue(QueueName.METADATA_EXTRACTION) private metadataExtraction: Queue<IMetadataExtractionJob>,
@InjectQueue(QueueName.OBJECT_TAGGING) private objectTagging: Queue<IAssetJob | IBaseJob>,
@InjectQueue(QueueName.CLIP_ENCODING) private clipEmbedding: Queue<IAssetJob | IBaseJob>,
@InjectQueue(QueueName.METADATA_EXTRACTION) private metadataExtraction: Queue<IMetadataExtractionJob | IBaseJob>,
@InjectQueue(QueueName.STORAGE_TEMPLATE_MIGRATION) private storageTemplateMigration: Queue,
@InjectQueue(QueueName.THUMBNAIL_GENERATION) private thumbnail: Queue,
@InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue<IAssetJob>,
@InjectQueue(QueueName.THUMBNAIL_GENERATION) private generateThumbnail: Queue,
@InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue<IAssetJob | IBaseJob>,
@InjectQueue(QueueName.SEARCH) private searchIndex: Queue,
) {}
@ -21,12 +41,16 @@ export class JobRepository implements IJobRepository {
return !!counts.active;
}
pause(name: QueueName) {
return this.queueMap[name].pause();
}
empty(name: QueueName) {
return this.getQueue(name).empty();
return this.queueMap[name].empty();
}
getJobCounts(name: QueueName): Promise<JobCounts> {
return this.getQueue(name).getJobCounts();
return this.queueMap[name].getJobCounts();
}
async queue(item: JobItem): Promise<void> {
@ -39,21 +63,28 @@ export class JobRepository implements IJobRepository {
await this.backgroundTask.add(item.name, item.data);
break;
case JobName.OBJECT_DETECTION:
case JobName.IMAGE_TAGGING:
case JobName.ENCODE_CLIP:
await this.machineLearning.add(item.name, item.data);
case JobName.QUEUE_OBJECT_TAGGING:
case JobName.DETECT_OBJECTS:
case JobName.CLASSIFY_IMAGE:
await this.objectTagging.add(item.name, item.data);
break;
case JobName.QUEUE_ENCODE_CLIP:
case JobName.ENCODE_CLIP:
await this.clipEmbedding.add(item.name, item.data);
break;
case JobName.QUEUE_METADATA_EXTRACTION:
case JobName.EXIF_EXTRACTION:
case JobName.EXTRACT_VIDEO_METADATA:
case JobName.REVERSE_GEOCODING:
await this.metadataExtraction.add(item.name, item.data);
break;
case JobName.QUEUE_GENERATE_THUMBNAILS:
case JobName.GENERATE_JPEG_THUMBNAIL:
case JobName.GENERATE_WEBP_THUMBNAIL:
await this.thumbnail.add(item.name, item.data);
await this.generateThumbnail.add(item.name, item.data);
break;
case JobName.USER_DELETION:
@ -68,6 +99,7 @@ export class JobRepository implements IJobRepository {
await this.backgroundTask.add(item.name, {});
break;
case JobName.QUEUE_VIDEO_CONVERSION:
case JobName.VIDEO_CONVERSION:
await this.videoTranscode.add(item.name, item.data);
break;
@ -85,25 +117,7 @@ export class JobRepository implements IJobRepository {
break;
default:
// TODO inject remaining queues and map job to queue
this.logger.error('Invalid job', item);
}
}
private getQueue(name: QueueName) {
switch (name) {
case QueueName.STORAGE_TEMPLATE_MIGRATION:
return this.storageTemplateMigration;
case QueueName.THUMBNAIL_GENERATION:
return this.thumbnail;
case QueueName.METADATA_EXTRACTION:
return this.metadataExtraction;
case QueueName.VIDEO_CONVERSION:
return this.videoTranscode;
case QueueName.MACHINE_LEARNING:
return this.machineLearning;
default:
throw new BadRequestException('Invalid job name');
}
}
}

View File

@ -7,7 +7,7 @@ const client = axios.create({ baseURL: MACHINE_LEARNING_URL });
@Injectable()
export class MachineLearningRepository implements IMachineLearningRepository {
tagImage(input: MachineLearningInput): Promise<string[]> {
classifyImage(input: MachineLearningInput): Promise<string[]> {
return client.post<string[]>('/image-classifier/tag-image', input).then((res) => res.data);
}

View File

@ -291,34 +291,52 @@ export interface AlbumResponseDto {
export interface AllJobStatusResponseDto {
/**
*
* @type {JobCounts}
* @type {JobCountsDto}
* @memberof AllJobStatusResponseDto
*/
'thumbnail-generation': JobCounts;
'thumbnail-generation-queue': JobCountsDto;
/**
*
* @type {JobCounts}
* @type {JobCountsDto}
* @memberof AllJobStatusResponseDto
*/
'metadata-extraction': JobCounts;
'metadata-extraction-queue': JobCountsDto;
/**
*
* @type {JobCounts}
* @type {JobCountsDto}
* @memberof AllJobStatusResponseDto
*/
'video-conversion': JobCounts;
'video-conversion-queue': JobCountsDto;
/**
*
* @type {JobCounts}
* @type {JobCountsDto}
* @memberof AllJobStatusResponseDto
*/
'machine-learning': JobCounts;
'object-tagging-queue': JobCountsDto;
/**
*
* @type {JobCounts}
* @type {JobCountsDto}
* @memberof AllJobStatusResponseDto
*/
'storage-template-migration': JobCounts;
'clip-encoding-queue': JobCountsDto;
/**
*
* @type {JobCountsDto}
* @memberof AllJobStatusResponseDto
*/
'storage-template-migration-queue': JobCountsDto;
/**
*
* @type {JobCountsDto}
* @memberof AllJobStatusResponseDto
*/
'background-task-queue': JobCountsDto;
/**
*
* @type {JobCountsDto}
* @memberof AllJobStatusResponseDto
*/
'search-queue': JobCountsDto;
}
/**
*
@ -1203,7 +1221,8 @@ export interface GetAssetCountByTimeBucketDto {
export const JobCommand = {
Start: 'start',
Stop: 'stop'
Pause: 'pause',
Empty: 'empty'
} as const;
export type JobCommand = typeof JobCommand[keyof typeof JobCommand];
@ -1226,42 +1245,42 @@ export interface JobCommandDto {
* @type {boolean}
* @memberof JobCommandDto
*/
'includeAllAssets': boolean;
'force': boolean;
}
/**
*
* @export
* @interface JobCounts
* @interface JobCountsDto
*/
export interface JobCounts {
export interface JobCountsDto {
/**
*
* @type {number}
* @memberof JobCounts
* @memberof JobCountsDto
*/
'active': number;
/**
*
* @type {number}
* @memberof JobCounts
* @memberof JobCountsDto
*/
'completed': number;
/**
*
* @type {number}
* @memberof JobCounts
* @memberof JobCountsDto
*/
'failed': number;
/**
*
* @type {number}
* @memberof JobCounts
* @memberof JobCountsDto
*/
'delayed': number;
/**
*
* @type {number}
* @memberof JobCounts
* @memberof JobCountsDto
*/
'waiting': number;
}
@ -1271,15 +1290,18 @@ export interface JobCounts {
* @enum {string}
*/
export const JobId = {
ThumbnailGeneration: 'thumbnail-generation',
MetadataExtraction: 'metadata-extraction',
VideoConversion: 'video-conversion',
MachineLearning: 'machine-learning',
StorageTemplateMigration: 'storage-template-migration'
export const JobName = {
ThumbnailGenerationQueue: 'thumbnail-generation-queue',
MetadataExtractionQueue: 'metadata-extraction-queue',
VideoConversionQueue: 'video-conversion-queue',
ObjectTaggingQueue: 'object-tagging-queue',
ClipEncodingQueue: 'clip-encoding-queue',
BackgroundTaskQueue: 'background-task-queue',
StorageTemplateMigrationQueue: 'storage-template-migration-queue',
SearchQueue: 'search-queue'
} as const;
export type JobId = typeof JobId[keyof typeof JobId];
export type JobName = typeof JobName[keyof typeof JobName];
/**
@ -6169,12 +6191,12 @@ export const JobApiAxiosParamCreator = function (configuration?: Configuration)
},
/**
*
* @param {JobId} jobId
* @param {JobName} jobId
* @param {JobCommandDto} jobCommandDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
sendJobCommand: async (jobId: JobId, jobCommandDto: JobCommandDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
sendJobCommand: async (jobId: JobName, jobCommandDto: JobCommandDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'jobId' is not null or undefined
assertParamExists('sendJobCommand', 'jobId', jobId)
// verify required parameter 'jobCommandDto' is not null or undefined
@ -6233,12 +6255,12 @@ export const JobApiFp = function(configuration?: Configuration) {
},
/**
*
* @param {JobId} jobId
* @param {JobName} jobId
* @param {JobCommandDto} jobCommandDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<number>> {
async sendJobCommand(jobId: JobName, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.sendJobCommand(jobId, jobCommandDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
@ -6262,12 +6284,12 @@ export const JobApiFactory = function (configuration?: Configuration, basePath?:
},
/**
*
* @param {JobId} jobId
* @param {JobName} jobId
* @param {JobCommandDto} jobCommandDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: any): AxiosPromise<number> {
sendJobCommand(jobId: JobName, jobCommandDto: JobCommandDto, options?: any): AxiosPromise<void> {
return localVarFp.sendJobCommand(jobId, jobCommandDto, options).then((request) => request(axios, basePath));
},
};
@ -6292,13 +6314,13 @@ export class JobApi extends BaseAPI {
/**
*
* @param {JobId} jobId
* @param {JobName} jobId
* @param {JobCommandDto} jobCommandDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof JobApi
*/
public sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig) {
public sendJobCommand(jobId: JobName, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig) {
return JobApiFp(this.configuration).sendJobCommand(jobId, jobCommandDto, options).then((request) => request(this.axios, this.basePath));
}
}

View File

@ -5,11 +5,11 @@
import AllInclusive from 'svelte-material-icons/AllInclusive.svelte';
import { locale } from '$lib/stores/preferences.store';
import { createEventDispatcher } from 'svelte';
import { JobCounts } from '@api';
import { JobCountsDto } from '@api';
export let title: string;
export let subtitle: string;
export let jobCounts: JobCounts;
export let jobCounts: JobCountsDto;
/**
* Show options to run job on all assets of just missing ones
*/
@ -19,8 +19,8 @@
const dispatch = createEventDispatcher();
const run = (includeAllAssets: boolean) => {
dispatch('click', { includeAllAssets });
const run = (force: boolean) => {
dispatch('click', { force });
};
</script>

View File

@ -4,7 +4,7 @@
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { AllJobStatusResponseDto, api, JobCommand, JobId } from '@api';
import { AllJobStatusResponseDto, api, JobCommand, JobName } from '@api';
import { onDestroy, onMount } from 'svelte';
import JobTile from './job-tile.svelte';
@ -18,35 +18,42 @@
onMount(async () => {
await load();
timer = setInterval(async () => await load(), 1_000);
timer = setInterval(async () => await load(), 5_000);
});
onDestroy(() => {
clearInterval(timer);
});
const run = async (
jobId: JobId,
jobName: string,
emptyMessage: string,
includeAllAssets: boolean
) => {
try {
const { data } = await api.jobApi.sendJobCommand(jobId, {
command: JobCommand.Start,
includeAllAssets
});
function getJobLabel(jobName: JobName) {
const names: Record<JobName, string> = {
[JobName.ThumbnailGenerationQueue]: 'Generate Thumbnails',
[JobName.MetadataExtractionQueue]: 'Extract Metadata',
[JobName.VideoConversionQueue]: 'Transcode Videos',
[JobName.ObjectTaggingQueue]: 'Tag Objects',
[JobName.ClipEncodingQueue]: 'Clip Encoding',
[JobName.BackgroundTaskQueue]: 'Background Task',
[JobName.StorageTemplateMigrationQueue]: 'Storage Template Migration',
[JobName.SearchQueue]: 'Search'
};
if (data) {
notificationController.show({
message: includeAllAssets ? `Started ${jobName} for all assets` : `Started ${jobName}`,
type: NotificationType.Info
});
} else {
notificationController.show({ message: emptyMessage, type: NotificationType.Info });
}
return names[jobName];
}
const start = async (jobId: JobName, force: boolean) => {
const label = getJobLabel(jobId);
try {
await api.jobApi.sendJobCommand(jobId, { command: JobCommand.Start, force });
jobs[jobId].active += 1;
notificationController.show({
message: `Started job: ${label}`,
type: NotificationType.Info
});
} catch (error) {
handleError(error, `Unable to start ${jobName}`);
handleError(error, `Unable to start job: ${label}`);
}
};
</script>
@ -54,76 +61,48 @@
<div class="flex flex-col gap-7">
{#if jobs}
<JobTile
title={'Generate thumbnails'}
subtitle={'Regenerate JPEG and WebP thumbnails'}
on:click={(e) => {
const { includeAllAssets } = e.detail;
run(
JobId.ThumbnailGeneration,
'thumbnail generation',
'No missing thumbnails found',
includeAllAssets
);
}}
jobCounts={jobs[JobId.ThumbnailGeneration]}
title="Generate thumbnails"
subtitle="Regenerate JPEG and WebP thumbnails"
on:click={(e) => start(JobName.ThumbnailGenerationQueue, e.detail.force)}
jobCounts={jobs[JobName.ThumbnailGenerationQueue]}
/>
<JobTile
title={'EXTRACT METADATA'}
subtitle={'Extract metadata information i.e. GPS, resolution...etc'}
on:click={(e) => {
const { includeAllAssets } = e.detail;
run(JobId.MetadataExtraction, 'extract EXIF', 'No missing EXIF found', includeAllAssets);
}}
jobCounts={jobs[JobId.MetadataExtraction]}
title="Extract Metadata"
subtitle="Extract metadata information i.e. GPS, resolution...etc"
on:click={(e) => start(JobName.MetadataExtractionQueue, e.detail.force)}
jobCounts={jobs[JobName.MetadataExtractionQueue]}
/>
<JobTile
title={'Detect objects'}
subtitle={'Run machine learning process to detect and classify objects'}
on:click={(e) => {
const { includeAllAssets } = e.detail;
run(
JobId.MachineLearning,
'object detection',
'No missing object detection found',
includeAllAssets
);
}}
jobCounts={jobs[JobId.MachineLearning]}
title="Tag Objects"
subtitle="Run machine learning to tag objects"
on:click={(e) => start(JobName.ObjectTaggingQueue, e.detail.force)}
jobCounts={jobs[JobName.ObjectTaggingQueue]}
>
Note that some assets may not have any objects detected
</JobTile>
<JobTile
title={'Video transcoding'}
subtitle={'Transcode videos not in the desired format'}
on:click={(e) => {
const { includeAllAssets } = e.detail;
run(
JobId.VideoConversion,
'video conversion',
'No videos without an encoded version found',
includeAllAssets
);
}}
jobCounts={jobs[JobId.VideoConversion]}
title="Encode Clip"
subtitle="Run machine learning to generate clip embeddings"
on:click={(e) => start(JobName.ClipEncodingQueue, e.detail.force)}
jobCounts={jobs[JobName.ClipEncodingQueue]}
/>
<JobTile
title={'Storage migration'}
title="Transcode Videos"
subtitle="Transcode videos not in the desired format"
on:click={(e) => start(JobName.VideoConversionQueue, e.detail.force)}
jobCounts={jobs[JobName.VideoConversionQueue]}
/>
<JobTile
title="Storage migration"
showOptions={false}
subtitle={''}
on:click={() =>
run(
JobId.StorageTemplateMigration,
'storage template migration',
'All files have been migrated to the new storage template',
false
)}
jobCounts={jobs[JobId.StorageTemplateMigration]}
on:click={(e) => start(JobName.StorageTemplateMigrationQueue, e.detail.force)}
jobCounts={jobs[JobName.StorageTemplateMigrationQueue]}
>
Apply the current
<a