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:
parent
32b847c26e
commit
76bf1c0379
@ -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);
|
||||||
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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`);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
|
@ -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() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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 });
|
||||||
|
@ -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;
|
||||||
|
23
server/libs/job/src/constants/job-name.constant.ts
Normal file
23
server/libs/job/src/constants/job-name.constant.ts
Normal 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';
|
4
server/libs/job/src/constants/queue-name.constant.ts
Normal file
4
server/libs/job/src/constants/queue-name.constant.ts
Normal 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';
|
7
server/libs/job/src/index.ts
Normal file
7
server/libs/job/src/index.ts
Normal 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';
|
18
server/libs/job/src/interfaces/asset-uploaded.interface.ts
Normal file
18
server/libs/job/src/interfaces/asset-uploaded.interface.ts
Normal 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;
|
||||||
|
}
|
@ -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;
|
@ -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;
|
10
server/libs/job/src/interfaces/video-transcode.interface.ts
Normal file
10
server/libs/job/src/interfaces/video-transcode.interface.ts
Normal 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;
|
9
server/libs/job/tsconfig.lib.json
Normal file
9
server/libs/job/tsconfig.lib.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "../../dist/libs/job"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||||
|
}
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -21,6 +21,12 @@
|
|||||||
],
|
],
|
||||||
"@app/database/*": [
|
"@app/database/*": [
|
||||||
"libs/database/src/*"
|
"libs/database/src/*"
|
||||||
|
],
|
||||||
|
"@app/job": [
|
||||||
|
"libs/job/src"
|
||||||
|
],
|
||||||
|
"@app/job/*": [
|
||||||
|
"libs/job/src/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user