1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-26 10:50:29 +02:00

Remove thumbnail generation on mobile app (#292)

* Remove thumbnail generation on mobile

* Remove tconditions for missing thumbnail on the backend

* Remove console.log

* Refactor queue systems

* Convert queue and processor name to constant

* Added corresponding interface to job queue
This commit is contained in:
Alex 2022-07-02 21:06:36 -05:00 committed by GitHub
parent 32b847c26e
commit 76bf1c0379
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 270 additions and 141 deletions

View File

@ -69,21 +69,6 @@ class BackupService {
), ),
); );
// Build thumbnail multipart data
var thumbnailData = await entity
.thumbnailDataWithSize(const ThumbnailSize(1440, 2560));
if (thumbnailData != null) {
thumbnailUploadData = http.MultipartFile.fromBytes(
"thumbnailData",
List.from(thumbnailData),
filename: fileNameWithoutPath,
contentType: MediaType(
"image",
"jpeg",
),
);
}
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
var req = MultipartRequest( var req = MultipartRequest(
@ -101,9 +86,6 @@ class BackupService {
req.fields['fileExtension'] = fileExtension; req.fields['fileExtension'] = fileExtension;
req.fields['duration'] = entity.videoDuration.toString(); req.fields['duration'] = entity.videoDuration.toString();
if (thumbnailUploadData != null) {
req.files.add(thumbnailUploadData);
}
req.files.add(assetRawUploadData); req.files.add(assetRawUploadData);
var res = await req.send(cancellationToken: cancelToken); var res = await req.send(cancellationToken: cancelToken);

View File

@ -31,6 +31,9 @@ import { SearchAssetDto } from './dto/search-asset.dto';
import { CommunicationGateway } from '../communication/communication.gateway'; 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 { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('asset') @Controller('asset')
@ -40,8 +43,8 @@ export class AssetController {
private assetService: AssetService, private assetService: AssetService,
private backgroundTaskService: BackgroundTaskService, private backgroundTaskService: BackgroundTaskService,
@InjectQueue('asset-uploaded-queue') @InjectQueue(assetUploadedQueueName)
private assetUploadedQueue: Queue, private assetUploadedQueue: Queue<IAssetUploadedJob>,
) {} ) {}
@Post('upload') @Post('upload')
@ -56,7 +59,7 @@ export class AssetController {
) )
async uploadFile( async uploadFile(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@UploadedFiles() uploadFiles: { assetData: Express.Multer.File[]; thumbnailData?: Express.Multer.File[] }, @UploadedFiles() uploadFiles: { assetData: Express.Multer.File[] },
@Body(ValidationPipe) assetInfo: CreateAssetDto, @Body(ValidationPipe) assetInfo: CreateAssetDto,
): Promise<'ok' | undefined> { ): Promise<'ok' | undefined> {
for (const file of uploadFiles.assetData) { for (const file of uploadFiles.assetData) {
@ -66,28 +69,12 @@ export class AssetController {
if (!savedAsset) { if (!savedAsset) {
return; return;
} }
if (uploadFiles.thumbnailData != null) {
const assetWithThumbnail = await this.assetService.updateThumbnailInfo(
savedAsset,
uploadFiles.thumbnailData[0].path,
);
await this.assetUploadedQueue.add( await this.assetUploadedQueue.add(
'asset-uploaded', assetUploadedProcessorName,
{ asset: assetWithThumbnail, fileName: file.originalname, fileSize: file.size, hasThumbnail: true }, { asset: savedAsset, fileName: file.originalname, fileSize: file.size },
{ jobId: savedAsset.id }, { jobId: savedAsset.id },
); );
this.wsCommunicateionGateway.server
.to(savedAsset.userId)
.emit('on_upload_success', JSON.stringify(assetWithThumbnail));
} else {
await this.assetUploadedQueue.add(
'asset-uploaded',
{ asset: savedAsset, fileName: file.originalname, fileSize: file.size, hasThumbnail: false },
{ jobId: savedAsset.id },
);
}
} catch (e) { } catch (e) {
Logger.error(`Error receiving upload file ${e}`); Logger.error(`Error receiving upload file ${e}`);
} }

View File

@ -7,6 +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';
@Module({ @Module({
imports: [ imports: [
@ -14,7 +15,7 @@ import { CommunicationModule } from '../communication/communication.module';
BackgroundTaskModule, BackgroundTaskModule,
TypeOrmModule.forFeature([AssetEntity]), TypeOrmModule.forFeature([AssetEntity]),
BullModule.registerQueue({ BullModule.registerQueue({
name: 'asset-uploaded-queue', name: assetUploadedQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,

View File

@ -6,7 +6,6 @@ import { extname } from 'path';
import { Request } from 'express'; import { Request } from 'express';
import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant'; import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
// import { CreateAssetDto } from '../api-v1/asset/dto/create-asset.dto';
export const assetUploadOption: MulterOptions = { export const assetUploadOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => { fileFilter: (req: Request, file: any, cb: any) => {
@ -30,34 +29,20 @@ export const assetUploadOption: MulterOptions = {
return; return;
} }
if (file.fieldname == 'assetData') { const originalUploadFolder = `${basePath}/${req.user.id}/original/${req.body['deviceId']}`;
const originalUploadFolder = `${basePath}/${req.user.id}/original/${req.body['deviceId']}`;
if (!existsSync(originalUploadFolder)) { if (!existsSync(originalUploadFolder)) {
mkdirSync(originalUploadFolder, { recursive: true }); mkdirSync(originalUploadFolder, { recursive: true });
}
// Save original to disk
cb(null, originalUploadFolder);
} else if (file.fieldname == 'thumbnailData') {
const thumbnailUploadFolder = `${basePath}/${req.user.id}/thumb/${req.body['deviceId']}`;
if (!existsSync(thumbnailUploadFolder)) {
mkdirSync(thumbnailUploadFolder, { recursive: true });
}
// Save thumbnail to disk
cb(null, thumbnailUploadFolder);
} }
// Save original to disk
cb(null, originalUploadFolder);
}, },
filename: (req: Request, file: Express.Multer.File, cb: any) => { filename: (req: Request, file: Express.Multer.File, cb: any) => {
const fileNameUUID = randomUUID(); const fileNameUUID = randomUUID();
if (file.fieldname == 'assetData') {
cb(null, `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`); cb(null, `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`);
} else if (file.fieldname == 'thumbnailData') {
cb(null, `${fileNameUUID}.jpeg`);
}
}, },
}), }),
}; };

View File

@ -3,12 +3,13 @@ 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 { thumbnailGeneratorQueueName, videoConversionQueueName } from '@app/job/constants/queue-name.constant';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([AssetEntity]), TypeOrmModule.forFeature([AssetEntity]),
BullModule.registerQueue({ BullModule.registerQueue({
name: 'video-conversion-queue', name: videoConversionQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@ -16,7 +17,7 @@ import { ScheduleTasksService } from './schedule-tasks.service';
}, },
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
name: 'thumbnail-generator-queue', name: thumbnailGeneratorQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,

View File

@ -6,6 +6,9 @@ import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull'; import { Queue } from 'bull';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { generateWEBPThumbnailProcessorName, mp4ConversionProcessorName } from '@app/job/constants/job-name.constant';
import { thumbnailGeneratorQueueName, videoConversionQueueName } from '@app/job/constants/queue-name.constant';
import { IVideoTranscodeJob } from '@app/job/interfaces/video-transcode.interface';
@Injectable() @Injectable()
export class ScheduleTasksService { export class ScheduleTasksService {
@ -13,11 +16,11 @@ export class ScheduleTasksService {
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>, private assetRepository: Repository<AssetEntity>,
@InjectQueue('thumbnail-generator-queue') @InjectQueue(thumbnailGeneratorQueueName)
private thumbnailGeneratorQueue: Queue, private thumbnailGeneratorQueue: Queue,
@InjectQueue('video-conversion-queue') @InjectQueue(videoConversionQueueName)
private videoConversionQueue: Queue, private videoConversionQueue: Queue<IVideoTranscodeJob>,
) {} ) {}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
@ -36,7 +39,11 @@ export class ScheduleTasksService {
} }
for (const asset of assets) { for (const asset of assets) {
await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset: asset }, { jobId: randomUUID() }); await this.thumbnailGeneratorQueue.add(
generateWEBPThumbnailProcessorName,
{ asset: asset },
{ jobId: randomUUID() },
);
} }
} }
@ -54,7 +61,7 @@ export class ScheduleTasksService {
}); });
for (const asset of assets) { for (const asset of assets) {
await this.videoConversionQueue.add('mp4-conversion', { asset }, { jobId: randomUUID() }); await this.videoConversionQueue.add(mp4ConversionProcessorName, { asset }, { jobId: randomUUID() });
} }
} }
} }

