feat(server/web) Add manual job trigger mechanism to the web (#767)

This commit is contained in:
Alex 2022-10-06 11:25:54 -05:00 committed by GitHub
parent 854c214bc0
commit 7587f858ae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
75 changed files with 1286 additions and 153 deletions

View file

@ -8,6 +8,7 @@ doc/AdminSignupResponseDto.md
doc/AlbumApi.md doc/AlbumApi.md
doc/AlbumCountResponseDto.md doc/AlbumCountResponseDto.md
doc/AlbumResponseDto.md doc/AlbumResponseDto.md
doc/AllJobStatusResponseDto.md
doc/AssetApi.md doc/AssetApi.md
doc/AssetCountByTimeBucket.md doc/AssetCountByTimeBucket.md
doc/AssetCountByTimeBucketResponseDto.md doc/AssetCountByTimeBucketResponseDto.md
@ -33,6 +34,12 @@ doc/DeviceTypeEnum.md
doc/ExifResponseDto.md doc/ExifResponseDto.md
doc/GetAssetByTimeBucketDto.md doc/GetAssetByTimeBucketDto.md
doc/GetAssetCountByTimeBucketDto.md doc/GetAssetCountByTimeBucketDto.md
doc/JobApi.md
doc/JobCommand.md
doc/JobCommandDto.md
doc/JobCounts.md
doc/JobId.md
doc/JobStatusResponseDto.md
doc/LoginCredentialDto.md doc/LoginCredentialDto.md
doc/LoginResponseDto.md doc/LoginResponseDto.md
doc/LogoutResponseDto.md doc/LogoutResponseDto.md
@ -59,6 +66,7 @@ lib/api/album_api.dart
lib/api/asset_api.dart lib/api/asset_api.dart
lib/api/authentication_api.dart lib/api/authentication_api.dart
lib/api/device_info_api.dart lib/api/device_info_api.dart
lib/api/job_api.dart
lib/api/server_info_api.dart lib/api/server_info_api.dart
lib/api/user_api.dart lib/api/user_api.dart
lib/api_client.dart lib/api_client.dart
@ -74,6 +82,7 @@ lib/model/add_users_dto.dart
lib/model/admin_signup_response_dto.dart lib/model/admin_signup_response_dto.dart
lib/model/album_count_response_dto.dart lib/model/album_count_response_dto.dart
lib/model/album_response_dto.dart lib/model/album_response_dto.dart
lib/model/all_job_status_response_dto.dart
lib/model/asset_count_by_time_bucket.dart lib/model/asset_count_by_time_bucket.dart
lib/model/asset_count_by_time_bucket_response_dto.dart lib/model/asset_count_by_time_bucket_response_dto.dart
lib/model/asset_count_by_user_id_response_dto.dart lib/model/asset_count_by_user_id_response_dto.dart
@ -96,6 +105,11 @@ lib/model/device_type_enum.dart
lib/model/exif_response_dto.dart lib/model/exif_response_dto.dart
lib/model/get_asset_by_time_bucket_dto.dart 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_dto.dart
lib/model/job_counts.dart
lib/model/job_id.dart
lib/model/job_status_response_dto.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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/JobId.md Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,4 +1,4 @@
node_modules/ node_modules/
upload/ upload/
dist/ dist/
.reverse-geocoding-dump

View file

@ -134,6 +134,9 @@ describe('Album service', () => {
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(),
}; };
sut = new AlbumService(albumRepositoryMock, assetRepositoryMock); sut = new AlbumService(albumRepositoryMock, assetRepositoryMock);

View file

@ -29,6 +29,9 @@ 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[]>;
getAssetWithNoEXIF(): Promise<AssetEntity[]>;
getAssetWithNoSmartInfo(): Promise<AssetEntity[]>;
} }
export const ASSET_REPOSITORY = 'ASSET_REPOSITORY'; export const ASSET_REPOSITORY = 'ASSET_REPOSITORY';
@ -40,6 +43,33 @@ export class AssetRepository implements IAssetRepository {
private assetRepository: Repository<AssetEntity>, private assetRepository: Repository<AssetEntity>,
) {} ) {}
async getAssetWithNoSmartInfo(): Promise<AssetEntity[]> {
return await this.assetRepository
.createQueryBuilder('asset')
.leftJoinAndSelect('asset.smartInfo', 'si')
.where('asset.resizePath IS NOT NULL')
.andWhere('si.id IS NULL')
.getMany();
}
async getAssetWithNoThumbnail(): Promise<AssetEntity[]> {
return await this.assetRepository
.createQueryBuilder('asset')
.where('asset.resizePath IS NULL')
.orWhere('asset.resizePath = :resizePath', { resizePath: '' })
.orWhere('asset.webpPath IS NULL')
.orWhere('asset.webpPath = :webpPath', { webpPath: '' })
.getMany();
}
async getAssetWithNoEXIF(): Promise<AssetEntity[]> {
return await this.assetRepository
.createQueryBuilder('asset')
.leftJoinAndSelect('asset.exifInfo', 'ei')
.where('ei."assetId" IS NULL')
.getMany();
}
async getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto> { async getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto> {
// Get asset count by AssetType // Get asset count by AssetType
const res = await this.assetRepository const res = await this.assetRepository

View file

@ -30,7 +30,7 @@ import { CommunicationGateway } from '../communication/communication.gateway';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull'; import { Queue } from 'bull';
import { IAssetUploadedJob } from '@app/job/index'; import { IAssetUploadedJob } from '@app/job/index';
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant'; import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant'; import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
@ -59,7 +59,7 @@ export class AssetController {
private assetService: AssetService, private assetService: AssetService,
private backgroundTaskService: BackgroundTaskService, private backgroundTaskService: BackgroundTaskService,
@InjectQueue(assetUploadedQueueName) @InjectQueue(QueueNameEnum.ASSET_UPLOADED)
private assetUploadedQueue: Queue<IAssetUploadedJob>, private assetUploadedQueue: Queue<IAssetUploadedJob>,
) {} ) {}

View file

@ -7,7 +7,7 @@ import { BullModule } from '@nestjs/bull';
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module'; import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service'; import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { CommunicationModule } from '../communication/communication.module'; import { CommunicationModule } from '../communication/communication.module';
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant'; import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
import { AssetRepository, ASSET_REPOSITORY } from './asset-repository'; import { AssetRepository, ASSET_REPOSITORY } from './asset-repository';
@Module({ @Module({
@ -16,7 +16,7 @@ import { AssetRepository, ASSET_REPOSITORY } from './asset-repository';
BackgroundTaskModule, BackgroundTaskModule,
TypeOrmModule.forFeature([AssetEntity]), TypeOrmModule.forFeature([AssetEntity]),
BullModule.registerQueue({ BullModule.registerQueue({
name: assetUploadedQueueName, name: QueueNameEnum.ASSET_UPLOADED,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,

View file

@ -107,6 +107,9 @@ 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(),
}; };
sui = new AssetService(assetRepositoryMock, a); sui = new AssetService(assetRepositoryMock, a);

View file

@ -1,12 +1,16 @@
import { ExifEntity } from '@app/database/entities/exif.entity'; import { ExifEntity } from '@app/database/entities/exif.entity';
import { ApiProperty } from '@nestjs/swagger';
export class ExifResponseDto { export class ExifResponseDto {
id?: string | null = null; @ApiProperty({ type: 'integer', format: 'int64' })
id?: number | null = null;
make?: string | null = null; make?: string | null = null;
model?: string | null = null; model?: string | null = null;
imageName?: string | null = null; imageName?: string | null = null;
exifImageWidth?: number | null = null; exifImageWidth?: number | null = null;
exifImageHeight?: number | null = null; exifImageHeight?: number | null = null;
@ApiProperty({ type: 'integer', format: 'int64' })
fileSizeInByte?: number | null = null; fileSizeInByte?: number | null = null;
orientation?: string | null = null; orientation?: string | null = null;
dateTimeOriginal?: Date | null = null; dateTimeOriginal?: Date | null = null;
@ -25,13 +29,13 @@ export class ExifResponseDto {
export function mapExif(entity: ExifEntity): ExifResponseDto { export function mapExif(entity: ExifEntity): ExifResponseDto {
return { return {
id: entity.id, id: parseInt(entity.id),
make: entity.make, make: entity.make,
model: entity.model, model: entity.model,
imageName: entity.imageName, imageName: entity.imageName,
exifImageWidth: entity.exifImageWidth, exifImageWidth: entity.exifImageWidth,
exifImageHeight: entity.exifImageHeight, exifImageHeight: entity.exifImageHeight,
fileSizeInByte: entity.fileSizeInByte, fileSizeInByte: entity.fileSizeInByte ? parseInt(entity.fileSizeInByte.toString()) : null,
orientation: entity.orientation, orientation: entity.orientation,
dateTimeOriginal: entity.dateTimeOriginal, dateTimeOriginal: entity.dateTimeOriginal,
modifyDate: entity.modifyDate, modifyDate: entity.modifyDate,

View file

@ -0,0 +1,21 @@
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',
}
export class GetJobDto {
@IsNotEmpty()
@IsEnum(JobId, {
message: `params must be one of ${Object.values(JobId).join()}`,
})
@ApiProperty({
enum: JobId,
enumName: 'JobId',
})
jobId!: string;
}

View file

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

View file

@ -0,0 +1,43 @@
import { Controller, Get, Body, UseGuards, ValidationPipe, Put, Param } from '@nestjs/common';
import { JobService } from './job.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AdminRolesGuard } from '../../middlewares/admin-role-guard.middleware';
import { AllJobStatusResponseDto } from './response-dto/all-job-status-response.dto';
import { GetJobDto } from './dto/get-job.dto';
import { JobStatusResponseDto } from './response-dto/job-status-response.dto';
import { JobCommandDto } from './dto/job-command.dto';
@UseGuards(JwtAuthGuard)
@UseGuards(AdminRolesGuard)
@ApiTags('Job')
@ApiBearerAuth()
@Controller('jobs')
export class JobController {
constructor(private readonly jobService: JobService) {}
@Get()
getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
return this.jobService.getAllJobsStatus();
}
@Get('/:jobId')
getJobStatus(@Param(ValidationPipe) params: GetJobDto): Promise<JobStatusResponseDto> {
return this.jobService.getJobStatus(params);
}
@Put('/:jobId')
async sendJobCommand(
@Param(ValidationPipe) params: GetJobDto,
@Body(ValidationPipe) body: JobCommandDto,
): Promise<number> {
if (body.command === 'start') {
return await this.jobService.startJob(params);
}
if (body.command === 'stop') {
return await this.jobService.stopJob(params);
}
return 0;
}
}

View file

@ -0,0 +1,82 @@
import { Module } from '@nestjs/common';
import { JobService } from './job.service';
import { JobController } from './job.controller';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../../config/jwt.config';
import { UserEntity } from '@app/database/entities/user.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bull';
import { QueueNameEnum } from '@app/job';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { ExifEntity } from '@app/database/entities/exif.entity';
import { AssetRepository, ASSET_REPOSITORY } from '../asset/asset-repository';
@Module({
imports: [
TypeOrmModule.forFeature([UserEntity, AssetEntity, ExifEntity]),
ImmichJwtModule,
JwtModule.register(jwtConfig),
BullModule.registerQueue(
{
name: QueueNameEnum.THUMBNAIL_GENERATION,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
},
{
name: QueueNameEnum.ASSET_UPLOADED,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
},
{
name: QueueNameEnum.METADATA_EXTRACTION,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
},
{
name: QueueNameEnum.VIDEO_CONVERSION,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
},
{
name: QueueNameEnum.CHECKSUM_GENERATION,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
},
{
name: QueueNameEnum.MACHINE_LEARNING,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
},
),
],
controllers: [JobController],
providers: [
JobService,
ImmichJwtService,
{
provide: ASSET_REPOSITORY,
useClass: AssetRepository,
},
],
})
export class JobModule {}

View file

@ -0,0 +1,180 @@
import {
exifExtractionProcessorName,
generateJPEGThumbnailProcessorName,
IMetadataExtractionJob,
IThumbnailGenerationJob,
IVideoTranscodeJob,
MachineLearningJobNameEnum,
QueueNameEnum,
videoMetadataExtractionProcessorName,
} from '@app/job';
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AllJobStatusResponseDto } from './response-dto/all-job-status-response.dto';
import { randomUUID } from 'crypto';
import { ASSET_REPOSITORY, IAssetRepository } from '../asset/asset-repository';
import { AssetType } from '@app/database/entities/asset.entity';
import { GetJobDto, JobId } from './dto/get-job.dto';
import { JobStatusResponseDto } from './response-dto/job-status-response.dto';
import { IMachineLearningJob } from '@app/job/interfaces/machine-learning.interface';
@Injectable()
export class JobService {
constructor(
@InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>,
@InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
@InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
private videoConversionQueue: Queue<IVideoTranscodeJob>,
@InjectQueue(QueueNameEnum.MACHINE_LEARNING)
private machineLearningQueue: Queue<IMachineLearningJob>,
@Inject(ASSET_REPOSITORY)
private _assetRepository: IAssetRepository,
) {
this.thumbnailGeneratorQueue.empty();
this.metadataExtractionQueue.empty();
this.videoConversionQueue.empty();
}
async startJob(jobDto: GetJobDto): Promise<number> {
switch (jobDto.jobId) {
case JobId.THUMBNAIL_GENERATION:
return this.runThumbnailGenerationJob();
case JobId.METADATA_EXTRACTION:
return this.runMetadataExtractionJob();
case JobId.VIDEO_CONVERSION:
return 0;
case JobId.MACHINE_LEARNING:
return this.runMachineLearningPipeline();
default:
throw new BadRequestException('Invalid job id');
}
}
async getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
const thumbnailGeneratorJobCount = await this.thumbnailGeneratorQueue.getJobCounts();
const metadataExtractionJobCount = await this.metadataExtractionQueue.getJobCounts();
const videoConversionJobCount = await this.videoConversionQueue.getJobCounts();
const machineLearningJobCount = await this.machineLearningQueue.getJobCounts();
const response = new AllJobStatusResponseDto();
response.isThumbnailGenerationActive = Boolean(thumbnailGeneratorJobCount.waiting);
response.thumbnailGenerationQueueCount = thumbnailGeneratorJobCount;
response.isMetadataExtractionActive = Boolean(metadataExtractionJobCount.waiting);
response.metadataExtractionQueueCount = metadataExtractionJobCount;
response.isVideoConversionActive = Boolean(videoConversionJobCount.waiting);
response.videoConversionQueueCount = videoConversionJobCount;
response.isMachineLearningActive = Boolean(machineLearningJobCount.waiting);
response.machineLearningQueueCount = machineLearningJobCount;
return response;
}
async getJobStatus(query: GetJobDto): Promise<JobStatusResponseDto> {
const response = new JobStatusResponseDto();
if (query.jobId === JobId.THUMBNAIL_GENERATION) {
response.isActive = Boolean((await this.thumbnailGeneratorQueue.getJobCounts()).waiting);
response.queueCount = await this.thumbnailGeneratorQueue.getJobCounts();
}
if (query.jobId === JobId.METADATA_EXTRACTION) {
response.isActive = Boolean((await this.metadataExtractionQueue.getJobCounts()).waiting);
response.queueCount = await this.metadataExtractionQueue.getJobCounts();
}
if (query.jobId === JobId.VIDEO_CONVERSION) {
response.isActive = Boolean((await this.videoConversionQueue.getJobCounts()).waiting);
response.queueCount = await this.videoConversionQueue.getJobCounts();
}
return response;
}
async stopJob(query: GetJobDto): Promise<number> {
switch (query.jobId) {
case JobId.THUMBNAIL_GENERATION:
this.thumbnailGeneratorQueue.empty();
return 0;
case JobId.METADATA_EXTRACTION:
this.metadataExtractionQueue.empty();
return 0;
case JobId.VIDEO_CONVERSION:
this.videoConversionQueue.empty();
return 0;
case JobId.MACHINE_LEARNING:
this.machineLearningQueue.empty();
return 0;
default:
throw new BadRequestException('Invalid job id');
}
}
private async runThumbnailGenerationJob(): Promise<number> {
const jobCount = await this.thumbnailGeneratorQueue.getJobCounts();
if (jobCount.waiting > 0) {
throw new BadRequestException('Thumbnail generation job is already running');
}
const assetsWithNoThumbnail = await this._assetRepository.getAssetWithNoThumbnail();
for (const asset of assetsWithNoThumbnail) {
await this.thumbnailGeneratorQueue.add(generateJPEGThumbnailProcessorName, { asset }, { jobId: randomUUID() });
}
return assetsWithNoThumbnail.length;
}
private async runMetadataExtractionJob(): Promise<number> {
const jobCount = await this.metadataExtractionQueue.getJobCounts();
if (jobCount.waiting > 0) {
throw new BadRequestException('Metadata extraction job is already running');
}
const assetsWithNoExif = await this._assetRepository.getAssetWithNoEXIF();
for (const asset of assetsWithNoExif) {
if (asset.type === AssetType.VIDEO) {
await this.metadataExtractionQueue.add(
videoMetadataExtractionProcessorName,
{ asset, fileName: asset.id },
{ jobId: randomUUID() },
);
} else {
await this.metadataExtractionQueue.add(
exifExtractionProcessorName,
{ asset, fileName: asset.id },
{ jobId: randomUUID() },
);
}
}
return assetsWithNoExif.length;
}
private async runMachineLearningPipeline(): Promise<number> {
const jobCount = await this.machineLearningQueue.getJobCounts();
if (jobCount.waiting > 0) {
throw new BadRequestException('Metadata extraction job is already running');
}
const assetWithNoSmartInfo = await this._assetRepository.getAssetWithNoSmartInfo();
for (const asset of assetWithNoSmartInfo) {
await this.machineLearningQueue.add(MachineLearningJobNameEnum.IMAGE_TAGGING, { asset }, { jobId: randomUUID() });
await this.machineLearningQueue.add(
MachineLearningJobNameEnum.OBJECT_DETECTION,
{ asset },
{ jobId: randomUUID() },
);
}
return assetWithNoSmartInfo.length;
}
}

View file

@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
export class JobCounts {
active!: number;
completed!: number;
failed!: number;
delayed!: number;
waiting!: number;
}
export class AllJobStatusResponseDto {
isThumbnailGenerationActive!: boolean;
isMetadataExtractionActive!: boolean;
isVideoConversionActive!: boolean;
isMachineLearningActive!: boolean;
@ApiProperty({
type: JobCounts,
})
thumbnailGenerationQueueCount!: JobCounts;
@ApiProperty({
type: JobCounts,
})
metadataExtractionQueueCount!: JobCounts;
@ApiProperty({
type: JobCounts,
})
videoConversionQueueCount!: JobCounts;
@ApiProperty({
type: JobCounts,
})
machineLearningQueueCount!: JobCounts;
}

View file

@ -0,0 +1,6 @@
import Bull from 'bull';
export class JobStatusResponseDto {
isActive!: boolean;
queueCount!: Bull.JobCounts;
}

View file

@ -5,13 +5,13 @@ export class ServerInfoResponseDto {
diskUse!: string; diskUse!: string;
diskAvailable!: string; diskAvailable!: string;
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer', format: 'int64' })
diskSizeRaw!: number; diskSizeRaw!: number;
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer', format: 'int64' })
diskUseRaw!: number; diskUseRaw!: number;
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer', format: 'int64' })
diskAvailableRaw!: number; diskAvailableRaw!: number;
@ApiProperty({ type: 'number', format: 'float' }) @ApiProperty({ type: 'number', format: 'float' })

View file

@ -15,6 +15,7 @@ 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 { DatabaseModule } from '@app/database'; import { DatabaseModule } from '@app/database';
import { JobModule } from './api-v1/job/job.module';
@Module({ @Module({
imports: [ imports: [
@ -55,6 +56,8 @@ import { DatabaseModule } from '@app/database';
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ScheduleTasksModule, ScheduleTasksModule,
JobModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [], providers: [],

View file

@ -3,18 +3,14 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from '@app/database/entities/asset.entity'; import { AssetEntity } from '@app/database/entities/asset.entity';
import { ScheduleTasksService } from './schedule-tasks.service'; import { ScheduleTasksService } from './schedule-tasks.service';
import { import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
} from '@app/job/constants/queue-name.constant';
import { ExifEntity } from '@app/database/entities/exif.entity'; import { ExifEntity } from '@app/database/entities/exif.entity';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([AssetEntity, ExifEntity]), TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
BullModule.registerQueue({ BullModule.registerQueue({
name: videoConversionQueueName, name: QueueNameEnum.VIDEO_CONVERSION,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@ -22,7 +18,7 @@ import { ExifEntity } from '@app/database/entities/exif.entity';
}, },
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
name: thumbnailGeneratorQueueName, name: QueueNameEnum.THUMBNAIL_GENERATION,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@ -31,7 +27,7 @@ import { ExifEntity } from '@app/database/entities/exif.entity';
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
name: metadataExtractionQueueName, name: QueueNameEnum.METADATA_EXTRACTION,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,

View file

@ -12,11 +12,9 @@ import {
generateWEBPThumbnailProcessorName, generateWEBPThumbnailProcessorName,
IMetadataExtractionJob, IMetadataExtractionJob,
IVideoTranscodeJob, IVideoTranscodeJob,
metadataExtractionQueueName,
mp4ConversionProcessorName, mp4ConversionProcessorName,
QueueNameEnum,
reverseGeocodingProcessorName, reverseGeocodingProcessorName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
videoMetadataExtractionProcessorName, videoMetadataExtractionProcessorName,
} from '@app/job'; } from '@app/job';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
@ -30,13 +28,13 @@ export class ScheduleTasksService {
@InjectRepository(ExifEntity) @InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>, private exifRepository: Repository<ExifEntity>,
@InjectQueue(thumbnailGeneratorQueueName) @InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
private thumbnailGeneratorQueue: Queue, private thumbnailGeneratorQueue: Queue,
@InjectQueue(videoConversionQueueName) @InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
private videoConversionQueue: Queue<IVideoTranscodeJob>, private videoConversionQueue: Queue<IVideoTranscodeJob>,
@InjectQueue(metadataExtractionQueueName) @InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
private metadataExtractionQueue: Queue<IMetadataExtractionJob>, private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
private configService: ConfigService, private configService: ConfigService,
@ -108,11 +106,11 @@ export class ScheduleTasksService {
@Cron(CronExpression.EVERY_DAY_AT_3AM) @Cron(CronExpression.EVERY_DAY_AT_3AM)
async extractExif() { async extractExif() {
const exifAssets = await this.assetRepository.find({ const exifAssets = await this.assetRepository
where: { .createQueryBuilder('asset')
exifInfo: IsNull(), .leftJoinAndSelect('asset.exifInfo', 'ei')
}, .where('ei."assetId" IS NULL')
}); .getMany();
for (const asset of exifAssets) { for (const asset of exifAssets) {
if (asset.type === AssetType.VIDEO) { if (asset.type === AssetType.VIDEO) {

View file

@ -4,13 +4,7 @@ import { AssetEntity } from '@app/database/entities/asset.entity';
import { ExifEntity } from '@app/database/entities/exif.entity'; import { ExifEntity } from '@app/database/entities/exif.entity';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity'; import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import { UserEntity } from '@app/database/entities/user.entity'; import { UserEntity } from '@app/database/entities/user.entity';
import { import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
assetUploadedQueueName,
generateChecksumQueueName,
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
} from '@app/job/constants/queue-name.constant';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
@ -19,6 +13,7 @@ import { CommunicationModule } from '../../immich/src/api-v1/communication/commu
import { MicroservicesService } from './microservices.service'; import { MicroservicesService } from './microservices.service';
import { AssetUploadedProcessor } from './processors/asset-uploaded.processor'; import { AssetUploadedProcessor } from './processors/asset-uploaded.processor';
import { GenerateChecksumProcessor } from './processors/generate-checksum.processor'; import { GenerateChecksumProcessor } from './processors/generate-checksum.processor';
import { MachineLearningProcessor } from './processors/machine-learning.processor';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor'; import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor'; import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
import { VideoTranscodeProcessor } from './processors/video-transcode.processor'; import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
@ -42,7 +37,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
}), }),
BullModule.registerQueue( BullModule.registerQueue(
{ {
name: thumbnailGeneratorQueueName, name: QueueNameEnum.THUMBNAIL_GENERATION,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@ -50,7 +45,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
}, },
}, },
{ {
name: assetUploadedQueueName, name: QueueNameEnum.ASSET_UPLOADED,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@ -58,7 +53,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
}, },
}, },
{ {
name: metadataExtractionQueueName, name: QueueNameEnum.METADATA_EXTRACTION,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@ -66,7 +61,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
}, },
}, },
{ {
name: videoConversionQueueName, name: QueueNameEnum.VIDEO_CONVERSION,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@ -74,7 +69,15 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
}, },
}, },
{ {
name: generateChecksumQueueName, name: QueueNameEnum.CHECKSUM_GENERATION,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
},
{
name: QueueNameEnum.MACHINE_LEARNING,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@ -92,6 +95,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
MetadataExtractionProcessor, MetadataExtractionProcessor,
VideoTranscodeProcessor, VideoTranscodeProcessor,
GenerateChecksumProcessor, GenerateChecksumProcessor,
MachineLearningProcessor,
ConfigService, ConfigService,
], ],
exports: [], exports: [],

View file

@ -1,4 +1,4 @@
import { generateChecksumQueueName } from '@app/job'; import { QueueNameEnum } from '@app/job';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Injectable, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bull'; import { Queue } from 'bull';
@ -6,14 +6,18 @@ import { randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class MicroservicesService implements OnModuleInit { export class MicroservicesService implements OnModuleInit {
constructor ( constructor(
@InjectQueue(generateChecksumQueueName) @InjectQueue(QueueNameEnum.CHECKSUM_GENERATION)
private generateChecksumQueue: Queue, private generateChecksumQueue: Queue,
) {} ) {}
async onModuleInit() { async onModuleInit() {
await this.generateChecksumQueue.add({}, { await this.generateChecksumQueue.add(
jobId: randomUUID(), delay: 10000 // wait for migration {},
}); {
jobId: randomUUID(),
delay: 10000, // wait for migration
},
);
} }
} }

View file

@ -4,30 +4,27 @@ import {
IMetadataExtractionJob, IMetadataExtractionJob,
IThumbnailGenerationJob, IThumbnailGenerationJob,
IVideoTranscodeJob, IVideoTranscodeJob,
assetUploadedQueueName,
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
assetUploadedProcessorName, assetUploadedProcessorName,
exifExtractionProcessorName, exifExtractionProcessorName,
generateJPEGThumbnailProcessorName, generateJPEGThumbnailProcessorName,
mp4ConversionProcessorName, mp4ConversionProcessorName,
videoMetadataExtractionProcessorName, videoMetadataExtractionProcessorName,
QueueNameEnum,
} from '@app/job'; } from '@app/job';
import { InjectQueue, Process, Processor } from '@nestjs/bull'; import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { Job, Queue } from 'bull'; import { Job, Queue } from 'bull';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
@Processor(assetUploadedQueueName) @Processor(QueueNameEnum.ASSET_UPLOADED)
export class AssetUploadedProcessor { export class AssetUploadedProcessor {
constructor( constructor(
@InjectQueue(thumbnailGeneratorQueueName) @InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>, private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>,
@InjectQueue(metadataExtractionQueueName) @InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
private metadataExtractionQueue: Queue<IMetadataExtractionJob>, private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
@InjectQueue(videoConversionQueueName) @InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
private videoConversionQueue: Queue<IVideoTranscodeJob>, private videoConversionQueue: Queue<IVideoTranscodeJob>,
) {} ) {}

View file

@ -1,5 +1,5 @@
import { AssetEntity } from '@app/database/entities/asset.entity'; import { AssetEntity } from '@app/database/entities/asset.entity';
import { generateChecksumQueueName } from '@app/job'; import { QueueNameEnum } from '@app/job';
import { Process, Processor } from '@nestjs/bull'; import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
@ -8,7 +8,7 @@ import fs from 'node:fs';
import { FindOptionsWhere, IsNull, MoreThan, QueryFailedError, Repository } from 'typeorm'; import { FindOptionsWhere, IsNull, MoreThan, QueryFailedError, Repository } from 'typeorm';
// TODO: just temporary task to generate previous uploaded assets. // TODO: just temporary task to generate previous uploaded assets.
@Processor(generateChecksumQueueName) @Processor(QueueNameEnum.CHECKSUM_GENERATION)
export class GenerateChecksumProcessor { export class GenerateChecksumProcessor {
constructor( constructor(
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
@ -33,7 +33,7 @@ export class GenerateChecksumProcessor {
const assets = await this.assetRepository.find({ const assets = await this.assetRepository.find({
where: whereStat, where: whereStat,
take: pageSize, take: pageSize,
order: { id: 'ASC' } order: { id: 'ASC' },
}); });
if (!assets?.length) { if (!assets?.length) {

View file

@ -0,0 +1,60 @@
import { AssetEntity } from '@app/database/entities/asset.entity';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import { MachineLearningJobNameEnum, QueueNameEnum } from '@app/job';
import { IMachineLearningJob } from '@app/job/interfaces/machine-learning.interface';
import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios';
import { Job } from 'bull';
import { Repository } from 'typeorm';
@Processor(QueueNameEnum.MACHINE_LEARNING)
export class MachineLearningProcessor {
constructor(
@InjectRepository(SmartInfoEntity)
private smartInfoRepository: Repository<SmartInfoEntity>,
) {}
@Process({ name: MachineLearningJobNameEnum.IMAGE_TAGGING, concurrency: 2 })
async tagImage(job: Job<IMachineLearningJob>) {
const { asset } = job.data;
const res = await axios.post('http://immich-machine-learning:3003/image-classifier/tag-image', {
thumbnailPath: asset.resizePath,
});
if (res.status == 201 && res.data.length > 0) {
const smartInfo = new SmartInfoEntity();
smartInfo.assetId = asset.id;
smartInfo.tags = [...res.data];
await this.smartInfoRepository.upsert(smartInfo, {
conflictPaths: ['assetId'],
});
}
}
@Process({ name: MachineLearningJobNameEnum.OBJECT_DETECTION, concurrency: 2 })
async detectObject(job: Job<IMachineLearningJob>) {
try {
const { asset }: { asset: AssetEntity } = job.data;
const res = await axios.post('http://immich-machine-learning:3003/object-detection/detect-object', {
thumbnailPath: asset.resizePath,
});
if (res.status == 201 && res.data.length > 0) {
const smartInfo = new SmartInfoEntity();
smartInfo.assetId = asset.id;
smartInfo.objects = [...res.data];
await this.smartInfoRepository.upsert(smartInfo, {
conflictPaths: ['assetId'],
});
}
} catch (error) {
Logger.error(`Failed to trigger object detection pipe line ${String(error)}`);
}
}
}

View file

@ -1,23 +1,19 @@
import { ImmichLogLevel } from '@app/common/constants/log-level.constant'; import { ImmichLogLevel } from '@app/common/constants/log-level.constant';
import { AssetEntity } from '@app/database/entities/asset.entity'; import { AssetEntity } from '@app/database/entities/asset.entity';
import { ExifEntity } from '@app/database/entities/exif.entity'; import { ExifEntity } from '@app/database/entities/exif.entity';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import { import {
IExifExtractionProcessor, IExifExtractionProcessor,
IVideoLengthExtractionProcessor, IVideoLengthExtractionProcessor,
exifExtractionProcessorName, exifExtractionProcessorName,
imageTaggingProcessorName,
objectDetectionProcessorName,
videoMetadataExtractionProcessorName, videoMetadataExtractionProcessorName,
metadataExtractionQueueName,
reverseGeocodingProcessorName, reverseGeocodingProcessorName,
IReverseGeocodingProcessor, IReverseGeocodingProcessor,
QueueNameEnum,
} from '@app/job'; } from '@app/job';
import { Process, Processor } from '@nestjs/bull'; import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios';
import { Job } from 'bull'; import { Job } from 'bull';
import exifr from 'exifr'; import exifr from 'exifr';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
@ -79,7 +75,7 @@ export interface GeoData {
distance: number; distance: number;
} }
@Processor(metadataExtractionQueueName) @Processor(QueueNameEnum.METADATA_EXTRACTION)
export class MetadataExtractionProcessor { export class MetadataExtractionProcessor {
private isGeocodeInitialized = false; private isGeocodeInitialized = false;
private logLevel: ImmichLogLevel; private logLevel: ImmichLogLevel;
@ -91,9 +87,6 @@ export class MetadataExtractionProcessor {
@InjectRepository(ExifEntity) @InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>, private exifRepository: Repository<ExifEntity>,
@InjectRepository(SmartInfoEntity)
private smartInfoRepository: Repository<SmartInfoEntity>,
private configService: ConfigService, private configService: ConfigService,
) { ) {
if (!configService.get('DISABLE_REVERSE_GEOCODING')) { if (!configService.get('DISABLE_REVERSE_GEOCODING')) {
@ -109,7 +102,8 @@ export class MetadataExtractionProcessor {
alternateNames: false, alternateNames: false,
}, },
countries: [], countries: [],
dumpDirectory: configService.get('REVERSE_GEOCODING_DUMP_DIRECTORY') || (process.cwd() + '/.reverse-geocoding-dump/'), dumpDirectory:
configService.get('REVERSE_GEOCODING_DUMP_DIRECTORY') || process.cwd() + '/.reverse-geocoding-dump/',
}).then(() => { }).then(() => {
this.isGeocodeInitialized = true; this.isGeocodeInitialized = true;
Logger.log('Reverse Geocoding Initialised'); Logger.log('Reverse Geocoding Initialised');
@ -273,48 +267,6 @@ export class MetadataExtractionProcessor {
} }
} }
@Process({ name: imageTaggingProcessorName, concurrency: 2 })
async tagImage(job: Job) {
const { asset }: { asset: AssetEntity } = job.data;
const res = await axios.post('http://immich-machine-learning:3003/image-classifier/tag-image', {
thumbnailPath: asset.resizePath,
});
if (res.status == 201 && res.data.length > 0) {
const smartInfo = new SmartInfoEntity();
smartInfo.assetId = asset.id;
smartInfo.tags = [...res.data];
await this.smartInfoRepository.upsert(smartInfo, {
conflictPaths: ['assetId'],
});
}
}
@Process({ name: objectDetectionProcessorName, concurrency: 2 })
async detectObject(job: Job) {
try {
const { asset }: { asset: AssetEntity } = job.data;
const res = await axios.post('http://immich-machine-learning:3003/object-detection/detect-object', {
thumbnailPath: asset.resizePath,
});
if (res.status == 201 && res.data.length > 0) {
const smartInfo = new SmartInfoEntity();
smartInfo.assetId = asset.id;
smartInfo.objects = [...res.data];
await this.smartInfoRepository.upsert(smartInfo, {
conflictPaths: ['assetId'],
});
}
} catch (error) {
Logger.error(`Failed to trigger object detection pipe line ${String(error)}`);
}
}
@Process({ name: videoMetadataExtractionProcessorName, concurrency: 2 }) @Process({ name: videoMetadataExtractionProcessorName, concurrency: 2 })
async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) { async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
const { asset, fileName } = job.data; const { asset, fileName } = job.data;

View file

@ -5,11 +5,9 @@ import {
WebpGeneratorProcessor, WebpGeneratorProcessor,
generateJPEGThumbnailProcessorName, generateJPEGThumbnailProcessorName,
generateWEBPThumbnailProcessorName, generateWEBPThumbnailProcessorName,
imageTaggingProcessorName,
objectDetectionProcessorName,
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
JpegGeneratorProcessor, JpegGeneratorProcessor,
QueueNameEnum,
MachineLearningJobNameEnum,
} from '@app/job'; } from '@app/job';
import { InjectQueue, Process, Processor } from '@nestjs/bull'; import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
@ -25,8 +23,9 @@ import sharp from 'sharp';
import { Repository } from 'typeorm/repository/Repository'; import { Repository } from 'typeorm/repository/Repository';
import { join } from 'path'; import { join } from 'path';
import { CommunicationGateway } from 'apps/immich/src/api-v1/communication/communication.gateway'; import { CommunicationGateway } from 'apps/immich/src/api-v1/communication/communication.gateway';
import { IMachineLearningJob } from '@app/job/interfaces/machine-learning.interface';
@Processor(thumbnailGeneratorQueueName) @Processor(QueueNameEnum.THUMBNAIL_GENERATION)
export class ThumbnailGeneratorProcessor { export class ThumbnailGeneratorProcessor {
private logLevel: ImmichLogLevel; private logLevel: ImmichLogLevel;
@ -34,13 +33,13 @@ export class ThumbnailGeneratorProcessor {
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>, private assetRepository: Repository<AssetEntity>,
@InjectQueue(thumbnailGeneratorQueueName) @InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
private thumbnailGeneratorQueue: Queue, private thumbnailGeneratorQueue: Queue,
private wsCommunicationGateway: CommunicationGateway, private wsCommunicationGateway: CommunicationGateway,
@InjectQueue(metadataExtractionQueueName) @InjectQueue(QueueNameEnum.MACHINE_LEARNING)
private metadataExtractionQueue: Queue, private machineLearningQueue: Queue<IMachineLearningJob>,
private configService: ConfigService, private configService: ConfigService,
) { ) {
@ -80,8 +79,12 @@ export class ThumbnailGeneratorProcessor {
asset.resizePath = jpegThumbnailPath; asset.resizePath = jpegThumbnailPath;
await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() }); await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() }); await this.machineLearningQueue.add(MachineLearningJobNameEnum.IMAGE_TAGGING, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() }); await this.machineLearningQueue.add(
MachineLearningJobNameEnum.OBJECT_DETECTION,
{ asset },
{ jobId: randomUUID() },
);
this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset))); this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
} }
@ -110,8 +113,12 @@ export class ThumbnailGeneratorProcessor {
asset.resizePath = jpegThumbnailPath; asset.resizePath = jpegThumbnailPath;
await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() }); await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() }); await this.machineLearningQueue.add(MachineLearningJobNameEnum.IMAGE_TAGGING, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() }); await this.machineLearningQueue.add(
MachineLearningJobNameEnum.OBJECT_DETECTION,
{ asset },
{ jobId: randomUUID() },
);
this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset))); this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
} }

View file

@ -1,7 +1,7 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants'; import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { AssetEntity } from '@app/database/entities/asset.entity'; import { AssetEntity } from '@app/database/entities/asset.entity';
import { QueueNameEnum } from '@app/job';
import { mp4ConversionProcessorName } from '@app/job/constants/job-name.constant'; import { mp4ConversionProcessorName } from '@app/job/constants/job-name.constant';
import { videoConversionQueueName } from '@app/job/constants/queue-name.constant';
import { IMp4ConversionProcessor } from '@app/job/interfaces/video-transcode.interface'; import { IMp4ConversionProcessor } from '@app/job/interfaces/video-transcode.interface';
import { Process, Processor } from '@nestjs/bull'; import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
@ -11,7 +11,7 @@ import ffmpeg from 'fluent-ffmpeg';
import { existsSync, mkdirSync } from 'fs'; import { existsSync, mkdirSync } from 'fs';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@Processor(videoConversionQueueName) @Processor(QueueNameEnum.VIDEO_CONVERSION)
export class VideoTranscodeProcessor { export class VideoTranscodeProcessor {
constructor( constructor(
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)

File diff suppressed because one or more lines are too long

View file

@ -20,5 +20,12 @@ export const generateWEBPThumbnailProcessorName = 'generate-webp-thumbnail';
export const exifExtractionProcessorName = 'exif-extraction'; export const exifExtractionProcessorName = 'exif-extraction';
export const videoMetadataExtractionProcessorName = 'extract-video-metadata'; export const videoMetadataExtractionProcessorName = 'extract-video-metadata';
export const reverseGeocodingProcessorName = 'reverse-geocoding'; export const reverseGeocodingProcessorName = 'reverse-geocoding';
export const objectDetectionProcessorName = 'detect-object';
export const imageTaggingProcessorName = 'tag-image'; /**
* Machine learning Queue Jobs
*/
export enum MachineLearningJobNameEnum {
OBJECT_DETECTION = 'detect-object',
IMAGE_TAGGING = 'tag-image',
}

View file

@ -1,5 +1,8 @@
export const thumbnailGeneratorQueueName = 'thumbnail-generator-queue'; export enum QueueNameEnum {
export const assetUploadedQueueName = 'asset-uploaded-queue'; THUMBNAIL_GENERATION = 'thumbnail-generation-queue',
export const metadataExtractionQueueName = 'metadata-extraction-queue'; METADATA_EXTRACTION = 'metadata-extraction-queue',
export const videoConversionQueueName = 'video-conversion-queue'; VIDEO_CONVERSION = 'video-conversion-queue',
export const generateChecksumQueueName = 'generate-checksum-queue'; CHECKSUM_GENERATION = 'generate-checksum-queue',
ASSET_UPLOADED = 'asset-uploaded-queue',
MACHINE_LEARNING = 'machine-learning-queue',
}

View file

@ -0,0 +1,8 @@
import { AssetEntity } from '@app/database/entities/asset.entity';
export interface IMachineLearningJob {
/**
* The Asset entity that was saved in the database
*/
asset: AssetEntity;
}

View file

@ -59,7 +59,7 @@
"@nestjs/testing": "^8.4.7", "@nestjs/testing": "^8.4.7",
"@openapitools/openapi-generator-cli": "2.5.1", "@openapitools/openapi-generator-cli": "2.5.1",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/bull": "^3.15.7", "@types/bull": "^3.15.9",
"@types/cookie-parser": "^1.4.3", "@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.0", "@types/cron": "^2.0.0",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
@ -2339,9 +2339,9 @@
} }
}, },
"node_modules/@types/bull": { "node_modules/@types/bull": {
"version": "3.15.7", "version": "3.15.9",
"resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.7.tgz", "resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.9.tgz",
"integrity": "sha512-7NC7XN5NoS0A+leJ/dR69ZfKaegOlCZaii/xGgKnCyh1UYisRncibImb7VMwrc3OdJcbDJt6+4om70TeNl3J7g==", "integrity": "sha512-MPUcyPPQauAmynoO3ezHAmCOhbB0pWmYyijr/5ctaCqhbKWsjW0YCod38ZcLzUBprosfZ9dPqfYIcfdKjk7RNQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@types/ioredis": "*", "@types/ioredis": "*",
@ -3764,6 +3764,27 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/cache-manager": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.0.0.tgz",
"integrity": "sha512-1qKdoeoJKmrf95Zvhr3NpBVAgBESt4TuZomBzn4N2gCFZvHjuUXBK1H8EDVsJdba6/grIgi6WGYb/ncJj+wjtg==",
"optional": true,
"peer": true,
"dependencies": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "^7.14.0"
}
},
"node_modules/cache-manager/node_modules/lru-cache": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.14.0.tgz",
"integrity": "sha512-EIRtP1GrSJny0dqb50QXRUNBxHJhcpxHC++M5tD7RYbvLLn5KVWKsbyswSSqDuU15UFi3bgTQIY8nhDMeF6aDQ==",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@ -7674,6 +7695,13 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
}, },
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"optional": true,
"peer": true
},
"node_modules/lodash.defaults": { "node_modules/lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@ -12900,9 +12928,9 @@
} }
}, },
"@types/bull": { "@types/bull": {
"version": "3.15.7", "version": "3.15.9",
"resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.7.tgz", "resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.9.tgz",
"integrity": "sha512-7NC7XN5NoS0A+leJ/dR69ZfKaegOlCZaii/xGgKnCyh1UYisRncibImb7VMwrc3OdJcbDJt6+4om70TeNl3J7g==", "integrity": "sha512-MPUcyPPQauAmynoO3ezHAmCOhbB0pWmYyijr/5ctaCqhbKWsjW0YCod38ZcLzUBprosfZ9dPqfYIcfdKjk7RNQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"@types/ioredis": "*", "@types/ioredis": "*",
@ -14073,6 +14101,26 @@
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="
}, },
"cache-manager": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.0.0.tgz",
"integrity": "sha512-1qKdoeoJKmrf95Zvhr3NpBVAgBESt4TuZomBzn4N2gCFZvHjuUXBK1H8EDVsJdba6/grIgi6WGYb/ncJj+wjtg==",
"optional": true,
"peer": true,
"requires": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "^7.14.0"
},
"dependencies": {
"lru-cache": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.14.0.tgz",
"integrity": "sha512-EIRtP1GrSJny0dqb50QXRUNBxHJhcpxHC++M5tD7RYbvLLn5KVWKsbyswSSqDuU15UFi3bgTQIY8nhDMeF6aDQ==",
"optional": true,
"peer": true
}
}
},
"call-bind": { "call-bind": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@ -17088,6 +17136,13 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
}, },
"lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"optional": true,
"peer": true
},
"lodash.defaults": { "lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",

View file

@ -78,7 +78,7 @@
"@nestjs/testing": "^8.4.7", "@nestjs/testing": "^8.4.7",
"@openapitools/openapi-generator-cli": "2.5.1", "@openapitools/openapi-generator-cli": "2.5.1",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/bull": "^3.15.7", "@types/bull": "^3.15.9",
"@types/cookie-parser": "^1.4.3", "@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.0", "@types/cron": "^2.0.0",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",

View file

@ -4,6 +4,7 @@ import {
AuthenticationApi, AuthenticationApi,
Configuration, Configuration,
DeviceInfoApi, DeviceInfoApi,
JobApi,
ServerInfoApi, ServerInfoApi,
UserApi UserApi
} from './open-api'; } from './open-api';
@ -15,6 +16,8 @@ class ImmichApi {
public authenticationApi: AuthenticationApi; public authenticationApi: AuthenticationApi;
public deviceInfoApi: DeviceInfoApi; public deviceInfoApi: DeviceInfoApi;
public serverInfoApi: ServerInfoApi; public serverInfoApi: ServerInfoApi;
public jobApi: JobApi;
private config = new Configuration({ basePath: '/api' }); private config = new Configuration({ basePath: '/api' });
constructor() { constructor() {
@ -24,6 +27,7 @@ class ImmichApi {
this.authenticationApi = new AuthenticationApi(this.config); this.authenticationApi = new AuthenticationApi(this.config);
this.deviceInfoApi = new DeviceInfoApi(this.config); this.deviceInfoApi = new DeviceInfoApi(this.config);
this.serverInfoApi = new ServerInfoApi(this.config); this.serverInfoApi = new ServerInfoApi(this.config);
this.jobApi = new JobApi(this.config);
} }
public setAccessToken(accessToken: string) { public setAccessToken(accessToken: string) {

View file

@ -170,6 +170,61 @@ export interface AlbumResponseDto {
*/ */
'assets': Array<AssetResponseDto>; 'assets': Array<AssetResponseDto>;
} }
/**
*
* @export
* @interface AllJobStatusResponseDto
*/
export interface AllJobStatusResponseDto {
/**
*
* @type {JobCounts}
* @memberof AllJobStatusResponseDto
*/
'thumbnailGenerationQueueCount': JobCounts;
/**
*
* @type {JobCounts}
* @memberof AllJobStatusResponseDto
*/
'metadataExtractionQueueCount': JobCounts;
/**
*
* @type {JobCounts}
* @memberof AllJobStatusResponseDto
*/
'videoConversionQueueCount': JobCounts;
/**
*
* @type {JobCounts}
* @memberof AllJobStatusResponseDto
*/
'machineLearningQueueCount': JobCounts;
/**
*
* @type {boolean}
* @memberof AllJobStatusResponseDto
*/
'isThumbnailGenerationActive': boolean;
/**
*
* @type {boolean}
* @memberof AllJobStatusResponseDto
*/
'isMetadataExtractionActive': boolean;
/**
*
* @type {boolean}
* @memberof AllJobStatusResponseDto
*/
'isVideoConversionActive': boolean;
/**
*
* @type {boolean}
* @memberof AllJobStatusResponseDto
*/
'isMachineLearningActive': boolean;
}
/** /**
* *
* @export * @export
@ -683,10 +738,16 @@ export type DeviceTypeEnum = typeof DeviceTypeEnum[keyof typeof DeviceTypeEnum];
export interface ExifResponseDto { export interface ExifResponseDto {
/** /**
* *
* @type {string} * @type {number}
* @memberof ExifResponseDto * @memberof ExifResponseDto
*/ */
'id'?: string | null; 'id'?: number | null;
/**
*
* @type {number}
* @memberof ExifResponseDto
*/
'fileSizeInByte'?: number | null;
/** /**
* *
* @type {string} * @type {string}
@ -717,12 +778,6 @@ export interface ExifResponseDto {
* @memberof ExifResponseDto * @memberof ExifResponseDto
*/ */
'exifImageHeight'?: number | null; 'exifImageHeight'?: number | null;
/**
*
* @type {number}
* @memberof ExifResponseDto
*/
'fileSizeInByte'?: number | null;
/** /**
* *
* @type {string} * @type {string}
@ -828,6 +883,105 @@ export interface GetAssetCountByTimeBucketDto {
*/ */
'timeGroup': TimeGroupEnum; 'timeGroup': TimeGroupEnum;
} }
/**
*
* @export
* @enum {string}
*/
export const JobCommand = {
Start: 'start',
Stop: 'stop'
} as const;
export type JobCommand = typeof JobCommand[keyof typeof JobCommand];
/**
*
* @export
* @interface JobCommandDto
*/
export interface JobCommandDto {
/**
*
* @type {JobCommand}
* @memberof JobCommandDto
*/
'command': JobCommand;
}
/**
*
* @export
* @interface JobCounts
*/
export interface JobCounts {
/**
*
* @type {number}
* @memberof JobCounts
*/
'active': number;
/**
*
* @type {number}
* @memberof JobCounts
*/
'completed': number;
/**
*
* @type {number}
* @memberof JobCounts
*/
'failed': number;
/**
*
* @type {number}
* @memberof JobCounts
*/
'delayed': number;
/**
*
* @type {number}
* @memberof JobCounts
*/
'waiting': number;
}
/**
*
* @export
* @enum {string}
*/
export const JobId = {
ThumbnailGeneration: 'thumbnail-generation',
MetadataExtraction: 'metadata-extraction',
VideoConversion: 'video-conversion',
MachineLearning: 'machine-learning'
} as const;
export type JobId = typeof JobId[keyof typeof JobId];
/**
*
* @export
* @interface JobStatusResponseDto
*/
export interface JobStatusResponseDto {
/**
*
* @type {boolean}
* @memberof JobStatusResponseDto
*/
'isActive': boolean;
/**
*
* @type {object}
* @memberof JobStatusResponseDto
*/
'queueCount': object;
}
/** /**
* *
* @export * @export
@ -3682,6 +3836,247 @@ export class DeviceInfoApi extends BaseAPI {
} }
/**
* JobApi - axios parameter creator
* @export
*/
export const JobApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllJobsStatus: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/jobs`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {JobId} jobId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getJobStatus: async (jobId: JobId, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'jobId' is not null or undefined
assertParamExists('getJobStatus', 'jobId', jobId)
const localVarPath = `/jobs/{jobId}`
.replace(`{${"jobId"}}`, encodeURIComponent(String(jobId)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {JobId} jobId
* @param {JobCommandDto} jobCommandDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
sendJobCommand: async (jobId: JobId, jobCommandDto: JobCommandDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'jobId' is not null or undefined
assertParamExists('sendJobCommand', 'jobId', jobId)
// verify required parameter 'jobCommandDto' is not null or undefined
assertParamExists('sendJobCommand', 'jobCommandDto', jobCommandDto)
const localVarPath = `/jobs/{jobId}`
.replace(`{${"jobId"}}`, encodeURIComponent(String(jobId)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(jobCommandDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* JobApi - functional programming interface
* @export
*/
export const JobApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = JobApiAxiosParamCreator(configuration)
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAllJobsStatus(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AllJobStatusResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllJobsStatus(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {JobId} jobId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getJobStatus(jobId: JobId, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<JobStatusResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getJobStatus(jobId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {JobId} jobId
* @param {JobCommandDto} jobCommandDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<number>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.sendJobCommand(jobId, jobCommandDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
/**
* JobApi - factory interface
* @export
*/
export const JobApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = JobApiFp(configuration)
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllJobsStatus(options?: any): AxiosPromise<AllJobStatusResponseDto> {
return localVarFp.getAllJobsStatus(options).then((request) => request(axios, basePath));
},
/**
*
* @param {JobId} jobId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getJobStatus(jobId: JobId, options?: any): AxiosPromise<JobStatusResponseDto> {
return localVarFp.getJobStatus(jobId, options).then((request) => request(axios, basePath));
},
/**
*
* @param {JobId} jobId
* @param {JobCommandDto} jobCommandDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: any): AxiosPromise<number> {
return localVarFp.sendJobCommand(jobId, jobCommandDto, options).then((request) => request(axios, basePath));
},
};
};
/**
* JobApi - object-oriented interface
* @export
* @class JobApi
* @extends {BaseAPI}
*/
export class JobApi extends BaseAPI {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof JobApi
*/
public getAllJobsStatus(options?: AxiosRequestConfig) {
return JobApiFp(this.configuration).getAllJobsStatus(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {JobId} jobId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof JobApi
*/
public getJobStatus(jobId: JobId, options?: AxiosRequestConfig) {
return JobApiFp(this.configuration).getJobStatus(jobId, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {JobId} jobId
* @param {JobCommandDto} jobCommandDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof JobApi
*/
public sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig) {
return JobApiFp(this.configuration).sendJobCommand(jobId, jobCommandDto, options).then((request) => request(this.axios, this.basePath));
}
}
/** /**
* ServerInfoApi - axios parameter creator * ServerInfoApi - axios parameter creator
* @export * @export

View file

@ -0,0 +1,52 @@
<script lang="ts">
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { createEventDispatcher } from 'svelte';
export let title: string;
export let subtitle: string;
export let buttonTitle = 'Run';
export let jobStatus: boolean;
export let waitingJobCount: number;
export let activeJobCount: number;
const dispatch = createEventDispatcher();
</script>
<div class="flex border p-6 rounded-2xl bg-white">
<div class="w-[70%]">
<h1 class="font-medium text-immich-primary">{title}</h1>
<p class="text-sm mt-1 font-medium">{subtitle}</p>
<p class="text-sm">
<slot />
</p>
<table class="text-left w-full mt-4">
<!-- table header -->
<thead class="border rounded-md mb-2 bg-gray-50 flex text-immich-primary w-full h-12">
<tr class="flex w-full place-items-center">
<th class="text-center w-1/3 font-medium text-sm">Status</th>
<th class="text-center w-1/3 font-medium text-sm">Active</th>
<th class="text-center w-1/3 font-medium text-sm">Waiting</th>
</tr>
</thead>
<tbody class="overflow-y-auto rounded-md w-full max-h-[320px] block border">
<tr class="text-center flex place-items-center w-full h-[40px]">
<td class="text-sm px-2 w-1/3 text-ellipsis">{jobStatus ? 'Active' : 'Idle'}</td>
<td class="text-sm px-2 w-1/3 text-ellipsis">{activeJobCount}</td>
<td class="text-sm px-2 w-1/3 text-ellipsis">{waitingJobCount}</td>
</tr>
</tbody>
</table>
</div>
<div class="w-[30%] flex place-items-center place-content-end">
<button
on:click={() => dispatch('click')}
class="border px-6 py-3 text-sm bg-gray-50 font-medium rounded-2xl hover:bg-immich-primary/10 transition-all hover:cursor-pointer disabled:cursor-not-allowed"
disabled={jobStatus}
>
{#if jobStatus}
<LoadingSpinner />
{:else}
{buttonTitle}
{/if}
</button>
</div>
</div>

View file

@ -0,0 +1,138 @@
<script lang="ts">
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { AllJobStatusResponseDto, api, JobCommand, JobId } from '@api';
import { onDestroy, onMount } from 'svelte';
import JobTile from './job-tile.svelte';
let allJobsStatus: AllJobStatusResponseDto;
let setIntervalHandler: NodeJS.Timer;
onMount(async () => {
const { data } = await api.jobApi.getAllJobsStatus();
allJobsStatus = data;
setIntervalHandler = setInterval(async () => {
const { data } = await api.jobApi.getAllJobsStatus();
allJobsStatus = data;
}, 1000);
});
1;
onDestroy(() => {
clearInterval(setIntervalHandler);
});
const runThumbnailGeneration = async () => {
try {
const { data } = await api.jobApi.sendJobCommand(JobId.ThumbnailGeneration, {
command: JobCommand.Start
});
if (data) {
notificationController.show({
message: `Thumbnail generation job started for ${data} asset`,
type: NotificationType.Info
});
} else {
notificationController.show({
message: `No missing thumbnails found`,
type: NotificationType.Info
});
}
} catch (e) {
console.log('[ERROR] runThumbnailGeneration', e);
notificationController.show({
message: `Error running thumbnail generation job, check console for more detail`,
type: NotificationType.Error
});
}
};
const runExtractEXIF = async () => {
try {
const { data } = await api.jobApi.sendJobCommand(JobId.MetadataExtraction, {
command: JobCommand.Start
});
if (data) {
notificationController.show({
message: `Extract EXIF job started for ${data} asset`,
type: NotificationType.Info
});
} else {
notificationController.show({
message: `No missing EXIF found`,
type: NotificationType.Info
});
}
} catch (e) {
console.log('[ERROR] runExtractEXIF', e);
notificationController.show({
message: `Error running extract EXIF job, check console for more detail`,
type: NotificationType.Error
});
}
};
const runMachineLearning = async () => {
try {
const { data } = await api.jobApi.sendJobCommand(JobId.MachineLearning, {
command: JobCommand.Start
});
if (data) {
notificationController.show({
message: `Object detection job started for ${data} asset`,
type: NotificationType.Info
});
} else {
notificationController.show({
message: `No missing object detection found`,
type: NotificationType.Info
});
}
} catch (e) {
console.log('[ERROR] runMachineLearning', e);
notificationController.show({
message: `Error running machine learning job, check console for more detail`,
type: NotificationType.Error
});
}
};
</script>
<div class="flex flex-col gap-6">
<JobTile
title={'Generate thumbnails'}
subtitle={'Regenerate missing thumbnail (JPEG, WEBP)'}
on:click={runThumbnailGeneration}
jobStatus={allJobsStatus?.isThumbnailGenerationActive}
waitingJobCount={allJobsStatus?.thumbnailGenerationQueueCount.waiting}
activeJobCount={allJobsStatus?.thumbnailGenerationQueueCount.active}
/>
<JobTile
title={'Extract EXIF'}
subtitle={'Extract missing EXIF information'}
on:click={runExtractEXIF}
jobStatus={allJobsStatus?.isMetadataExtractionActive}
waitingJobCount={allJobsStatus?.metadataExtractionQueueCount.waiting}
activeJobCount={allJobsStatus?.metadataExtractionQueueCount.active}
/>
<JobTile
title={'Detect objects'}
subtitle={'Run machine learning process to detect and classify objects'}
on:click={runMachineLearning}
jobStatus={allJobsStatus?.isMachineLearningActive}
waitingJobCount={allJobsStatus?.machineLearningQueueCount.waiting}
activeJobCount={allJobsStatus?.machineLearningQueueCount.active}
>
Note that some asset does not have any object detected, this is normal.
</JobTile>
</div>

View file

@ -94,7 +94,7 @@
<div <div
id="immich-scrubbable-scrollbar" id="immich-scrubbable-scrollbar"
class="fixed right-0 bg-immich-bg z-10 hover:cursor-row-resize select-none" class="fixed right-0 bg-immich-bg z-[999] hover:cursor-row-resize select-none "
style:width={isDragging ? '100vw' : '60px'} style:width={isDragging ? '100vw' : '60px'}
style:background-color={isDragging ? 'transparent' : 'transparent'} style:background-color={isDragging ? 'transparent' : 'transparent'}
on:mouseenter={() => (isHover = true)} on:mouseenter={() => (isHover = true)}

View file

@ -1,5 +1,7 @@
export enum AdminSideBarSelection { export enum AdminSideBarSelection {
USER_MANAGEMENT = 'User management' USER_MANAGEMENT = 'User management',
JOBS = 'Jobs',
SETTINGS = 'Settings'
} }
export enum AppSideBarSelection { export enum AppSideBarSelection {

View file

@ -0,0 +1,3 @@
<main>
<slot />
</main>

View file

@ -4,6 +4,7 @@
import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection'; import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection';
import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte'; import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte'; import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
import Cog from 'svelte-material-icons/Cog.svelte';
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte'; import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
import UserManagement from '$lib/components/admin-page/user-management.svelte'; import UserManagement from '$lib/components/admin-page/user-management.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
@ -12,6 +13,7 @@
import StatusBox from '$lib/components/shared-components/status-box.svelte'; import StatusBox from '$lib/components/shared-components/status-box.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { api, UserResponseDto } from '@api'; import { api, UserResponseDto } from '@api';
import JobsPanel from '$lib/components/admin-page/jobs/jobs-panel.svelte';
let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT; let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
@ -104,14 +106,21 @@
{/if} {/if}
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen"> <section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen">
<section id="admin-sidebar" class="pt-8 pr-6 flex flex-col"> <section id="admin-sidebar" class="pt-8 pr-6 flex flex-col gap-1">
<SideBarButton <SideBarButton
title="User" title="Users"
logo={AccountMultipleOutline} logo={AccountMultipleOutline}
actionType={AdminSideBarSelection.USER_MANAGEMENT} actionType={AdminSideBarSelection.USER_MANAGEMENT}
isSelected={selectedAction === AdminSideBarSelection.USER_MANAGEMENT} isSelected={selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
on:selected={onButtonClicked} on:selected={onButtonClicked}
/> />
<SideBarButton
title="Jobs"
logo={Cog}
actionType={AdminSideBarSelection.JOBS}
isSelected={selectedAction === AdminSideBarSelection.JOBS}
on:selected={onButtonClicked}
/>
<div class="mb-6 mt-auto"> <div class="mb-6 mt-auto">
<StatusBox /> <StatusBox />
@ -132,6 +141,9 @@
on:edit-user={editUserHandler} on:edit-user={editUserHandler}
/> />
{/if} {/if}
{#if selectedAction === AdminSideBarSelection.JOBS}
<JobsPanel />
{/if}
</section> </section>
</section> </section>
</section> </section>