mirror of
https://github.com/immich-app/immich.git
synced 2025-01-06 11:56:46 +01: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:
parent
db6b14361d
commit
386eef046d
68 changed files with 1094 additions and 691 deletions
12
mobile/openapi/.openapi-generator/FILES
generated
12
mobile/openapi/.openapi-generator/FILES
generated
|
@ -51,8 +51,8 @@ doc/GetAssetCountByTimeBucketDto.md
|
||||||
doc/JobApi.md
|
doc/JobApi.md
|
||||||
doc/JobCommand.md
|
doc/JobCommand.md
|
||||||
doc/JobCommandDto.md
|
doc/JobCommandDto.md
|
||||||
doc/JobCounts.md
|
doc/JobCountsDto.md
|
||||||
doc/JobId.md
|
doc/JobName.md
|
||||||
doc/LoginCredentialDto.md
|
doc/LoginCredentialDto.md
|
||||||
doc/LoginResponseDto.md
|
doc/LoginResponseDto.md
|
||||||
doc/LogoutResponseDto.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/get_asset_count_by_time_bucket_dto.dart
|
||||||
lib/model/job_command.dart
|
lib/model/job_command.dart
|
||||||
lib/model/job_command_dto.dart
|
lib/model/job_command_dto.dart
|
||||||
lib/model/job_counts.dart
|
lib/model/job_counts_dto.dart
|
||||||
lib/model/job_id.dart
|
lib/model/job_name.dart
|
||||||
lib/model/login_credential_dto.dart
|
lib/model/login_credential_dto.dart
|
||||||
lib/model/login_response_dto.dart
|
lib/model/login_response_dto.dart
|
||||||
lib/model/logout_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_api_test.dart
|
||||||
test/job_command_dto_test.dart
|
test/job_command_dto_test.dart
|
||||||
test/job_command_test.dart
|
test/job_command_test.dart
|
||||||
test/job_counts_test.dart
|
test/job_counts_dto_test.dart
|
||||||
test/job_id_test.dart
|
test/job_name_test.dart
|
||||||
test/login_credential_dto_test.dart
|
test/login_credential_dto_test.dart
|
||||||
test/login_response_dto_test.dart
|
test/login_response_dto_test.dart
|
||||||
test/logout_response_dto_test.dart
|
test/logout_response_dto_test.dart
|
||||||
|
|
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/AllJobStatusResponseDto.md
generated
BIN
mobile/openapi/doc/AllJobStatusResponseDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/JobApi.md
generated
BIN
mobile/openapi/doc/JobApi.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/JobCommandDto.md
generated
BIN
mobile/openapi/doc/JobCommandDto.md
generated
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/job_api.dart
generated
BIN
mobile/openapi/lib/api/job_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_helper.dart
generated
BIN
mobile/openapi/lib/api_helper.dart
generated
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/lib/model/job_command.dart
generated
BIN
mobile/openapi/lib/model/job_command.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/job_command_dto.dart
generated
BIN
mobile/openapi/lib/model/job_command_dto.dart
generated
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/lib/model/job_id.dart
generated
BIN
mobile/openapi/lib/model/job_id.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/job_name.dart
generated
Normal file
BIN
mobile/openapi/lib/model/job_name.dart
generated
Normal file
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/test/job_api_test.dart
generated
BIN
mobile/openapi/test/job_api_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/job_command_dto_test.dart
generated
BIN
mobile/openapi/test/job_command_dto_test.dart
generated
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -38,10 +38,6 @@ export interface IAssetRepository {
|
||||||
getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
|
getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
|
||||||
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
|
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
|
||||||
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
|
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
|
||||||
getAssetWithNoThumbnail(): Promise<AssetEntity[]>;
|
|
||||||
getAssetWithNoEncodedVideo(): Promise<AssetEntity[]>;
|
|
||||||
getAssetWithNoEXIF(): Promise<AssetEntity[]>;
|
|
||||||
getAssetWithNoSmartInfo(): Promise<AssetEntity[]>;
|
|
||||||
getExistingAssets(
|
getExistingAssets(
|
||||||
userId: string,
|
userId: string,
|
||||||
checkDuplicateAssetDto: CheckExistingAssetsDto,
|
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> {
|
async getAssetCountByUserId(ownerId: string): Promise<AssetCountByUserIdResponseDto> {
|
||||||
// Get asset count by AssetType
|
// Get asset count by AssetType
|
||||||
const items = await this.assetRepository
|
const items = await this.assetRepository
|
||||||
|
|
|
@ -146,10 +146,6 @@ describe('AssetService', () => {
|
||||||
getAssetByTimeBucket: jest.fn(),
|
getAssetByTimeBucket: jest.fn(),
|
||||||
getAssetByChecksum: jest.fn(),
|
getAssetByChecksum: jest.fn(),
|
||||||
getAssetCountByUserId: jest.fn(),
|
getAssetCountByUserId: jest.fn(),
|
||||||
getAssetWithNoEXIF: jest.fn(),
|
|
||||||
getAssetWithNoThumbnail: jest.fn(),
|
|
||||||
getAssetWithNoSmartInfo: jest.fn(),
|
|
||||||
getAssetWithNoEncodedVideo: jest.fn(),
|
|
||||||
getExistingAssets: jest.fn(),
|
getExistingAssets: jest.fn(),
|
||||||
countByIdAndUser: jest.fn(),
|
countByIdAndUser: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
|
@ -63,7 +63,7 @@ import { AssetSearchDto } from './dto/asset-search.dto';
|
||||||
import { AddAssetsDto } from '../album/dto/add-assets.dto';
|
import { AddAssetsDto } from '../album/dto/add-assets.dto';
|
||||||
import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
|
import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getFileNameWithoutExtension } from '../../utils/file-name.util';
|
import { getFileNameWithoutExtension } from '@app/domain';
|
||||||
|
|
||||||
const fileInfo = promisify(stat);
|
const fileInfo = promisify(stat);
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 {}
|
|
|
@ -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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -7,7 +7,6 @@ import { AlbumModule } from './api-v1/album/album.module';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
|
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 { TagModule } from './api-v1/tag/tag.module';
|
||||||
import { DomainModule, SearchService } from '@app/domain';
|
import { DomainModule, SearchService } from '@app/domain';
|
||||||
import { InfraModule } from '@app/infra';
|
import { InfraModule } from '@app/infra';
|
||||||
|
@ -15,6 +14,7 @@ import {
|
||||||
APIKeyController,
|
APIKeyController,
|
||||||
AuthController,
|
AuthController,
|
||||||
DeviceInfoController,
|
DeviceInfoController,
|
||||||
|
JobController,
|
||||||
OAuthController,
|
OAuthController,
|
||||||
SearchController,
|
SearchController,
|
||||||
ShareController,
|
ShareController,
|
||||||
|
@ -42,8 +42,6 @@ import { AuthGuard } from './middlewares/auth.guard';
|
||||||
|
|
||||||
ScheduleTasksModule,
|
ScheduleTasksModule,
|
||||||
|
|
||||||
JobModule,
|
|
||||||
|
|
||||||
TagModule,
|
TagModule,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
|
@ -51,6 +49,7 @@ import { AuthGuard } from './middlewares/auth.guard';
|
||||||
APIKeyController,
|
APIKeyController,
|
||||||
AuthController,
|
AuthController,
|
||||||
DeviceInfoController,
|
DeviceInfoController,
|
||||||
|
JobController,
|
||||||
OAuthController,
|
OAuthController,
|
||||||
SearchController,
|
SearchController,
|
||||||
ShareController,
|
ShareController,
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
export * from './api-key.controller';
|
export * from './api-key.controller';
|
||||||
export * from './auth.controller';
|
export * from './auth.controller';
|
||||||
export * from './device-info.controller';
|
export * from './device-info.controller';
|
||||||
|
export * from './job.controller';
|
||||||
export * from './oauth.controller';
|
export * from './oauth.controller';
|
||||||
export * from './search.controller';
|
export * from './search.controller';
|
||||||
export * from './share.controller';
|
export * from './share.controller';
|
||||||
|
|
21
server/apps/immich/src/controllers/job.controller.ts
Normal file
21
server/apps/immich/src/controllers/job.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,7 +6,8 @@ import { ConfigModule } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import {
|
import {
|
||||||
BackgroundTaskProcessor,
|
BackgroundTaskProcessor,
|
||||||
MachineLearningProcessor,
|
ClipEncodingProcessor,
|
||||||
|
ObjectTaggingProcessor,
|
||||||
SearchIndexProcessor,
|
SearchIndexProcessor,
|
||||||
StorageTemplateMigrationProcessor,
|
StorageTemplateMigrationProcessor,
|
||||||
ThumbnailGeneratorProcessor,
|
ThumbnailGeneratorProcessor,
|
||||||
|
@ -24,7 +25,8 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
|
||||||
ThumbnailGeneratorProcessor,
|
ThumbnailGeneratorProcessor,
|
||||||
MetadataExtractionProcessor,
|
MetadataExtractionProcessor,
|
||||||
VideoTranscodeProcessor,
|
VideoTranscodeProcessor,
|
||||||
MachineLearningProcessor,
|
ObjectTaggingProcessor,
|
||||||
|
ClipEncodingProcessor,
|
||||||
StorageTemplateMigrationProcessor,
|
StorageTemplateMigrationProcessor,
|
||||||
BackgroundTaskProcessor,
|
BackgroundTaskProcessor,
|
||||||
SearchIndexProcessor,
|
SearchIndexProcessor,
|
||||||
|
|
|
@ -2,6 +2,7 @@ import {
|
||||||
AssetService,
|
AssetService,
|
||||||
IAssetJob,
|
IAssetJob,
|
||||||
IAssetUploadedJob,
|
IAssetUploadedJob,
|
||||||
|
IBaseJob,
|
||||||
IBulkEntityJob,
|
IBulkEntityJob,
|
||||||
IDeleteFilesJob,
|
IDeleteFilesJob,
|
||||||
IUserDeletionJob,
|
IUserDeletionJob,
|
||||||
|
@ -48,20 +49,35 @@ export class BackgroundTaskProcessor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Processor(QueueName.MACHINE_LEARNING)
|
@Processor(QueueName.OBJECT_TAGGING)
|
||||||
export class MachineLearningProcessor {
|
export class ObjectTaggingProcessor {
|
||||||
constructor(private smartInfoService: SmartInfoService) {}
|
constructor(private smartInfoService: SmartInfoService) {}
|
||||||
|
|
||||||
@Process({ name: JobName.IMAGE_TAGGING, concurrency: 1 })
|
@Process({ name: JobName.QUEUE_OBJECT_TAGGING, concurrency: 1 })
|
||||||
async onTagImage(job: Job<IAssetJob>) {
|
async onQueueObjectTagging(job: Job<IBaseJob>) {
|
||||||
await this.smartInfoService.handleTagImage(job.data);
|
await this.smartInfoService.handleQueueObjectTagging(job.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Process({ name: JobName.OBJECT_DETECTION, concurrency: 1 })
|
@Process({ name: JobName.DETECT_OBJECTS, concurrency: 1 })
|
||||||
async onDetectObject(job: Job<IAssetJob>) {
|
async onDetectObjects(job: Job<IAssetJob>) {
|
||||||
await this.smartInfoService.handleDetectObjects(job.data);
|
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 })
|
@Process({ name: JobName.ENCODE_CLIP, concurrency: 1 })
|
||||||
async onEncodeClip(job: Job<IAssetJob>) {
|
async onEncodeClip(job: Job<IAssetJob>) {
|
||||||
await this.smartInfoService.handleEncodeClip(job.data);
|
await this.smartInfoService.handleEncodeClip(job.data);
|
||||||
|
@ -117,6 +133,11 @@ export class StorageTemplateMigrationProcessor {
|
||||||
export class ThumbnailGeneratorProcessor {
|
export class ThumbnailGeneratorProcessor {
|
||||||
constructor(private mediaService: MediaService) {}
|
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 })
|
@Process({ name: JobName.GENERATE_JPEG_THUMBNAIL, concurrency: 3 })
|
||||||
async handleGenerateJpegThumbnail(job: Job<IAssetJob>) {
|
async handleGenerateJpegThumbnail(job: Job<IAssetJob>) {
|
||||||
await this.mediaService.handleGenerateJpegThumbnail(job.data);
|
await this.mediaService.handleGenerateJpegThumbnail(job.data);
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
import {
|
import {
|
||||||
AssetCore,
|
AssetCore,
|
||||||
|
getFileNameWithoutExtension,
|
||||||
IAssetRepository,
|
IAssetRepository,
|
||||||
IAssetUploadedJob,
|
IAssetUploadedJob,
|
||||||
|
IBaseJob,
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
IReverseGeocodingJob,
|
IReverseGeocodingJob,
|
||||||
JobName,
|
JobName,
|
||||||
QueueName,
|
QueueName,
|
||||||
|
WithoutProperty,
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
import { AssetEntity, AssetType, ExifEntity } from '@app/infra';
|
import { AssetEntity, AssetType, ExifEntity } from '@app/infra';
|
||||||
import { Process, Processor } from '@nestjs/bull';
|
import { Process, Processor } from '@nestjs/bull';
|
||||||
|
@ -85,8 +88,8 @@ export class MetadataExtractionProcessor {
|
||||||
private assetCore: AssetCore;
|
private assetCore: AssetCore;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(IAssetRepository) assetRepository: IAssetRepository,
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||||
@Inject(IJobRepository) jobRepository: IJobRepository,
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
|
|
||||||
@InjectRepository(ExifEntity)
|
@InjectRepository(ExifEntity)
|
||||||
private exifRepository: Repository<ExifEntity>,
|
private exifRepository: Repository<ExifEntity>,
|
||||||
|
@ -148,6 +151,24 @@ export class MetadataExtractionProcessor {
|
||||||
return { country, state, city };
|
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)
|
@Process(JobName.EXIF_EXTRACTION)
|
||||||
async extractExifInfo(job: Job<IAssetUploadedJob>) {
|
async extractExifInfo(job: Job<IAssetUploadedJob>) {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -1,6 +1,15 @@
|
||||||
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
||||||
import { AssetEntity } from '@app/infra';
|
import { AssetEntity, AssetType } from '@app/infra';
|
||||||
import { IAssetJob, IAssetRepository, JobName, QueueName, SystemConfigService } from '@app/domain';
|
import {
|
||||||
|
IAssetJob,
|
||||||
|
IAssetRepository,
|
||||||
|
IBaseJob,
|
||||||
|
IJobRepository,
|
||||||
|
JobName,
|
||||||
|
QueueName,
|
||||||
|
SystemConfigService,
|
||||||
|
WithoutProperty,
|
||||||
|
} from '@app/domain';
|
||||||
import { Process, Processor } from '@nestjs/bull';
|
import { Process, Processor } from '@nestjs/bull';
|
||||||
import { Inject, Logger } from '@nestjs/common';
|
import { Inject, Logger } from '@nestjs/common';
|
||||||
import { Job } from 'bull';
|
import { Job } from 'bull';
|
||||||
|
@ -12,11 +21,27 @@ export class VideoTranscodeProcessor {
|
||||||
readonly logger = new Logger(VideoTranscodeProcessor.name);
|
readonly logger = new Logger(VideoTranscodeProcessor.name);
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||||
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
private systemConfigService: SystemConfigService,
|
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 })
|
@Process({ name: JobName.VIDEO_CONVERSION, concurrency: 2 })
|
||||||
async videoConversion(job: Job<IAssetJob>) {
|
async handleVideoConversion(job: Job<IAssetJob>) {
|
||||||
const { asset } = job.data;
|
const { asset } = job.data;
|
||||||
const basePath = APP_UPLOAD_LOCATION;
|
const basePath = APP_UPLOAD_LOCATION;
|
||||||
const encodedVideoPath = `${basePath}/${asset.ownerId}/encoded-video`;
|
const encodedVideoPath = `${basePath}/${asset.ownerId}/encoded-video`;
|
||||||
|
|
|
@ -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": {
|
"/oauth/mobile-redirect": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "mobileRedirect",
|
"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": {
|
"info": {
|
||||||
|
@ -3604,6 +3597,108 @@
|
||||||
"isAutoBackup"
|
"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": {
|
"OAuthConfigDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
@ -5193,92 +5288,6 @@
|
||||||
"usage",
|
"usage",
|
||||||
"usageByUser"
|
"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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,12 @@
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
export * from './upload_location.constant';
|
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_URL = process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003';
|
||||||
export const MACHINE_LEARNING_ENABLED = MACHINE_LEARNING_URL !== 'false';
|
export const MACHINE_LEARNING_ENABLED = MACHINE_LEARNING_URL !== 'false';
|
||||||
|
|
||||||
|
export function assertMachineLearningEnabled() {
|
||||||
|
if (!MACHINE_LEARNING_ENABLED) {
|
||||||
|
throw new BadRequestException('Machine learning is not enabled.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -2,12 +2,22 @@ import { AssetEntity, AssetType } from '@app/infra/db/entities';
|
||||||
|
|
||||||
export interface AssetSearchOptions {
|
export interface AssetSearchOptions {
|
||||||
isVisible?: boolean;
|
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 const IAssetRepository = 'IAssetRepository';
|
||||||
|
|
||||||
export interface IAssetRepository {
|
export interface IAssetRepository {
|
||||||
getByIds(ids: string[]): Promise<AssetEntity[]>;
|
getByIds(ids: string[]): Promise<AssetEntity[]>;
|
||||||
|
getWithout(property: WithoutProperty): Promise<AssetEntity[]>;
|
||||||
deleteAll(ownerId: string): Promise<void>;
|
deleteAll(ownerId: string): Promise<void>;
|
||||||
getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>;
|
getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>;
|
||||||
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
|
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { APIKeyService } from './api-key';
|
||||||
import { AssetService } from './asset';
|
import { AssetService } from './asset';
|
||||||
import { AuthService } from './auth';
|
import { AuthService } from './auth';
|
||||||
import { DeviceInfoService } from './device-info';
|
import { DeviceInfoService } from './device-info';
|
||||||
|
import { JobService } from './job';
|
||||||
import { MediaService } from './media';
|
import { MediaService } from './media';
|
||||||
import { OAuthService } from './oauth';
|
import { OAuthService } from './oauth';
|
||||||
import { SearchService } from './search';
|
import { SearchService } from './search';
|
||||||
|
@ -18,6 +19,7 @@ const providers: Provider[] = [
|
||||||
APIKeyService,
|
APIKeyService,
|
||||||
AuthService,
|
AuthService,
|
||||||
DeviceInfoService,
|
DeviceInfoService,
|
||||||
|
JobService,
|
||||||
MediaService,
|
MediaService,
|
||||||
OAuthService,
|
OAuthService,
|
||||||
SmartInfoService,
|
SmartInfoService,
|
||||||
|
|
|
@ -18,3 +18,4 @@ export * from './system-config';
|
||||||
export * from './tag';
|
export * from './tag';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
export * from './user-token';
|
export * from './user-token';
|
||||||
|
export * from './util';
|
||||||
|
|
2
server/libs/domain/src/job/dto/index.ts
Normal file
2
server/libs/domain/src/job/dto/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './job-command.dto';
|
||||||
|
export * from './job-id.dto';
|
14
server/libs/domain/src/job/dto/job-command.dto.ts
Normal file
14
server/libs/domain/src/job/dto/job-command.dto.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsEnum, IsNotEmpty, IsOptional } from 'class-validator';
|
||||||
|
import { JobCommand } from '../job.constants';
|
||||||
|
|
||||||
|
export class JobCommandDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEnum(JobCommand)
|
||||||
|
@ApiProperty({ type: 'string', enum: JobCommand, enumName: 'JobCommand' })
|
||||||
|
command!: JobCommand;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
force!: boolean;
|
||||||
|
}
|
10
server/libs/domain/src/job/dto/job-id.dto.ts
Normal file
10
server/libs/domain/src/job/dto/job-id.dto.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsNotEmpty } from 'class-validator';
|
||||||
|
import { QueueName } from '../job.constants';
|
||||||
|
|
||||||
|
export class JobIdDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEnum(QueueName)
|
||||||
|
@ApiProperty({ type: String, enum: QueueName, enumName: 'JobName' })
|
||||||
|
jobId!: QueueName;
|
||||||
|
}
|
|
@ -1,3 +1,6 @@
|
||||||
|
export * from './dto';
|
||||||
export * from './job.constants';
|
export * from './job.constants';
|
||||||
export * from './job.interface';
|
export * from './job.interface';
|
||||||
export * from './job.repository';
|
export * from './job.repository';
|
||||||
|
export * from './job.service';
|
||||||
|
export * from './response-dto';
|
||||||
|
|
|
@ -2,32 +2,63 @@ export enum QueueName {
|
||||||
THUMBNAIL_GENERATION = 'thumbnail-generation-queue',
|
THUMBNAIL_GENERATION = 'thumbnail-generation-queue',
|
||||||
METADATA_EXTRACTION = 'metadata-extraction-queue',
|
METADATA_EXTRACTION = 'metadata-extraction-queue',
|
||||||
VIDEO_CONVERSION = 'video-conversion-queue',
|
VIDEO_CONVERSION = 'video-conversion-queue',
|
||||||
MACHINE_LEARNING = 'machine-learning-queue',
|
OBJECT_TAGGING = 'object-tagging-queue',
|
||||||
BACKGROUND_TASK = 'background-task',
|
CLIP_ENCODING = 'clip-encoding-queue',
|
||||||
|
BACKGROUND_TASK = 'background-task-queue',
|
||||||
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue',
|
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue',
|
||||||
SEARCH = 'search-queue',
|
SEARCH = 'search-queue',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum JobCommand {
|
||||||
|
START = 'start',
|
||||||
|
PAUSE = 'pause',
|
||||||
|
EMPTY = 'empty',
|
||||||
|
}
|
||||||
|
|
||||||
export enum JobName {
|
export enum JobName {
|
||||||
|
// upload
|
||||||
ASSET_UPLOADED = 'asset-uploaded',
|
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_JPEG_THUMBNAIL = 'generate-jpeg-thumbnail',
|
||||||
GENERATE_WEBP_THUMBNAIL = 'generate-webp-thumbnail',
|
GENERATE_WEBP_THUMBNAIL = 'generate-webp-thumbnail',
|
||||||
|
|
||||||
|
// metadata
|
||||||
|
QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction',
|
||||||
EXIF_EXTRACTION = 'exif-extraction',
|
EXIF_EXTRACTION = 'exif-extraction',
|
||||||
EXTRACT_VIDEO_METADATA = 'extract-video-metadata',
|
EXTRACT_VIDEO_METADATA = 'extract-video-metadata',
|
||||||
REVERSE_GEOCODING = 'reverse-geocoding',
|
REVERSE_GEOCODING = 'reverse-geocoding',
|
||||||
|
|
||||||
|
// user deletion
|
||||||
USER_DELETION = 'user-deletion',
|
USER_DELETION = 'user-deletion',
|
||||||
USER_DELETE_CHECK = 'user-delete-check',
|
USER_DELETE_CHECK = 'user-delete-check',
|
||||||
|
|
||||||
|
// storage template
|
||||||
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration',
|
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration',
|
||||||
SYSTEM_CONFIG_CHANGE = 'system-config-change',
|
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',
|
DELETE_FILES = 'delete-files',
|
||||||
|
|
||||||
|
// search
|
||||||
SEARCH_INDEX_ASSETS = 'search-index-assets',
|
SEARCH_INDEX_ASSETS = 'search-index-assets',
|
||||||
SEARCH_INDEX_ASSET = 'search-index-asset',
|
SEARCH_INDEX_ASSET = 'search-index-asset',
|
||||||
SEARCH_INDEX_ALBUMS = 'search-index-albums',
|
SEARCH_INDEX_ALBUMS = 'search-index-albums',
|
||||||
SEARCH_INDEX_ALBUM = 'search-index-album',
|
SEARCH_INDEX_ALBUM = 'search-index-album',
|
||||||
SEARCH_REMOVE_ALBUM = 'search-remove-album',
|
SEARCH_REMOVE_ALBUM = 'search-remove-album',
|
||||||
SEARCH_REMOVE_ASSET = 'search-remove-asset',
|
SEARCH_REMOVE_ASSET = 'search-remove-asset',
|
||||||
|
|
||||||
|
// clip
|
||||||
|
QUEUE_ENCODE_CLIP = 'queue-clip-encode',
|
||||||
ENCODE_CLIP = 'clip-encode',
|
ENCODE_CLIP = 'clip-encode',
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,31 +1,35 @@
|
||||||
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/db/entities';
|
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/db/entities';
|
||||||
|
|
||||||
export interface IAlbumJob {
|
export interface IBaseJob {
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAlbumJob extends IBaseJob {
|
||||||
album: AlbumEntity;
|
album: AlbumEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAssetJob {
|
export interface IAssetJob extends IBaseJob {
|
||||||
asset: AssetEntity;
|
asset: AssetEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IBulkEntityJob {
|
export interface IBulkEntityJob extends IBaseJob {
|
||||||
ids: string[];
|
ids: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAssetUploadedJob {
|
export interface IAssetUploadedJob extends IBaseJob {
|
||||||
asset: AssetEntity;
|
asset: AssetEntity;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IDeleteFilesJob {
|
export interface IDeleteFilesJob extends IBaseJob {
|
||||||
files: Array<string | null | undefined>;
|
files: Array<string | null | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IUserDeletionJob {
|
export interface IUserDeletionJob extends IBaseJob {
|
||||||
user: UserEntity;
|
user: UserEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IReverseGeocodingJob {
|
export interface IReverseGeocodingJob extends IBaseJob {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { JobName, QueueName } from './job.constants';
|
||||||
import {
|
import {
|
||||||
IAssetJob,
|
IAssetJob,
|
||||||
IAssetUploadedJob,
|
IAssetUploadedJob,
|
||||||
|
IBaseJob,
|
||||||
IBulkEntityJob,
|
IBulkEntityJob,
|
||||||
IDeleteFilesJob,
|
IDeleteFilesJob,
|
||||||
IReverseGeocodingJob,
|
IReverseGeocodingJob,
|
||||||
|
@ -17,21 +18,45 @@ export interface JobCounts {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type JobItem =
|
export type JobItem =
|
||||||
|
// Asset Upload
|
||||||
| { name: JobName.ASSET_UPLOADED; data: IAssetUploadedJob }
|
| { name: JobName.ASSET_UPLOADED; data: IAssetUploadedJob }
|
||||||
|
|
||||||
|
// Transcoding
|
||||||
|
| { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob }
|
||||||
| { name: JobName.VIDEO_CONVERSION; data: IAssetJob }
|
| { name: JobName.VIDEO_CONVERSION; data: IAssetJob }
|
||||||
|
|
||||||
|
// Thumbnails
|
||||||
|
| { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob }
|
||||||
| { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IAssetJob }
|
| { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IAssetJob }
|
||||||
| { name: JobName.GENERATE_WEBP_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_DELETE_CHECK }
|
||||||
| { name: JobName.USER_DELETION; data: IUserDeletionJob }
|
| { name: JobName.USER_DELETION; data: IUserDeletionJob }
|
||||||
|
|
||||||
|
// Storage Template
|
||||||
| { name: JobName.STORAGE_TEMPLATE_MIGRATION }
|
| { name: JobName.STORAGE_TEMPLATE_MIGRATION }
|
||||||
| { name: JobName.SYSTEM_CONFIG_CHANGE }
|
| { 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.EXTRACT_VIDEO_METADATA; data: IAssetUploadedJob }
|
||||||
| { name: JobName.OBJECT_DETECTION; data: IAssetJob }
|
| { name: JobName.REVERSE_GEOCODING; data: IReverseGeocodingJob }
|
||||||
| { name: JobName.IMAGE_TAGGING; data: IAssetJob }
|
|
||||||
|
// 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 }
|
| { name: JobName.ENCODE_CLIP; data: IAssetJob }
|
||||||
|
|
||||||
|
// Filesystem
|
||||||
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
|
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
|
||||||
|
|
||||||
|
// Search
|
||||||
| { name: JobName.SEARCH_INDEX_ASSETS }
|
| { name: JobName.SEARCH_INDEX_ASSETS }
|
||||||
| { name: JobName.SEARCH_INDEX_ASSET; data: IBulkEntityJob }
|
| { name: JobName.SEARCH_INDEX_ASSET; data: IBulkEntityJob }
|
||||||
| { name: JobName.SEARCH_INDEX_ALBUMS }
|
| { name: JobName.SEARCH_INDEX_ALBUMS }
|
||||||
|
@ -43,6 +68,7 @@ export const IJobRepository = 'IJobRepository';
|
||||||
|
|
||||||
export interface IJobRepository {
|
export interface IJobRepository {
|
||||||
queue(item: JobItem): Promise<void>;
|
queue(item: JobItem): Promise<void>;
|
||||||
|
pause(name: QueueName): Promise<void>;
|
||||||
empty(name: QueueName): Promise<void>;
|
empty(name: QueueName): Promise<void>;
|
||||||
isActive(name: QueueName): Promise<boolean>;
|
isActive(name: QueueName): Promise<boolean>;
|
||||||
getJobCounts(name: QueueName): Promise<JobCounts>;
|
getJobCounts(name: QueueName): Promise<JobCounts>;
|
||||||
|
|
170
server/libs/domain/src/job/job.service.spec.ts
Normal file
170
server/libs/domain/src/job/job.service.spec.ts
Normal file
|
@ -0,0 +1,170 @@
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { newJobRepositoryMock } from '../../test';
|
||||||
|
import { IJobRepository, JobCommand, JobName, JobService, QueueName } from '../job';
|
||||||
|
|
||||||
|
describe(JobService.name, () => {
|
||||||
|
let sut: JobService;
|
||||||
|
let jobMock: jest.Mocked<IJobRepository>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jobMock = newJobRepositoryMock();
|
||||||
|
sut = new JobService(jobMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work', () => {
|
||||||
|
expect(sut).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllJobStatus', () => {
|
||||||
|
it('should get all job statuses', async () => {
|
||||||
|
jobMock.getJobCounts.mockResolvedValue({
|
||||||
|
active: 1,
|
||||||
|
completed: 1,
|
||||||
|
failed: 1,
|
||||||
|
delayed: 1,
|
||||||
|
waiting: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(sut.getAllJobsStatus()).resolves.toEqual({
|
||||||
|
'background-task-queue': {
|
||||||
|
active: 1,
|
||||||
|
completed: 1,
|
||||||
|
delayed: 1,
|
||||||
|
failed: 1,
|
||||||
|
waiting: 1,
|
||||||
|
},
|
||||||
|
'clip-encoding-queue': {
|
||||||
|
active: 1,
|
||||||
|
completed: 1,
|
||||||
|
delayed: 1,
|
||||||
|
failed: 1,
|
||||||
|
waiting: 1,
|
||||||
|
},
|
||||||
|
'metadata-extraction-queue': {
|
||||||
|
active: 1,
|
||||||
|
completed: 1,
|
||||||
|
delayed: 1,
|
||||||
|
failed: 1,
|
||||||
|
waiting: 1,
|
||||||
|
},
|
||||||
|
'object-tagging-queue': {
|
||||||
|
active: 1,
|
||||||
|
completed: 1,
|
||||||
|
delayed: 1,
|
||||||
|
failed: 1,
|
||||||
|
waiting: 1,
|
||||||
|
},
|
||||||
|
'search-queue': {
|
||||||
|
active: 1,
|
||||||
|
completed: 1,
|
||||||
|
delayed: 1,
|
||||||
|
failed: 1,
|
||||||
|
waiting: 1,
|
||||||
|
},
|
||||||
|
'storage-template-migration-queue': {
|
||||||
|
active: 1,
|
||||||
|
completed: 1,
|
||||||
|
delayed: 1,
|
||||||
|
failed: 1,
|
||||||
|
waiting: 1,
|
||||||
|
},
|
||||||
|
'thumbnail-generation-queue': {
|
||||||
|
active: 1,
|
||||||
|
completed: 1,
|
||||||
|
delayed: 1,
|
||||||
|
failed: 1,
|
||||||
|
waiting: 1,
|
||||||
|
},
|
||||||
|
'video-conversion-queue': {
|
||||||
|
active: 1,
|
||||||
|
completed: 1,
|
||||||
|
delayed: 1,
|
||||||
|
failed: 1,
|
||||||
|
waiting: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleCommand', () => {
|
||||||
|
it('should handle a pause command', async () => {
|
||||||
|
await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.PAUSE, force: false });
|
||||||
|
|
||||||
|
expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle an empty command', async () => {
|
||||||
|
await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.EMPTY, force: false });
|
||||||
|
|
||||||
|
expect(jobMock.empty).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not start a job that is already running', async () => {
|
||||||
|
jobMock.isActive.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
|
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a start video conversion command', async () => {
|
||||||
|
jobMock.isActive.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false });
|
||||||
|
|
||||||
|
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force: false } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a start storage template migration command', async () => {
|
||||||
|
jobMock.isActive.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await sut.handleCommand(QueueName.STORAGE_TEMPLATE_MIGRATION, { command: JobCommand.START, force: false });
|
||||||
|
|
||||||
|
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.STORAGE_TEMPLATE_MIGRATION });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a start object tagging command', async () => {
|
||||||
|
jobMock.isActive.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await sut.handleCommand(QueueName.OBJECT_TAGGING, { command: JobCommand.START, force: false });
|
||||||
|
|
||||||
|
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_OBJECT_TAGGING, data: { force: false } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a start clip encoding command', async () => {
|
||||||
|
jobMock.isActive.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await sut.handleCommand(QueueName.CLIP_ENCODING, { command: JobCommand.START, force: false });
|
||||||
|
|
||||||
|
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_ENCODE_CLIP, data: { force: false } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a start metadata extraction command', async () => {
|
||||||
|
jobMock.isActive.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.START, force: false });
|
||||||
|
|
||||||
|
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force: false } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a start thumbnail generation command', async () => {
|
||||||
|
jobMock.isActive.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await sut.handleCommand(QueueName.THUMBNAIL_GENERATION, { command: JobCommand.START, force: false });
|
||||||
|
|
||||||
|
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw a bad request when an invalid queue is used', async () => {
|
||||||
|
jobMock.isActive.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.handleCommand(QueueName.BACKGROUND_TASK, { command: JobCommand.START, force: false }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
|
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
68
server/libs/domain/src/job/job.service.ts
Normal file
68
server/libs/domain/src/job/job.service.ts
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import { assertMachineLearningEnabled } from '@app/common';
|
||||||
|
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { JobCommandDto } from './dto';
|
||||||
|
import { JobCommand, JobName, QueueName } from './job.constants';
|
||||||
|
import { IJobRepository } from './job.repository';
|
||||||
|
import { AllJobStatusResponseDto } from './response-dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JobService {
|
||||||
|
private logger = new Logger(JobService.name);
|
||||||
|
|
||||||
|
constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {}
|
||||||
|
|
||||||
|
handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> {
|
||||||
|
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
|
||||||
|
|
||||||
|
switch (dto.command) {
|
||||||
|
case JobCommand.START:
|
||||||
|
return this.start(queueName, dto);
|
||||||
|
|
||||||
|
case JobCommand.PAUSE:
|
||||||
|
return this.jobRepository.pause(queueName);
|
||||||
|
|
||||||
|
case JobCommand.EMPTY:
|
||||||
|
return this.jobRepository.empty(queueName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
|
||||||
|
const response = new AllJobStatusResponseDto();
|
||||||
|
for (const queueName of Object.values(QueueName)) {
|
||||||
|
response[queueName] = await this.jobRepository.getJobCounts(queueName);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async start(name: QueueName, { force }: JobCommandDto): Promise<void> {
|
||||||
|
const isActive = await this.jobRepository.isActive(name);
|
||||||
|
if (isActive) {
|
||||||
|
throw new BadRequestException(`Job is already running`);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case QueueName.VIDEO_CONVERSION:
|
||||||
|
return this.jobRepository.queue({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force } });
|
||||||
|
|
||||||
|
case QueueName.STORAGE_TEMPLATE_MIGRATION:
|
||||||
|
return this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION });
|
||||||
|
|
||||||
|
case QueueName.OBJECT_TAGGING:
|
||||||
|
assertMachineLearningEnabled();
|
||||||
|
return this.jobRepository.queue({ name: JobName.QUEUE_OBJECT_TAGGING, data: { force } });
|
||||||
|
|
||||||
|
case QueueName.CLIP_ENCODING:
|
||||||
|
assertMachineLearningEnabled();
|
||||||
|
return this.jobRepository.queue({ name: JobName.QUEUE_ENCODE_CLIP, data: { force } });
|
||||||
|
|
||||||
|
case QueueName.METADATA_EXTRACTION:
|
||||||
|
return this.jobRepository.queue({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force } });
|
||||||
|
|
||||||
|
case QueueName.THUMBNAIL_GENERATION:
|
||||||
|
return this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force } });
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new BadRequestException(`Invalid job name: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { QueueName } from '../job.constants';
|
||||||
|
|
||||||
|
export class JobCountsDto {
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
active!: number;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
completed!: number;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
failed!: number;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
delayed!: number;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
waiting!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AllJobStatusResponseDto implements Record<QueueName, JobCountsDto> {
|
||||||
|
@ApiProperty({ type: JobCountsDto })
|
||||||
|
[QueueName.THUMBNAIL_GENERATION]!: JobCountsDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: JobCountsDto })
|
||||||
|
[QueueName.METADATA_EXTRACTION]!: JobCountsDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: JobCountsDto })
|
||||||
|
[QueueName.VIDEO_CONVERSION]!: JobCountsDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: JobCountsDto })
|
||||||
|
[QueueName.OBJECT_TAGGING]!: JobCountsDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: JobCountsDto })
|
||||||
|
[QueueName.CLIP_ENCODING]!: JobCountsDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: JobCountsDto })
|
||||||
|
[QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobCountsDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: JobCountsDto })
|
||||||
|
[QueueName.BACKGROUND_TASK]!: JobCountsDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: JobCountsDto })
|
||||||
|
[QueueName.SEARCH]!: JobCountsDto;
|
||||||
|
}
|
1
server/libs/domain/src/job/response-dto/index.ts
Normal file
1
server/libs/domain/src/job/response-dto/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './all-job-status-response.dto';
|
|
@ -3,9 +3,9 @@ import { AssetType } from '@app/infra/db/entities';
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import sanitize from 'sanitize-filename';
|
import sanitize from 'sanitize-filename';
|
||||||
import { IAssetRepository, mapAsset } from '../asset';
|
import { IAssetRepository, mapAsset, WithoutProperty } from '../asset';
|
||||||
import { CommunicationEvent, ICommunicationRepository } from '../communication';
|
import { CommunicationEvent, ICommunicationRepository } from '../communication';
|
||||||
import { IAssetJob, IJobRepository, JobName } from '../job';
|
import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job';
|
||||||
import { IStorageRepository } from '../storage';
|
import { IStorageRepository } from '../storage';
|
||||||
import { IMediaRepository } from './media.repository';
|
import { IMediaRepository } from './media.repository';
|
||||||
|
|
||||||
|
@ -21,6 +21,22 @@ export class MediaService {
|
||||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
@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> {
|
async handleGenerateJpegThumbnail(data: IAssetJob): Promise<void> {
|
||||||
const { asset } = data;
|
const { asset } = data;
|
||||||
|
|
||||||
|
@ -52,8 +68,8 @@ export class MediaService {
|
||||||
asset.resizePath = jpegThumbnailPath;
|
asset.resizePath = jpegThumbnailPath;
|
||||||
|
|
||||||
await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } });
|
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.CLASSIFY_IMAGE, data: { asset } });
|
||||||
await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, data: { asset } });
|
await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } });
|
||||||
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
|
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
|
||||||
|
|
||||||
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
|
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
|
||||||
|
@ -71,8 +87,8 @@ export class MediaService {
|
||||||
asset.resizePath = jpegThumbnailPath;
|
asset.resizePath = jpegThumbnailPath;
|
||||||
|
|
||||||
await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } });
|
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.CLASSIFY_IMAGE, data: { asset } });
|
||||||
await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, data: { asset } });
|
await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } });
|
||||||
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
|
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
|
||||||
|
|
||||||
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
|
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
|
||||||
|
|
|
@ -5,7 +5,7 @@ export interface MachineLearningInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IMachineLearningRepository {
|
export interface IMachineLearningRepository {
|
||||||
tagImage(input: MachineLearningInput): Promise<string[]>;
|
classifyImage(input: MachineLearningInput): Promise<string[]>;
|
||||||
detectObjects(input: MachineLearningInput): Promise<string[]>;
|
detectObjects(input: MachineLearningInput): Promise<string[]>;
|
||||||
encodeImage(input: MachineLearningInput): Promise<number[]>;
|
encodeImage(input: MachineLearningInput): Promise<number[]>;
|
||||||
encodeText(input: string): Promise<number[]>;
|
encodeText(input: string): Promise<number[]>;
|
||||||
|
|
|
@ -1,6 +1,13 @@
|
||||||
import { AssetEntity } from '@app/infra/db/entities';
|
import { AssetEntity } from '@app/infra/db/entities';
|
||||||
import { newJobRepositoryMock, newMachineLearningRepositoryMock, newSmartInfoRepositoryMock } from '../../test';
|
import {
|
||||||
import { IJobRepository } from '../job';
|
assetEntityStub,
|
||||||
|
newAssetRepositoryMock,
|
||||||
|
newJobRepositoryMock,
|
||||||
|
newMachineLearningRepositoryMock,
|
||||||
|
newSmartInfoRepositoryMock,
|
||||||
|
} from '../../test';
|
||||||
|
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||||
|
import { IJobRepository, JobName } from '../job';
|
||||||
import { IMachineLearningRepository } from './machine-learning.interface';
|
import { IMachineLearningRepository } from './machine-learning.interface';
|
||||||
import { ISmartInfoRepository } from './smart-info.repository';
|
import { ISmartInfoRepository } from './smart-info.repository';
|
||||||
import { SmartInfoService } from './smart-info.service';
|
import { SmartInfoService } from './smart-info.service';
|
||||||
|
@ -12,35 +19,63 @@ const asset = {
|
||||||
|
|
||||||
describe(SmartInfoService.name, () => {
|
describe(SmartInfoService.name, () => {
|
||||||
let sut: SmartInfoService;
|
let sut: SmartInfoService;
|
||||||
|
let assetMock: jest.Mocked<IAssetRepository>;
|
||||||
let jobMock: jest.Mocked<IJobRepository>;
|
let jobMock: jest.Mocked<IJobRepository>;
|
||||||
let smartMock: jest.Mocked<ISmartInfoRepository>;
|
let smartMock: jest.Mocked<ISmartInfoRepository>;
|
||||||
let machineMock: jest.Mocked<IMachineLearningRepository>;
|
let machineMock: jest.Mocked<IMachineLearningRepository>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
assetMock = newAssetRepositoryMock();
|
||||||
smartMock = newSmartInfoRepositoryMock();
|
smartMock = newSmartInfoRepositoryMock();
|
||||||
jobMock = newJobRepositoryMock();
|
jobMock = newJobRepositoryMock();
|
||||||
machineMock = newMachineLearningRepositoryMock();
|
machineMock = newMachineLearningRepositoryMock();
|
||||||
sut = new SmartInfoService(jobMock, smartMock, machineMock);
|
sut = new SmartInfoService(assetMock, jobMock, smartMock, machineMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work', () => {
|
it('should work', () => {
|
||||||
expect(sut).toBeDefined();
|
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', () => {
|
describe('handleTagImage', () => {
|
||||||
it('should skip assets without a resize path', async () => {
|
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(smartMock.upsert).not.toHaveBeenCalled();
|
||||||
expect(machineMock.tagImage).not.toHaveBeenCalled();
|
expect(machineMock.classifyImage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should save the returned tags', async () => {
|
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({
|
expect(smartMock.upsert).toHaveBeenCalledWith({
|
||||||
assetId: 'asset-1',
|
assetId: 'asset-1',
|
||||||
tags: ['tag1', 'tag2', 'tag3'],
|
tags: ['tag1', 'tag2', 'tag3'],
|
||||||
|
@ -48,19 +83,19 @@ describe(SmartInfoService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle an error with the machine learning pipeline', async () => {
|
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();
|
expect(smartMock.upsert).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should no update the smart info if no tags were returned', async () => {
|
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();
|
expect(smartMock.upsert).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -102,4 +137,53 @@ describe(SmartInfoService.name, () => {
|
||||||
expect(smartMock.upsert).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { MACHINE_LEARNING_ENABLED } from '@app/common';
|
import { MACHINE_LEARNING_ENABLED } from '@app/common';
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/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 { IMachineLearningRepository } from './machine-learning.interface';
|
||||||
import { ISmartInfoRepository } from './smart-info.repository';
|
import { ISmartInfoRepository } from './smart-info.repository';
|
||||||
|
|
||||||
|
@ -9,26 +10,24 @@ export class SmartInfoService {
|
||||||
private logger = new Logger(SmartInfoService.name);
|
private logger = new Logger(SmartInfoService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
@Inject(ISmartInfoRepository) private repository: ISmartInfoRepository,
|
@Inject(ISmartInfoRepository) private repository: ISmartInfoRepository,
|
||||||
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
|
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handleTagImage(data: IAssetJob) {
|
async handleQueueObjectTagging({ force }: IBaseJob) {
|
||||||
const { asset } = data;
|
|
||||||
|
|
||||||
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tags = await this.machineLearning.tagImage({ thumbnailPath: asset.resizePath });
|
const assets = force
|
||||||
if (tags.length > 0) {
|
? await this.assetRepository.getAll()
|
||||||
await this.repository.upsert({ assetId: asset.id, tags });
|
: await this.assetRepository.getWithout(WithoutProperty.OBJECT_TAGS);
|
||||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
|
|
||||||
|
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) {
|
} 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) {
|
async handleEncodeClip(data: IAssetJob) {
|
||||||
const { asset } = data;
|
const { asset } = data;
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { IAssetRepository } from '../src';
|
||||||
export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
|
export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
|
||||||
return {
|
return {
|
||||||
getByIds: jest.fn(),
|
getByIds: jest.fn(),
|
||||||
|
getWithout: jest.fn(),
|
||||||
getAll: jest.fn(),
|
getAll: jest.fn(),
|
||||||
deleteAll: jest.fn(),
|
deleteAll: jest.fn(),
|
||||||
save: jest.fn(),
|
save: jest.fn(),
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { IJobRepository } from '../src';
|
||||||
export const newJobRepositoryMock = (): jest.Mocked<IJobRepository> => {
|
export const newJobRepositoryMock = (): jest.Mocked<IJobRepository> => {
|
||||||
return {
|
return {
|
||||||
empty: jest.fn(),
|
empty: jest.fn(),
|
||||||
|
pause: jest.fn(),
|
||||||
queue: jest.fn().mockImplementation(() => Promise.resolve()),
|
queue: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||||
isActive: jest.fn(),
|
isActive: jest.fn(),
|
||||||
getJobCounts: jest.fn(),
|
getJobCounts: jest.fn(),
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { IMachineLearningRepository } from '../src';
|
||||||
|
|
||||||
export const newMachineLearningRepositoryMock = (): jest.Mocked<IMachineLearningRepository> => {
|
export const newMachineLearningRepositoryMock = (): jest.Mocked<IMachineLearningRepository> => {
|
||||||
return {
|
return {
|
||||||
tagImage: jest.fn(),
|
classifyImage: jest.fn(),
|
||||||
detectObjects: jest.fn(),
|
detectObjects: jest.fn(),
|
||||||
encodeImage: jest.fn(),
|
encodeImage: jest.fn(),
|
||||||
encodeText: jest.fn(),
|
encodeText: jest.fn(),
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { AssetSearchOptions, IAssetRepository } from '@app/domain';
|
import { AssetSearchOptions, IAssetRepository, WithoutProperty } from '@app/domain';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
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';
|
import { AssetEntity, AssetType } from '../entities';
|
||||||
|
|
||||||
@Injectable()
|
@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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { InjectQueue } from '@nestjs/bull';
|
||||||
import { BadRequestException, Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Queue } from 'bull';
|
import { Queue } from 'bull';
|
||||||
|
|
||||||
export class JobRepository implements IJobRepository {
|
export class JobRepository implements IJobRepository {
|
||||||
private logger = new Logger(JobRepository.name);
|
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(
|
constructor(
|
||||||
@InjectQueue(QueueName.BACKGROUND_TASK) private backgroundTask: Queue,
|
@InjectQueue(QueueName.BACKGROUND_TASK) private backgroundTask: Queue,
|
||||||
@InjectQueue(QueueName.MACHINE_LEARNING) private machineLearning: Queue<IAssetJob>,
|
@InjectQueue(QueueName.OBJECT_TAGGING) private objectTagging: Queue<IAssetJob | IBaseJob>,
|
||||||
@InjectQueue(QueueName.METADATA_EXTRACTION) private metadataExtraction: Queue<IMetadataExtractionJob>,
|
@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.STORAGE_TEMPLATE_MIGRATION) private storageTemplateMigration: Queue,
|
||||||
@InjectQueue(QueueName.THUMBNAIL_GENERATION) private thumbnail: Queue,
|
@InjectQueue(QueueName.THUMBNAIL_GENERATION) private generateThumbnail: Queue,
|
||||||
@InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue<IAssetJob>,
|
@InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue<IAssetJob | IBaseJob>,
|
||||||
@InjectQueue(QueueName.SEARCH) private searchIndex: Queue,
|
@InjectQueue(QueueName.SEARCH) private searchIndex: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@ -21,12 +41,16 @@ export class JobRepository implements IJobRepository {
|
||||||
return !!counts.active;
|
return !!counts.active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pause(name: QueueName) {
|
||||||
|
return this.queueMap[name].pause();
|
||||||
|
}
|
||||||
|
|
||||||
empty(name: QueueName) {
|
empty(name: QueueName) {
|
||||||
return this.getQueue(name).empty();
|
return this.queueMap[name].empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
getJobCounts(name: QueueName): Promise<JobCounts> {
|
getJobCounts(name: QueueName): Promise<JobCounts> {
|
||||||
return this.getQueue(name).getJobCounts();
|
return this.queueMap[name].getJobCounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
async queue(item: JobItem): Promise<void> {
|
async queue(item: JobItem): Promise<void> {
|
||||||
|
@ -39,21 +63,28 @@ export class JobRepository implements IJobRepository {
|
||||||
await this.backgroundTask.add(item.name, item.data);
|
await this.backgroundTask.add(item.name, item.data);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case JobName.OBJECT_DETECTION:
|
case JobName.QUEUE_OBJECT_TAGGING:
|
||||||
case JobName.IMAGE_TAGGING:
|
case JobName.DETECT_OBJECTS:
|
||||||
case JobName.ENCODE_CLIP:
|
case JobName.CLASSIFY_IMAGE:
|
||||||
await this.machineLearning.add(item.name, item.data);
|
await this.objectTagging.add(item.name, item.data);
|
||||||
break;
|
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.EXIF_EXTRACTION:
|
||||||
case JobName.EXTRACT_VIDEO_METADATA:
|
case JobName.EXTRACT_VIDEO_METADATA:
|
||||||
case JobName.REVERSE_GEOCODING:
|
case JobName.REVERSE_GEOCODING:
|
||||||
await this.metadataExtraction.add(item.name, item.data);
|
await this.metadataExtraction.add(item.name, item.data);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case JobName.QUEUE_GENERATE_THUMBNAILS:
|
||||||
case JobName.GENERATE_JPEG_THUMBNAIL:
|
case JobName.GENERATE_JPEG_THUMBNAIL:
|
||||||
case JobName.GENERATE_WEBP_THUMBNAIL:
|
case JobName.GENERATE_WEBP_THUMBNAIL:
|
||||||
await this.thumbnail.add(item.name, item.data);
|
await this.generateThumbnail.add(item.name, item.data);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case JobName.USER_DELETION:
|
case JobName.USER_DELETION:
|
||||||
|
@ -68,6 +99,7 @@ export class JobRepository implements IJobRepository {
|
||||||
await this.backgroundTask.add(item.name, {});
|
await this.backgroundTask.add(item.name, {});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case JobName.QUEUE_VIDEO_CONVERSION:
|
||||||
case JobName.VIDEO_CONVERSION:
|
case JobName.VIDEO_CONVERSION:
|
||||||
await this.videoTranscode.add(item.name, item.data);
|
await this.videoTranscode.add(item.name, item.data);
|
||||||
break;
|
break;
|
||||||
|
@ -85,25 +117,7 @@ export class JobRepository implements IJobRepository {
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// TODO inject remaining queues and map job to queue
|
|
||||||
this.logger.error('Invalid job', item);
|
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ const client = axios.create({ baseURL: MACHINE_LEARNING_URL });
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MachineLearningRepository implements IMachineLearningRepository {
|
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);
|
return client.post<string[]>('/image-classifier/tag-image', input).then((res) => res.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
90
web/src/api/open-api/api.ts
generated
90
web/src/api/open-api/api.ts
generated
|
@ -291,34 +291,52 @@ export interface AlbumResponseDto {
|
||||||
export interface AllJobStatusResponseDto {
|
export interface AllJobStatusResponseDto {
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {JobCounts}
|
* @type {JobCountsDto}
|
||||||
* @memberof AllJobStatusResponseDto
|
* @memberof AllJobStatusResponseDto
|
||||||
*/
|
*/
|
||||||
'thumbnail-generation': JobCounts;
|
'thumbnail-generation-queue': JobCountsDto;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {JobCounts}
|
* @type {JobCountsDto}
|
||||||
* @memberof AllJobStatusResponseDto
|
* @memberof AllJobStatusResponseDto
|
||||||
*/
|
*/
|
||||||
'metadata-extraction': JobCounts;
|
'metadata-extraction-queue': JobCountsDto;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {JobCounts}
|
* @type {JobCountsDto}
|
||||||
* @memberof AllJobStatusResponseDto
|
* @memberof AllJobStatusResponseDto
|
||||||
*/
|
*/
|
||||||
'video-conversion': JobCounts;
|
'video-conversion-queue': JobCountsDto;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {JobCounts}
|
* @type {JobCountsDto}
|
||||||
* @memberof AllJobStatusResponseDto
|
* @memberof AllJobStatusResponseDto
|
||||||
*/
|
*/
|
||||||
'machine-learning': JobCounts;
|
'object-tagging-queue': JobCountsDto;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {JobCounts}
|
* @type {JobCountsDto}
|
||||||
* @memberof AllJobStatusResponseDto
|
* @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 = {
|
export const JobCommand = {
|
||||||
Start: 'start',
|
Start: 'start',
|
||||||
Stop: 'stop'
|
Pause: 'pause',
|
||||||
|
Empty: 'empty'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type JobCommand = typeof JobCommand[keyof typeof JobCommand];
|
export type JobCommand = typeof JobCommand[keyof typeof JobCommand];
|
||||||
|
@ -1226,42 +1245,42 @@ export interface JobCommandDto {
|
||||||
* @type {boolean}
|
* @type {boolean}
|
||||||
* @memberof JobCommandDto
|
* @memberof JobCommandDto
|
||||||
*/
|
*/
|
||||||
'includeAllAssets': boolean;
|
'force': boolean;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @export
|
* @export
|
||||||
* @interface JobCounts
|
* @interface JobCountsDto
|
||||||
*/
|
*/
|
||||||
export interface JobCounts {
|
export interface JobCountsDto {
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {number}
|
* @type {number}
|
||||||
* @memberof JobCounts
|
* @memberof JobCountsDto
|
||||||
*/
|
*/
|
||||||
'active': number;
|
'active': number;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {number}
|
* @type {number}
|
||||||
* @memberof JobCounts
|
* @memberof JobCountsDto
|
||||||
*/
|
*/
|
||||||
'completed': number;
|
'completed': number;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {number}
|
* @type {number}
|
||||||
* @memberof JobCounts
|
* @memberof JobCountsDto
|
||||||
*/
|
*/
|
||||||
'failed': number;
|
'failed': number;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {number}
|
* @type {number}
|
||||||
* @memberof JobCounts
|
* @memberof JobCountsDto
|
||||||
*/
|
*/
|
||||||
'delayed': number;
|
'delayed': number;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {number}
|
* @type {number}
|
||||||
* @memberof JobCounts
|
* @memberof JobCountsDto
|
||||||
*/
|
*/
|
||||||
'waiting': number;
|
'waiting': number;
|
||||||
}
|
}
|
||||||
|
@ -1271,15 +1290,18 @@ export interface JobCounts {
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const JobId = {
|
export const JobName = {
|
||||||
ThumbnailGeneration: 'thumbnail-generation',
|
ThumbnailGenerationQueue: 'thumbnail-generation-queue',
|
||||||
MetadataExtraction: 'metadata-extraction',
|
MetadataExtractionQueue: 'metadata-extraction-queue',
|
||||||
VideoConversion: 'video-conversion',
|
VideoConversionQueue: 'video-conversion-queue',
|
||||||
MachineLearning: 'machine-learning',
|
ObjectTaggingQueue: 'object-tagging-queue',
|
||||||
StorageTemplateMigration: 'storage-template-migration'
|
ClipEncodingQueue: 'clip-encoding-queue',
|
||||||
|
BackgroundTaskQueue: 'background-task-queue',
|
||||||
|
StorageTemplateMigrationQueue: 'storage-template-migration-queue',
|
||||||
|
SearchQueue: 'search-queue'
|
||||||
} as const;
|
} 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 {JobCommandDto} jobCommandDto
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @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
|
// verify required parameter 'jobId' is not null or undefined
|
||||||
assertParamExists('sendJobCommand', 'jobId', jobId)
|
assertParamExists('sendJobCommand', 'jobId', jobId)
|
||||||
// verify required parameter 'jobCommandDto' is not null or undefined
|
// 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 {JobCommandDto} jobCommandDto
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @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);
|
const localVarAxiosArgs = await localVarAxiosParamCreator.sendJobCommand(jobId, jobCommandDto, options);
|
||||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
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 {JobCommandDto} jobCommandDto
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @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));
|
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 {JobCommandDto} jobCommandDto
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @throws {RequiredError}
|
||||||
* @memberof JobApi
|
* @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));
|
return JobApiFp(this.configuration).sendJobCommand(jobId, jobCommandDto, options).then((request) => request(this.axios, this.basePath));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,11 +5,11 @@
|
||||||
import AllInclusive from 'svelte-material-icons/AllInclusive.svelte';
|
import AllInclusive from 'svelte-material-icons/AllInclusive.svelte';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { JobCounts } from '@api';
|
import { JobCountsDto } from '@api';
|
||||||
|
|
||||||
export let title: string;
|
export let title: string;
|
||||||
export let subtitle: 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
|
* Show options to run job on all assets of just missing ones
|
||||||
*/
|
*/
|
||||||
|
@ -19,8 +19,8 @@
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
const run = (includeAllAssets: boolean) => {
|
const run = (force: boolean) => {
|
||||||
dispatch('click', { includeAllAssets });
|
dispatch('click', { force });
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
NotificationType
|
NotificationType
|
||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
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 { onDestroy, onMount } from 'svelte';
|
||||||
import JobTile from './job-tile.svelte';
|
import JobTile from './job-tile.svelte';
|
||||||
|
|
||||||
|
@ -18,35 +18,42 @@
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await load();
|
await load();
|
||||||
timer = setInterval(async () => await load(), 1_000);
|
timer = setInterval(async () => await load(), 5_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
});
|
});
|
||||||
|
|
||||||
const run = async (
|
function getJobLabel(jobName: JobName) {
|
||||||
jobId: JobId,
|
const names: Record<JobName, string> = {
|
||||||
jobName: string,
|
[JobName.ThumbnailGenerationQueue]: 'Generate Thumbnails',
|
||||||
emptyMessage: string,
|
[JobName.MetadataExtractionQueue]: 'Extract Metadata',
|
||||||
includeAllAssets: boolean
|
[JobName.VideoConversionQueue]: 'Transcode Videos',
|
||||||
) => {
|
[JobName.ObjectTaggingQueue]: 'Tag Objects',
|
||||||
try {
|
[JobName.ClipEncodingQueue]: 'Clip Encoding',
|
||||||
const { data } = await api.jobApi.sendJobCommand(jobId, {
|
[JobName.BackgroundTaskQueue]: 'Background Task',
|
||||||
command: JobCommand.Start,
|
[JobName.StorageTemplateMigrationQueue]: 'Storage Template Migration',
|
||||||
includeAllAssets
|
[JobName.SearchQueue]: 'Search'
|
||||||
});
|
};
|
||||||
|
|
||||||
if (data) {
|
return names[jobName];
|
||||||
notificationController.show({
|
}
|
||||||
message: includeAllAssets ? `Started ${jobName} for all assets` : `Started ${jobName}`,
|
|
||||||
type: NotificationType.Info
|
const start = async (jobId: JobName, force: boolean) => {
|
||||||
});
|
const label = getJobLabel(jobId);
|
||||||
} else {
|
|
||||||
notificationController.show({ message: emptyMessage, type: NotificationType.Info });
|
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) {
|
} catch (error) {
|
||||||
handleError(error, `Unable to start ${jobName}`);
|
handleError(error, `Unable to start job: ${label}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -54,76 +61,48 @@
|
||||||
<div class="flex flex-col gap-7">
|
<div class="flex flex-col gap-7">
|
||||||
{#if jobs}
|
{#if jobs}
|
||||||
<JobTile
|
<JobTile
|
||||||
title={'Generate thumbnails'}
|
title="Generate thumbnails"
|
||||||
subtitle={'Regenerate JPEG and WebP thumbnails'}
|
subtitle="Regenerate JPEG and WebP thumbnails"
|
||||||
on:click={(e) => {
|
on:click={(e) => start(JobName.ThumbnailGenerationQueue, e.detail.force)}
|
||||||
const { includeAllAssets } = e.detail;
|
jobCounts={jobs[JobName.ThumbnailGenerationQueue]}
|
||||||
|
|
||||||
run(
|
|
||||||
JobId.ThumbnailGeneration,
|
|
||||||
'thumbnail generation',
|
|
||||||
'No missing thumbnails found',
|
|
||||||
includeAllAssets
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
jobCounts={jobs[JobId.ThumbnailGeneration]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<JobTile
|
<JobTile
|
||||||
title={'EXTRACT METADATA'}
|
title="Extract Metadata"
|
||||||
subtitle={'Extract metadata information i.e. GPS, resolution...etc'}
|
subtitle="Extract metadata information i.e. GPS, resolution...etc"
|
||||||
on:click={(e) => {
|
on:click={(e) => start(JobName.MetadataExtractionQueue, e.detail.force)}
|
||||||
const { includeAllAssets } = e.detail;
|
jobCounts={jobs[JobName.MetadataExtractionQueue]}
|
||||||
run(JobId.MetadataExtraction, 'extract EXIF', 'No missing EXIF found', includeAllAssets);
|
|
||||||
}}
|
|
||||||
jobCounts={jobs[JobId.MetadataExtraction]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<JobTile
|
<JobTile
|
||||||
title={'Detect objects'}
|
title="Tag Objects"
|
||||||
subtitle={'Run machine learning process to detect and classify objects'}
|
subtitle="Run machine learning to tag objects"
|
||||||
on:click={(e) => {
|
on:click={(e) => start(JobName.ObjectTaggingQueue, e.detail.force)}
|
||||||
const { includeAllAssets } = e.detail;
|
jobCounts={jobs[JobName.ObjectTaggingQueue]}
|
||||||
|
|
||||||
run(
|
|
||||||
JobId.MachineLearning,
|
|
||||||
'object detection',
|
|
||||||
'No missing object detection found',
|
|
||||||
includeAllAssets
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
jobCounts={jobs[JobId.MachineLearning]}
|
|
||||||
>
|
>
|
||||||
Note that some assets may not have any objects detected
|
Note that some assets may not have any objects detected
|
||||||
</JobTile>
|
</JobTile>
|
||||||
|
|
||||||
<JobTile
|
<JobTile
|
||||||
title={'Video transcoding'}
|
title="Encode Clip"
|
||||||
subtitle={'Transcode videos not in the desired format'}
|
subtitle="Run machine learning to generate clip embeddings"
|
||||||
on:click={(e) => {
|
on:click={(e) => start(JobName.ClipEncodingQueue, e.detail.force)}
|
||||||
const { includeAllAssets } = e.detail;
|
jobCounts={jobs[JobName.ClipEncodingQueue]}
|
||||||
run(
|
|
||||||
JobId.VideoConversion,
|
|
||||||
'video conversion',
|
|
||||||
'No videos without an encoded version found',
|
|
||||||
includeAllAssets
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
jobCounts={jobs[JobId.VideoConversion]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<JobTile
|
<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}
|
showOptions={false}
|
||||||
subtitle={''}
|
subtitle={''}
|
||||||
on:click={() =>
|
on:click={(e) => start(JobName.StorageTemplateMigrationQueue, e.detail.force)}
|
||||||
run(
|
jobCounts={jobs[JobName.StorageTemplateMigrationQueue]}
|
||||||
JobId.StorageTemplateMigration,
|
|
||||||
'storage template migration',
|
|
||||||
'All files have been migrated to the new storage template',
|
|
||||||
false
|
|
||||||
)}
|
|
||||||
jobCounts={jobs[JobId.StorageTemplateMigration]}
|
|
||||||
>
|
>
|
||||||
Apply the current
|
Apply the current
|
||||||
<a
|
<a
|
||||||
|
|
Loading…
Reference in a new issue