View File

@ -12,6 +12,12 @@ import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor'; import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
import { VideoTranscodeProcessor } from './processors/video-transcode.processor'; import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module'; import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module';
import {
assetUploadedQueueName,
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
} from '@app/job/constants/queue-name.constant';
@Module({ @Module({
imports: [ imports: [
@ -26,7 +32,7 @@ import { CommunicationModule } from '../../immich/src/api-v1/communication/commu
}), }),
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
name: 'thumbnail-generator-queue', name: thumbnailGeneratorQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@ -34,7 +40,7 @@ import { CommunicationModule } from '../../immich/src/api-v1/communication/commu
}, },
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
name: 'asset-uploaded-queue', name: assetUploadedQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@ -42,7 +48,7 @@ import { CommunicationModule } from '../../immich/src/api-v1/communication/commu
}, },
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
name: 'metadata-extraction-queue', name: metadataExtractionQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
@ -50,7 +56,7 @@ import { CommunicationModule } from '../../immich/src/api-v1/communication/commu
}, },
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
name: 'video-conversion-queue', name: videoConversionQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,

View File

@ -1,61 +1,58 @@
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 { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; import { AssetType } from '@app/database/entities/asset.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import {
IAssetUploadedJob,
IMetadataExtractionJob,
IThumbnailGenerationJob,
IVideoTranscodeJob,
assetUploadedQueueName,
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
assetUploadedProcessorName,
exifExtractionProcessorName,
generateJPEGThumbnailProcessorName,
mp4ConversionProcessorName,
videoLengthExtractionProcessorName,
} from '@app/job';
@Processor('asset-uploaded-queue') @Processor(assetUploadedQueueName)
export class AssetUploadedProcessor { export class AssetUploadedProcessor {
constructor( constructor(
@InjectQueue('thumbnail-generator-queue') @InjectQueue(thumbnailGeneratorQueueName)
private thumbnailGeneratorQueue: Queue, private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>,
@InjectQueue('metadata-extraction-queue') @InjectQueue(metadataExtractionQueueName)
private metadataExtractionQueue: Queue, private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
@InjectQueue('video-conversion-queue') @InjectQueue(videoConversionQueueName)
private videoConversionQueue: Queue, private videoConversionQueue: Queue<IVideoTranscodeJob>,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
) {} ) {}
/** /**
* Post processing uploaded asset to perform the following function if missing * Post processing uploaded asset to perform the following function if missing
* 1. Generate JPEG Thumbnail * 1. Generate JPEG Thumbnail
* 2. Generate Webp Thumbnail <-> if JPEG thumbnail exist * 2. Generate Webp Thumbnail
* 3. EXIF extractor * 3. EXIF extractor
* 4. Reverse Geocoding * 4. Reverse Geocoding
* *
* @param job asset-uploaded * @param job asset-uploaded
*/ */
@Process('asset-uploaded') @Process(assetUploadedProcessorName)
async processUploadedVideo(job: Job) { async processUploadedVideo(job: Job<IAssetUploadedJob>) {
const { const { asset, fileName, fileSize } = job.data;
asset,
fileName,
fileSize,
hasThumbnail,
}: { asset: AssetEntity; fileName: string; fileSize: number; hasThumbnail: boolean } = job.data;
if (hasThumbnail) { await this.thumbnailGeneratorQueue.add(generateJPEGThumbnailProcessorName, { asset }, { jobId: randomUUID() });
// The jobs below depends on the existence of jpeg thumbnail
await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add('tag-image', { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add('detect-object', { asset }, { jobId: randomUUID() });
} else {
// Generate Thumbnail -> Then generate webp, tag image and detect object
await this.thumbnailGeneratorQueue.add('generate-jpeg-thumbnail', { asset }, { jobId: randomUUID() });
}
// Video Conversion // Video Conversion
if (asset.type == AssetType.VIDEO) { if (asset.type == AssetType.VIDEO) {
await this.videoConversionQueue.add('mp4-conversion', { asset }, { jobId: randomUUID() }); await this.videoConversionQueue.add(mp4ConversionProcessorName, { asset }, { jobId: randomUUID() });
} else { } else {
// Extract Metadata/Exif for Images - Currently the library cannot extract EXIF for video yet // Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet
await this.metadataExtractionQueue.add( await this.metadataExtractionQueue.add(
'exif-extraction', exifExtractionProcessorName,
{ {
asset, asset,
fileName, fileName,
@ -67,7 +64,7 @@ export class AssetUploadedProcessor {
// Extract video duration if uploaded from the web // Extract video duration if uploaded from the web
if (asset.type == AssetType.VIDEO && asset.duration == '0:00:00.000000') { if (asset.type == AssetType.VIDEO && asset.duration == '0:00:00.000000') {
await this.metadataExtractionQueue.add('extract-video-length', { asset }, { jobId: randomUUID() }); await this.metadataExtractionQueue.add(videoLengthExtractionProcessorName, { asset }, { jobId: randomUUID() });
} }
} }
} }

View File

@ -13,8 +13,17 @@ import axios from 'axios';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity'; import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
import path from 'path'; import path from 'path';
import {
IExifExtractionProcessor,
IVideoLengthExtractionProcessor,
exifExtractionProcessorName,
imageTaggingProcessorName,
objectDetectionProcessorName,
videoLengthExtractionProcessorName,
metadataExtractionQueueName,
} from '@app/job';
@Processor('metadata-extraction-queue') @Processor(metadataExtractionQueueName)
export class MetadataExtractionProcessor { export class MetadataExtractionProcessor {
private geocodingClient?: GeocodeService; private geocodingClient?: GeocodeService;
@ -35,8 +44,8 @@ export class MetadataExtractionProcessor {
} }
} }
@Process('exif-extraction') @Process(exifExtractionProcessorName)
async extractExifInfo(job: Job) { async extractExifInfo(job: Job<IExifExtractionProcessor>) {
try { try {
const { asset, fileName, fileSize }: { asset: AssetEntity; fileName: string; fileSize: number } = job.data; const { asset, fileName, fileSize }: { asset: AssetEntity; fileName: string; fileSize: number } = job.data;
@ -89,7 +98,7 @@ export class MetadataExtractionProcessor {
} }
} }
@Process({ name: 'tag-image', concurrency: 2 }) @Process({ name: imageTaggingProcessorName, concurrency: 2 })
async tagImage(job: Job) { async tagImage(job: Job) {
const { asset }: { asset: AssetEntity } = job.data; const { asset }: { asset: AssetEntity } = job.data;
@ -108,7 +117,7 @@ export class MetadataExtractionProcessor {
} }
} }
@Process({ name: 'detect-object', concurrency: 2 }) @Process({ name: objectDetectionProcessorName, concurrency: 2 })
async detectObject(job: Job) { async detectObject(job: Job) {
try { try {
const { asset }: { asset: AssetEntity } = job.data; const { asset }: { asset: AssetEntity } = job.data;
@ -131,9 +140,9 @@ export class MetadataExtractionProcessor {
} }
} }
@Process({ name: 'extract-video-length', concurrency: 2 }) @Process({ name: videoLengthExtractionProcessorName, concurrency: 2 })
async extractVideoLength(job: Job) { async extractVideoLength(job: Job<IVideoLengthExtractionProcessor>) {
const { asset }: { asset: AssetEntity } = job.data; const { asset } = job.data;
ffmpeg.ffprobe(asset.originalPath, async (err, data) => { ffmpeg.ffprobe(asset.originalPath, async (err, data) => {
if (!err) { if (!err) {

View File

@ -9,25 +9,35 @@ import { randomUUID } from 'node:crypto';
import { CommunicationGateway } from '../../../immich/src/api-v1/communication/communication.gateway'; import { CommunicationGateway } from '../../../immich/src/api-v1/communication/communication.gateway';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import {
WebpGeneratorProcessor,
generateJPEGThumbnailProcessorName,
generateWEBPThumbnailProcessorName,
imageTaggingProcessorName,
objectDetectionProcessorName,
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
JpegGeneratorProcessor,
} from '@app/job';
@Processor('thumbnail-generator-queue') @Processor(thumbnailGeneratorQueueName)
export class ThumbnailGeneratorProcessor { export class ThumbnailGeneratorProcessor {
constructor( constructor(
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>, private assetRepository: Repository<AssetEntity>,
@InjectQueue('thumbnail-generator-queue') @InjectQueue(thumbnailGeneratorQueueName)
private thumbnailGeneratorQueue: Queue, private thumbnailGeneratorQueue: Queue,
private wsCommunicateionGateway: CommunicationGateway, private wsCommunicateionGateway: CommunicationGateway,
@InjectQueue('metadata-extraction-queue') @InjectQueue(metadataExtractionQueueName)
private metadataExtractionQueue: Queue, private metadataExtractionQueue: Queue,
) {} ) {}
@Process('generate-jpeg-thumbnail') @Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 })
async generateJPEGThumbnail(job: Job) { async generateJPEGThumbnail(job: Job<JpegGeneratorProcessor>) {
const { asset }: { asset: AssetEntity } = job.data; const { asset } = job.data;
const resizePath = `upload/${asset.userId}/thumb/${asset.deviceId}/`; const resizePath = `upload/${asset.userId}/thumb/${asset.deviceId}/`;
@ -43,6 +53,7 @@ export class ThumbnailGeneratorProcessor {
sharp(asset.originalPath) sharp(asset.originalPath)
.resize(1440, 2560, { fit: 'inside' }) .resize(1440, 2560, { fit: 'inside' })
.jpeg() .jpeg()
.rotate()
.toFile(jpegThumbnailPath, async (err) => { .toFile(jpegThumbnailPath, async (err) => {
if (!err) { if (!err) {
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath }); await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
@ -50,9 +61,13 @@ export class ThumbnailGeneratorProcessor {
// Update resize path to send to generate webp queue // Update resize path to send to generate webp queue
asset.resizePath = jpegThumbnailPath; asset.resizePath = jpegThumbnailPath;
await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset }, { jobId: randomUUID() }); await this.thumbnailGeneratorQueue.add(
await this.metadataExtractionQueue.add('tag-image', { asset }, { jobId: randomUUID() }); generateWEBPThumbnailProcessorName,
await this.metadataExtractionQueue.add('detect-object', { asset }, { jobId: randomUUID() }); { asset },
{ jobId: randomUUID() },
);
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset)); this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset));
} }
}); });
@ -76,9 +91,13 @@ export class ThumbnailGeneratorProcessor {
// Update resize path to send to generate webp queue // Update resize path to send to generate webp queue
asset.resizePath = jpegThumbnailPath; asset.resizePath = jpegThumbnailPath;
await this.thumbnailGeneratorQueue.add('generate-webp-thumbnail', { asset }, { jobId: randomUUID() }); await this.thumbnailGeneratorQueue.add(
await this.metadataExtractionQueue.add('tag-image', { asset }, { jobId: randomUUID() }); generateWEBPThumbnailProcessorName,
await this.metadataExtractionQueue.add('detect-object', { asset }, { jobId: randomUUID() }); { asset },
{ jobId: randomUUID() },
);
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset)); this.wsCommunicateionGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(asset));
}) })
@ -86,8 +105,8 @@ export class ThumbnailGeneratorProcessor {
} }
} }
@Process({ name: 'generate-webp-thumbnail', concurrency: 2 }) @Process({ name: generateWEBPThumbnailProcessorName, concurrency: 3 })
async generateWepbThumbnail(job: Job<{ asset: AssetEntity }>) { async generateWepbThumbnail(job: Job<WebpGeneratorProcessor>) {
const { asset } = job.data; const { asset } = job.data;
if (!asset.resizePath) { if (!asset.resizePath) {
@ -98,6 +117,7 @@ export class ThumbnailGeneratorProcessor {
sharp(asset.resizePath) sharp(asset.resizePath)
.resize(250) .resize(250)
.webp() .webp()
.rotate()
.toFile(webpPath, (err) => { .toFile(webpPath, (err) => {
if (!err) { if (!err) {
this.assetRepository.update({ id: asset.id }, { webpPath: webpPath }); this.assetRepository.update({ id: asset.id }, { webpPath: webpPath });

View File

@ -1,3 +1,6 @@
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 { 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,16 +11,16 @@ import { Repository } from 'typeorm';
import { AssetEntity } from '../../../../libs/database/src/entities/asset.entity'; import { AssetEntity } from '../../../../libs/database/src/entities/asset.entity';
import { APP_UPLOAD_LOCATION } from '../../../immich/src/constants/upload_location.constant'; import { APP_UPLOAD_LOCATION } from '../../../immich/src/constants/upload_location.constant';
@Processor('video-conversion-queue') @Processor(videoConversionQueueName)
export class VideoTranscodeProcessor { export class VideoTranscodeProcessor {
constructor( constructor(
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>, private assetRepository: Repository<AssetEntity>,
) {} ) {}
@Process({ name: 'mp4-conversion', concurrency: 1 }) @Process({ name: mp4ConversionProcessorName, concurrency: 1 })
async mp4Conversion(job: Job) { async mp4Conversion(job: Job<IMp4ConversionProcessor>) {
const { asset }: { asset: AssetEntity } = job.data; const { asset } = job.data;
if (asset.mimeType != 'video/mp4') { if (asset.mimeType != 'video/mp4') {
const basePath = APP_UPLOAD_LOCATION; const basePath = APP_UPLOAD_LOCATION;

View File

@ -0,0 +1,23 @@
/**
* Asset Uploaded Queue Jobs
*/
export const assetUploadedProcessorName = 'asset-uploaded';
/**
* Video Conversion Queue Jobs
**/
export const mp4ConversionProcessorName = 'mp4-conversion';
/**
* Thumbnail Generator Queue Jobs
*/
export const generateJPEGThumbnailProcessorName = 'generate-jpeg-thumbnail';
export const generateWEBPThumbnailProcessorName = 'generate-webp-thumbnail';
/**
* Metadata Extraction Queue Jobs
*/
export const exifExtractionProcessorName = 'exif-extraction';
export const videoLengthExtractionProcessorName = 'extract-video-length';
export const objectDetectionProcessorName = 'detect-object';
export const imageTaggingProcessorName = 'tag-image';

View File

@ -0,0 +1,4 @@
export const thumbnailGeneratorQueueName = 'thumbnail-generator-queue';
export const assetUploadedQueueName = 'asset-uploaded-queue';
export const metadataExtractionQueueName = 'metadata-extraction-queue';
export const videoConversionQueueName = 'video-conversion-queue';

View File

@ -0,0 +1,7 @@
export * from './interfaces/asset-uploaded.interface';
export * from './interfaces/metadata-extraction.interface';
export * from './interfaces/video-transcode.interface';
export * from './interfaces/thumbnail-generation.interface';
export * from './constants/job-name.constant';
export * from './constants/queue-name.constant';

View File

@ -0,0 +1,18 @@
import { AssetEntity } from '@app/database/entities/asset.entity';
export interface IAssetUploadedJob {
/**
* The Asset entity that was saved in the database
*/
asset: AssetEntity;
/**
* Original file name
*/
fileName: string;
/**
* File size in byte
*/
fileSize: number;
}

View File

@ -0,0 +1,27 @@
import { AssetEntity } from '@app/database/entities/asset.entity';
export interface IExifExtractionProcessor {
/**
* The Asset entity that was saved in the database
*/
asset: AssetEntity;
/**
* Original file name
*/
fileName: string;
/**
* File size in byte
*/
fileSize: number;
}
export interface IVideoLengthExtractionProcessor {
/**
* The Asset entity that was saved in the database
*/
asset: AssetEntity;
}
export type IMetadataExtractionJob = IExifExtractionProcessor | IVideoLengthExtractionProcessor;

View File

@ -0,0 +1,17 @@
import { AssetEntity } from '@app/database/entities/asset.entity';
export interface JpegGeneratorProcessor {
/**
* The Asset entity that was saved in the database
*/
asset: AssetEntity;
}
export interface WebpGeneratorProcessor {
/**
* The Asset entity that was saved in the database
*/
asset: AssetEntity;
}
export type IThumbnailGenerationJob = JpegGeneratorProcessor | WebpGeneratorProcessor;

View File

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

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": true,
"outDir": "../../dist/libs/job"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

View File

@ -34,6 +34,15 @@
"compilerOptions": { "compilerOptions": {
"tsConfigPath": "libs/database/tsconfig.lib.json" "tsConfigPath": "libs/database/tsconfig.lib.json"
} }
},
"job": {
"type": "library",
"root": "libs/job",
"entryFile": "index",
"sourceRoot": "libs/job/src",
"compilerOptions": {
"tsConfigPath": "libs/job/tsconfig.lib.json"
}
} }
} }
} }

View File

@ -120,7 +120,8 @@
"moduleNameMapper": { "moduleNameMapper": {
"^@app/database(|/.*)$": "<rootDir>/libs/database/src/$1", "^@app/database(|/.*)$": "<rootDir>/libs/database/src/$1",
"@app/database/config/(.*)": "<rootDir>/libs/database/src/config/$1", "@app/database/config/(.*)": "<rootDir>/libs/database/src/config/$1",
"@app/database/config": "<rootDir>/libs/database/src/config" "@app/database/config": "<rootDir>/libs/database/src/config",
"^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1"
} }
} }
} }

View File

@ -21,6 +21,12 @@
], ],
"@app/database/*": [ "@app/database/*": [
"libs/database/src/*" "libs/database/src/*"
],
"@app/job": [
"libs/job/src"
],
"@app/job/*": [
"libs/job/src/*"
] ]
} }
}, },