diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index f01b4603b8..abe3d9b878 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -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 diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 98eabb4abc..8ec63af338 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/AllJobStatusResponseDto.md b/mobile/openapi/doc/AllJobStatusResponseDto.md index 66695ac2e1..306c902efc 100644 Binary files a/mobile/openapi/doc/AllJobStatusResponseDto.md and b/mobile/openapi/doc/AllJobStatusResponseDto.md differ diff --git a/mobile/openapi/doc/JobApi.md b/mobile/openapi/doc/JobApi.md index edb412cd57..f3a6b81ccb 100644 Binary files a/mobile/openapi/doc/JobApi.md and b/mobile/openapi/doc/JobApi.md differ diff --git a/mobile/openapi/doc/JobCommandDto.md b/mobile/openapi/doc/JobCommandDto.md index 68cbee51a8..49dc499f46 100644 Binary files a/mobile/openapi/doc/JobCommandDto.md and b/mobile/openapi/doc/JobCommandDto.md differ diff --git a/mobile/openapi/doc/JobCounts.md b/mobile/openapi/doc/JobCountsDto.md similarity index 94% rename from mobile/openapi/doc/JobCounts.md rename to mobile/openapi/doc/JobCountsDto.md index 353b834382..c4987e5013 100644 Binary files a/mobile/openapi/doc/JobCounts.md and b/mobile/openapi/doc/JobCountsDto.md differ diff --git a/mobile/openapi/doc/JobId.md b/mobile/openapi/doc/JobName.md similarity index 93% rename from mobile/openapi/doc/JobId.md rename to mobile/openapi/doc/JobName.md index d2f68234d0..43fb27c794 100644 Binary files a/mobile/openapi/doc/JobId.md and b/mobile/openapi/doc/JobName.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d70ad04993..4ccf97fad2 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/job_api.dart b/mobile/openapi/lib/api/job_api.dart index 5d53dfb919..85174188aa 100644 Binary files a/mobile/openapi/lib/api/job_api.dart and b/mobile/openapi/lib/api/job_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 3b2399f23e..7268a004e4 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index ba9f94ccdc..236532a19d 100644 Binary files a/mobile/openapi/lib/api_helper.dart and b/mobile/openapi/lib/api_helper.dart differ diff --git a/mobile/openapi/lib/model/all_job_status_response_dto.dart b/mobile/openapi/lib/model/all_job_status_response_dto.dart index 60f788a302..5feb3bfd09 100644 Binary files a/mobile/openapi/lib/model/all_job_status_response_dto.dart and b/mobile/openapi/lib/model/all_job_status_response_dto.dart differ diff --git a/mobile/openapi/lib/model/job_command.dart b/mobile/openapi/lib/model/job_command.dart index 2734028076..4ebdec26b4 100644 Binary files a/mobile/openapi/lib/model/job_command.dart and b/mobile/openapi/lib/model/job_command.dart differ diff --git a/mobile/openapi/lib/model/job_command_dto.dart b/mobile/openapi/lib/model/job_command_dto.dart index adf9fc3344..fe5c383439 100644 Binary files a/mobile/openapi/lib/model/job_command_dto.dart and b/mobile/openapi/lib/model/job_command_dto.dart differ diff --git a/mobile/openapi/lib/model/job_counts.dart b/mobile/openapi/lib/model/job_counts_dto.dart similarity index 72% rename from mobile/openapi/lib/model/job_counts.dart rename to mobile/openapi/lib/model/job_counts_dto.dart index 795821a19b..41174e57a7 100644 Binary files a/mobile/openapi/lib/model/job_counts.dart and b/mobile/openapi/lib/model/job_counts_dto.dart differ diff --git a/mobile/openapi/lib/model/job_id.dart b/mobile/openapi/lib/model/job_id.dart deleted file mode 100644 index 19919f6d72..0000000000 Binary files a/mobile/openapi/lib/model/job_id.dart and /dev/null differ diff --git a/mobile/openapi/lib/model/job_name.dart b/mobile/openapi/lib/model/job_name.dart new file mode 100644 index 0000000000..48b1cd9bcf Binary files /dev/null and b/mobile/openapi/lib/model/job_name.dart differ diff --git a/mobile/openapi/test/all_job_status_response_dto_test.dart b/mobile/openapi/test/all_job_status_response_dto_test.dart index 9405fd1f3d..1154f4fa9f 100644 Binary files a/mobile/openapi/test/all_job_status_response_dto_test.dart and b/mobile/openapi/test/all_job_status_response_dto_test.dart differ diff --git a/mobile/openapi/test/job_api_test.dart b/mobile/openapi/test/job_api_test.dart index cfdfa6479c..6f980417e0 100644 Binary files a/mobile/openapi/test/job_api_test.dart and b/mobile/openapi/test/job_api_test.dart differ diff --git a/mobile/openapi/test/job_command_dto_test.dart b/mobile/openapi/test/job_command_dto_test.dart index fe847827ac..83a27b4d9c 100644 Binary files a/mobile/openapi/test/job_command_dto_test.dart and b/mobile/openapi/test/job_command_dto_test.dart differ diff --git a/mobile/openapi/test/job_counts_test.dart b/mobile/openapi/test/job_counts_dto_test.dart similarity index 89% rename from mobile/openapi/test/job_counts_test.dart rename to mobile/openapi/test/job_counts_dto_test.dart index 15f1ea87c9..33c14891f3 100644 Binary files a/mobile/openapi/test/job_counts_test.dart and b/mobile/openapi/test/job_counts_dto_test.dart differ diff --git a/mobile/openapi/test/job_id_test.dart b/mobile/openapi/test/job_name_test.dart similarity index 87% rename from mobile/openapi/test/job_id_test.dart rename to mobile/openapi/test/job_name_test.dart index 66b6b7656c..4c14d76be0 100644 Binary files a/mobile/openapi/test/job_id_test.dart and b/mobile/openapi/test/job_name_test.dart differ diff --git a/server/apps/immich/src/api-v1/asset/asset-repository.ts b/server/apps/immich/src/api-v1/asset/asset-repository.ts index 9c36da9ae0..c4f8e33649 100644 --- a/server/apps/immich/src/api-v1/asset/asset-repository.ts +++ b/server/apps/immich/src/api-v1/asset/asset-repository.ts @@ -38,10 +38,6 @@ export interface IAssetRepository { getAssetCountByUserId(userId: string): Promise; getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise; getAssetByChecksum(userId: string, checksum: Buffer): Promise; - getAssetWithNoThumbnail(): Promise; - getAssetWithNoEncodedVideo(): Promise; - getAssetWithNoEXIF(): Promise; - getAssetWithNoSmartInfo(): Promise; getExistingAssets( userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto, @@ -76,45 +72,6 @@ export class AssetRepository implements IAssetRepository { }); } - async getAssetWithNoSmartInfo(): Promise { - 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 { - return await this.assetRepository.find({ - where: [ - { resizePath: IsNull(), isVisible: true }, - { resizePath: '', isVisible: true }, - { webpPath: IsNull(), isVisible: true }, - { webpPath: '', isVisible: true }, - ], - }); - } - - async getAssetWithNoEncodedVideo(): Promise { - return await this.assetRepository.find({ - where: [ - { type: AssetType.VIDEO, encodedVideoPath: IsNull() }, - { type: AssetType.VIDEO, encodedVideoPath: '' }, - ], - }); - } - - async getAssetWithNoEXIF(): Promise { - 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 { // Get asset count by AssetType const items = await this.assetRepository diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index a4875a95aa..40d2b23da1 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -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(), }; diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index 3f051fb223..76a0ab2327 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -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); diff --git a/server/apps/immich/src/api-v1/job/dto/get-job.dto.ts b/server/apps/immich/src/api-v1/job/dto/get-job.dto.ts deleted file mode 100644 index f67b48e0fd..0000000000 --- a/server/apps/immich/src/api-v1/job/dto/get-job.dto.ts +++ /dev/null @@ -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; -} diff --git a/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts b/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts deleted file mode 100644 index 5984404184..0000000000 --- a/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts +++ /dev/null @@ -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; -} diff --git a/server/apps/immich/src/api-v1/job/job.controller.ts b/server/apps/immich/src/api-v1/job/job.controller.ts deleted file mode 100644 index 6abcdb5428..0000000000 --- a/server/apps/immich/src/api-v1/job/job.controller.ts +++ /dev/null @@ -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 { - return this.jobService.getAllJobsStatus(); - } - - @Put('/:jobId') - async sendJobCommand( - @Param(ValidationPipe) params: GetJobDto, - @Body(ValidationPipe) dto: JobCommandDto, - ): Promise { - 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; - } -} diff --git a/server/apps/immich/src/api-v1/job/job.module.ts b/server/apps/immich/src/api-v1/job/job.module.ts deleted file mode 100644 index cc513af409..0000000000 --- a/server/apps/immich/src/api-v1/job/job.module.ts +++ /dev/null @@ -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 {} diff --git a/server/apps/immich/src/api-v1/job/job.service.ts b/server/apps/immich/src/api-v1/job/job.service.ts deleted file mode 100644 index 3a630130c3..0000000000 --- a/server/apps/immich/src/api-v1/job/job.service.ts +++ /dev/null @@ -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 { - return this.run(this.asQueueName(jobId), includeAllAssets); - } - - async stop(jobId: JobId): Promise { - await this.jobRepository.empty(this.asQueueName(jobId)); - return 0; - } - - async getAllJobsStatus(): Promise { - 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 { - 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}`); - } - } -} diff --git a/server/apps/immich/src/api-v1/job/response-dto/all-job-status-response.dto.ts b/server/apps/immich/src/api-v1/job/response-dto/all-job-status-response.dto.ts deleted file mode 100644 index 9b26936ce6..0000000000 --- a/server/apps/immich/src/api-v1/job/response-dto/all-job-status-response.dto.ts +++ /dev/null @@ -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; -} diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index ec8bc4aaed..12fd9d231c 100644 --- a/server/apps/immich/src/app.module.ts +++ b/server/apps/immich/src/app.module.ts @@ -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, diff --git a/server/apps/immich/src/controllers/index.ts b/server/apps/immich/src/controllers/index.ts index 171a0debb3..a621302b51 100644 --- a/server/apps/immich/src/controllers/index.ts +++ b/server/apps/immich/src/controllers/index.ts @@ -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'; diff --git a/server/apps/immich/src/controllers/job.controller.ts b/server/apps/immich/src/controllers/job.controller.ts new file mode 100644 index 0000000000..942234c9cf --- /dev/null +++ b/server/apps/immich/src/controllers/job.controller.ts @@ -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 { + return this.jobService.getAllJobsStatus(); + } + + @Put('/:jobId') + sendJobCommand(@Param(ValidationPipe) { jobId }: JobIdDto, @Body(ValidationPipe) dto: JobCommandDto): Promise { + return this.jobService.handleCommand(jobId, dto); + } +} diff --git a/server/apps/microservices/src/microservices.module.ts b/server/apps/microservices/src/microservices.module.ts index 68b755af65..4530a139c7 100644 --- a/server/apps/microservices/src/microservices.module.ts +++ b/server/apps/microservices/src/microservices.module.ts @@ -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, diff --git a/server/apps/microservices/src/processors.ts b/server/apps/microservices/src/processors.ts index fe935744d7..61dfbdddae 100644 --- a/server/apps/microservices/src/processors.ts +++ b/server/apps/microservices/src/processors.ts @@ -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) { - await this.smartInfoService.handleTagImage(job.data); + @Process({ name: JobName.QUEUE_OBJECT_TAGGING, concurrency: 1 }) + async onQueueObjectTagging(job: Job) { + await this.smartInfoService.handleQueueObjectTagging(job.data); } - @Process({ name: JobName.OBJECT_DETECTION, concurrency: 1 }) - async onDetectObject(job: Job) { + @Process({ name: JobName.DETECT_OBJECTS, concurrency: 1 }) + async onDetectObjects(job: Job) { await this.smartInfoService.handleDetectObjects(job.data); } + @Process({ name: JobName.CLASSIFY_IMAGE, concurrency: 1 }) + async onClassifyImage(job: Job) { + 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) { + await this.smartInfoService.handleQueueEncodeClip(job.data); + } + @Process({ name: JobName.ENCODE_CLIP, concurrency: 1 }) async onEncodeClip(job: Job) { 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) { + await this.mediaService.handleQueueGenerateThumbnails(job.data); + } + @Process({ name: JobName.GENERATE_JPEG_THUMBNAIL, concurrency: 3 }) async handleGenerateJpegThumbnail(job: Job) { await this.mediaService.handleGenerateJpegThumbnail(job.data); diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index 5bc8c4e786..afa8721b1e 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -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, @@ -148,6 +151,24 @@ export class MetadataExtractionProcessor { return { country, state, city }; } + @Process(JobName.QUEUE_METADATA_EXTRACTION) + async handleQueueMetadataExtraction(job: Job) { + 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) { try { diff --git a/server/apps/microservices/src/processors/video-transcode.processor.ts b/server/apps/microservices/src/processors/video-transcode.processor.ts index 93a43f64c9..4d8fd6b48e 100644 --- a/server/apps/microservices/src/processors/video-transcode.processor.ts +++ b/server/apps/microservices/src/processors/video-transcode.processor.ts @@ -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): Promise { + 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) { + async handleVideoConversion(job: Job) { const { asset } = job.data; const basePath = APP_UPLOAD_LOCATION; const encodedVideoPath = `${basePath}/${asset.ownerId}/encoded-video`; diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 26653e5daf..836104eb89 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -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" - ] } } } diff --git a/server/libs/common/src/constants/index.ts b/server/libs/common/src/constants/index.ts index c0ceffa4c4..436e4d6f00 100644 --- a/server/libs/common/src/constants/index.ts +++ b/server/libs/common/src/constants/index.ts @@ -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.'); + } +} diff --git a/server/libs/domain/src/asset/asset.repository.ts b/server/libs/domain/src/asset/asset.repository.ts index 9b75eb8148..3d0b60c361 100644 --- a/server/libs/domain/src/asset/asset.repository.ts +++ b/server/libs/domain/src/asset/asset.repository.ts @@ -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; + getWithout(property: WithoutProperty): Promise; deleteAll(ownerId: string): Promise; getAll(options?: AssetSearchOptions): Promise; save(asset: Partial): Promise; diff --git a/server/libs/domain/src/domain.module.ts b/server/libs/domain/src/domain.module.ts index c469a2bc52..0e9071ee8f 100644 --- a/server/libs/domain/src/domain.module.ts +++ b/server/libs/domain/src/domain.module.ts @@ -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, diff --git a/server/libs/domain/src/index.ts b/server/libs/domain/src/index.ts index cf4403aed5..734640f94b 100644 --- a/server/libs/domain/src/index.ts +++ b/server/libs/domain/src/index.ts @@ -18,3 +18,4 @@ export * from './system-config'; export * from './tag'; export * from './user'; export * from './user-token'; +export * from './util'; diff --git a/server/libs/domain/src/job/dto/index.ts b/server/libs/domain/src/job/dto/index.ts new file mode 100644 index 0000000000..abb9f96817 --- /dev/null +++ b/server/libs/domain/src/job/dto/index.ts @@ -0,0 +1,2 @@ +export * from './job-command.dto'; +export * from './job-id.dto'; diff --git a/server/libs/domain/src/job/dto/job-command.dto.ts b/server/libs/domain/src/job/dto/job-command.dto.ts new file mode 100644 index 0000000000..94ddbb9745 --- /dev/null +++ b/server/libs/domain/src/job/dto/job-command.dto.ts @@ -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; +} diff --git a/server/libs/domain/src/job/dto/job-id.dto.ts b/server/libs/domain/src/job/dto/job-id.dto.ts new file mode 100644 index 0000000000..28e8d40043 --- /dev/null +++ b/server/libs/domain/src/job/dto/job-id.dto.ts @@ -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; +} diff --git a/server/libs/domain/src/job/index.ts b/server/libs/domain/src/job/index.ts index 5721a508cf..755d7994a7 100644 --- a/server/libs/domain/src/job/index.ts +++ b/server/libs/domain/src/job/index.ts @@ -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'; diff --git a/server/libs/domain/src/job/job.constants.ts b/server/libs/domain/src/job/job.constants.ts index 0404f33ddd..2a75cd3d3b 100644 --- a/server/libs/domain/src/job/job.constants.ts +++ b/server/libs/domain/src/job/job.constants.ts @@ -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', } diff --git a/server/libs/domain/src/job/job.interface.ts b/server/libs/domain/src/job/job.interface.ts index ad21fb1484..640cc12944 100644 --- a/server/libs/domain/src/job/job.interface.ts +++ b/server/libs/domain/src/job/job.interface.ts @@ -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; } -export interface IUserDeletionJob { +export interface IUserDeletionJob extends IBaseJob { user: UserEntity; } -export interface IReverseGeocodingJob { +export interface IReverseGeocodingJob extends IBaseJob { assetId: string; latitude: number; longitude: number; diff --git a/server/libs/domain/src/job/job.repository.ts b/server/libs/domain/src/job/job.repository.ts index d9f72586c8..ea6d6309ea 100644 --- a/server/libs/domain/src/job/job.repository.ts +++ b/server/libs/domain/src/job/job.repository.ts @@ -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; + pause(name: QueueName): Promise; empty(name: QueueName): Promise; isActive(name: QueueName): Promise; getJobCounts(name: QueueName): Promise; diff --git a/server/libs/domain/src/job/job.service.spec.ts b/server/libs/domain/src/job/job.service.spec.ts new file mode 100644 index 0000000000..42ce5e5144 --- /dev/null +++ b/server/libs/domain/src/job/job.service.spec.ts @@ -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; + + 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(); + }); + }); +}); diff --git a/server/libs/domain/src/job/job.service.ts b/server/libs/domain/src/job/job.service.ts new file mode 100644 index 0000000000..fd89573ec6 --- /dev/null +++ b/server/libs/domain/src/job/job.service.ts @@ -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 { + 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 { + 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 { + 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}`); + } + } +} diff --git a/server/libs/domain/src/job/response-dto/all-job-status-response.dto.ts b/server/libs/domain/src/job/response-dto/all-job-status-response.dto.ts new file mode 100644 index 0000000000..09bb55c307 --- /dev/null +++ b/server/libs/domain/src/job/response-dto/all-job-status-response.dto.ts @@ -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 { + @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; +} diff --git a/server/libs/domain/src/job/response-dto/index.ts b/server/libs/domain/src/job/response-dto/index.ts new file mode 100644 index 0000000000..7229910ae5 --- /dev/null +++ b/server/libs/domain/src/job/response-dto/index.ts @@ -0,0 +1 @@ +export * from './all-job-status-response.dto'; diff --git a/server/libs/domain/src/media/media.service.ts b/server/libs/domain/src/media/media.service.ts index 97f84162cf..f39cc2ab8b 100644 --- a/server/libs/domain/src/media/media.service.ts +++ b/server/libs/domain/src/media/media.service.ts @@ -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 { + 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 { 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)); diff --git a/server/libs/domain/src/smart-info/machine-learning.interface.ts b/server/libs/domain/src/smart-info/machine-learning.interface.ts index a42bf30231..9d22e08572 100644 --- a/server/libs/domain/src/smart-info/machine-learning.interface.ts +++ b/server/libs/domain/src/smart-info/machine-learning.interface.ts @@ -5,7 +5,7 @@ export interface MachineLearningInput { } export interface IMachineLearningRepository { - tagImage(input: MachineLearningInput): Promise; + classifyImage(input: MachineLearningInput): Promise; detectObjects(input: MachineLearningInput): Promise; encodeImage(input: MachineLearningInput): Promise; encodeText(input: string): Promise; diff --git a/server/libs/domain/src/smart-info/smart-info.service.spec.ts b/server/libs/domain/src/smart-info/smart-info.service.spec.ts index 41e3887b6b..2a763a8481 100644 --- a/server/libs/domain/src/smart-info/smart-info.service.spec.ts +++ b/server/libs/domain/src/smart-info/smart-info.service.spec.ts @@ -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; let jobMock: jest.Mocked; let smartMock: jest.Mocked; let machineMock: jest.Mocked; 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(); + }); + }); }); diff --git a/server/libs/domain/src/smart-info/smart-info.service.ts b/server/libs/domain/src/smart-info/smart-info.service.ts index 2621576eed..8e7cd014ab 100644 --- a/server/libs/domain/src/smart-info/smart-info.service.ts +++ b/server/libs/domain/src/smart-info/smart-info.service.ts @@ -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; diff --git a/server/apps/immich/src/utils/file-name.util.ts b/server/libs/domain/src/util.ts similarity index 100% rename from server/apps/immich/src/utils/file-name.util.ts rename to server/libs/domain/src/util.ts diff --git a/server/libs/domain/test/asset.repository.mock.ts b/server/libs/domain/test/asset.repository.mock.ts index b56bd1419b..314804c2c5 100644 --- a/server/libs/domain/test/asset.repository.mock.ts +++ b/server/libs/domain/test/asset.repository.mock.ts @@ -3,6 +3,7 @@ import { IAssetRepository } from '../src'; export const newAssetRepositoryMock = (): jest.Mocked => { return { getByIds: jest.fn(), + getWithout: jest.fn(), getAll: jest.fn(), deleteAll: jest.fn(), save: jest.fn(), diff --git a/server/libs/domain/test/job.repository.mock.ts b/server/libs/domain/test/job.repository.mock.ts index 22c47b2dad..5b2d9547b2 100644 --- a/server/libs/domain/test/job.repository.mock.ts +++ b/server/libs/domain/test/job.repository.mock.ts @@ -3,6 +3,7 @@ import { IJobRepository } from '../src'; export const newJobRepositoryMock = (): jest.Mocked => { return { empty: jest.fn(), + pause: jest.fn(), queue: jest.fn().mockImplementation(() => Promise.resolve()), isActive: jest.fn(), getJobCounts: jest.fn(), diff --git a/server/libs/domain/test/machine-learning.repository.mock.ts b/server/libs/domain/test/machine-learning.repository.mock.ts index 0bc06814ec..b9205d44e8 100644 --- a/server/libs/domain/test/machine-learning.repository.mock.ts +++ b/server/libs/domain/test/machine-learning.repository.mock.ts @@ -2,7 +2,7 @@ import { IMachineLearningRepository } from '../src'; export const newMachineLearningRepositoryMock = (): jest.Mocked => { return { - tagImage: jest.fn(), + classifyImage: jest.fn(), detectObjects: jest.fn(), encodeImage: jest.fn(), encodeText: jest.fn(), diff --git a/server/libs/infra/src/db/repository/asset.repository.ts b/server/libs/infra/src/db/repository/asset.repository.ts index 17adb46bb3..38e57b4f1a 100644 --- a/server/libs/infra/src/db/repository/asset.repository.ts +++ b/server/libs/infra/src/db/repository/asset.repository.ts @@ -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 { + let relations: FindOptionsRelations = {}; + let where: FindOptionsWhere | FindOptionsWhere[] = {}; + + 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, + }); + } } diff --git a/server/libs/infra/src/job/job.repository.ts b/server/libs/infra/src/job/job.repository.ts index 53f3fb9a78..1fbacd840a 100644 --- a/server/libs/infra/src/job/job.repository.ts +++ b/server/libs/infra/src/job/job.repository.ts @@ -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.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, - @InjectQueue(QueueName.METADATA_EXTRACTION) private metadataExtraction: Queue, + @InjectQueue(QueueName.OBJECT_TAGGING) private objectTagging: Queue, + @InjectQueue(QueueName.CLIP_ENCODING) private clipEmbedding: Queue, + @InjectQueue(QueueName.METADATA_EXTRACTION) private metadataExtraction: Queue, @InjectQueue(QueueName.STORAGE_TEMPLATE_MIGRATION) private storageTemplateMigration: Queue, - @InjectQueue(QueueName.THUMBNAIL_GENERATION) private thumbnail: Queue, - @InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue, + @InjectQueue(QueueName.THUMBNAIL_GENERATION) private generateThumbnail: Queue, + @InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue, @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 { - return this.getQueue(name).getJobCounts(); + return this.queueMap[name].getJobCounts(); } async queue(item: JobItem): Promise { @@ -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'); - } - } } diff --git a/server/libs/infra/src/machine-learning/machine-learning.repository.ts b/server/libs/infra/src/machine-learning/machine-learning.repository.ts index a69d687445..78fc809779 100644 --- a/server/libs/infra/src/machine-learning/machine-learning.repository.ts +++ b/server/libs/infra/src/machine-learning/machine-learning.repository.ts @@ -7,7 +7,7 @@ const client = axios.create({ baseURL: MACHINE_LEARNING_URL }); @Injectable() export class MachineLearningRepository implements IMachineLearningRepository { - tagImage(input: MachineLearningInput): Promise { + classifyImage(input: MachineLearningInput): Promise { return client.post('/image-classifier/tag-image', input).then((res) => res.data); } diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 5b514cc3f4..c2607f7074 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -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 => { + sendJobCommand: async (jobId: JobName, jobCommandDto: JobCommandDto, options: AxiosRequestConfig = {}): Promise => { // 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> { + async sendJobCommand(jobId: JobName, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { 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 { + sendJobCommand(jobId: JobName, jobCommandDto: JobCommandDto, options?: any): AxiosPromise { 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)); } } diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte index f7b23c8ad7..625c4167d0 100644 --- a/web/src/lib/components/admin-page/jobs/job-tile.svelte +++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte @@ -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 }); }; diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index 815cfe6fe9..3a66855078 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -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.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}`); } }; @@ -54,76 +61,48 @@
{#if jobs} { - 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]} /> { - 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]} /> { - 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 { - 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]} /> start(JobName.VideoConversionQueue, e.detail.force)} + jobCounts={jobs[JobName.VideoConversionQueue]} + /> + + - 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