mirror of
https://github.com/immich-app/immich.git
synced 2024-12-25 10:43:13 +02:00
refactor(server): job handlers (#2572)
* refactor(server): job handlers * chore: remove comment * chore: add comments for
This commit is contained in:
parent
d6756f3d81
commit
1c2d83e2c7
@ -1,5 +1,5 @@
|
|||||||
import { AuthUserDto, IJobRepository, JobName } from '@app/domain';
|
import { AuthUserDto, IJobRepository, JobName } from '@app/domain';
|
||||||
import { AssetEntity, UserEntity } from '@app/infra/entities';
|
import { AssetEntity, AssetType, UserEntity } from '@app/infra/entities';
|
||||||
import { IAssetRepository } from './asset-repository';
|
import { IAssetRepository } from './asset-repository';
|
||||||
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
|
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
|
||||||
import { parse } from 'node:path';
|
import { parse } from 'node:path';
|
||||||
@ -43,7 +43,10 @@ export class AssetCore {
|
|||||||
sidecarPath: sidecarFile?.originalPath || null,
|
sidecarPath: sidecarFile?.originalPath || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.jobRepository.queue({ name: JobName.ASSET_UPLOADED, data: { asset } });
|
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: asset.id } });
|
||||||
|
if (asset.type === AssetType.VIDEO) {
|
||||||
|
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } });
|
||||||
|
}
|
||||||
|
|
||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
|
@ -328,8 +328,9 @@ describe('AssetService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
expect(jobMock.queue.mock.calls).toEqual([
|
||||||
[{ name: JobName.ASSET_UPLOADED, data: { asset: assetEntityStub.livePhotoMotionAsset } }],
|
[{ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: assetEntityStub.livePhotoMotionAsset.id } }],
|
||||||
[{ name: JobName.ASSET_UPLOADED, data: { asset: assetEntityStub.livePhotoStillAsset } }],
|
[{ name: JobName.VIDEO_CONVERSION, data: { id: assetEntityStub.livePhotoMotionAsset.id } }],
|
||||||
|
[{ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: assetEntityStub.livePhotoStillAsset.id } }],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
AssetService,
|
|
||||||
FacialRecognitionService,
|
FacialRecognitionService,
|
||||||
|
IDeleteFilesJob,
|
||||||
|
JobItem,
|
||||||
JobName,
|
JobName,
|
||||||
|
JobService,
|
||||||
JOBS_TO_QUEUE,
|
JOBS_TO_QUEUE,
|
||||||
MediaService,
|
MediaService,
|
||||||
MetadataService,
|
MetadataService,
|
||||||
@ -16,12 +18,12 @@ import {
|
|||||||
UserService,
|
UserService,
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
import { getQueueToken } from '@nestjs/bull';
|
import { getQueueToken } from '@nestjs/bull';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { Queue } from 'bull';
|
import { Queue } from 'bull';
|
||||||
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
|
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
|
||||||
|
|
||||||
type JobHandler<T = any> = (data: T) => void | Promise<void>;
|
type JobHandler<T = any> = (data: T) => boolean | Promise<boolean>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProcessorService {
|
export class ProcessorService {
|
||||||
@ -30,8 +32,8 @@ export class ProcessorService {
|
|||||||
// TODO refactor to domain
|
// TODO refactor to domain
|
||||||
private metadataProcessor: MetadataExtractionProcessor,
|
private metadataProcessor: MetadataExtractionProcessor,
|
||||||
|
|
||||||
private assetService: AssetService,
|
|
||||||
private facialRecognitionService: FacialRecognitionService,
|
private facialRecognitionService: FacialRecognitionService,
|
||||||
|
private jobService: JobService,
|
||||||
private mediaService: MediaService,
|
private mediaService: MediaService,
|
||||||
private metadataService: MetadataService,
|
private metadataService: MetadataService,
|
||||||
private personService: PersonService,
|
private personService: PersonService,
|
||||||
@ -43,9 +45,10 @@ export class ProcessorService {
|
|||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private logger = new Logger(ProcessorService.name);
|
||||||
|
|
||||||
private handlers: Record<JobName, JobHandler> = {
|
private handlers: Record<JobName, JobHandler> = {
|
||||||
[JobName.ASSET_UPLOADED]: (data) => this.assetService.handleAssetUpload(data),
|
[JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data),
|
||||||
[JobName.DELETE_FILES]: (data) => this.storageService.handleDeleteFiles(data),
|
|
||||||
[JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(),
|
[JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(),
|
||||||
[JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data),
|
[JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data),
|
||||||
[JobName.QUEUE_OBJECT_TAGGING]: (data) => this.smartInfoService.handleQueueObjectTagging(data),
|
[JobName.QUEUE_OBJECT_TAGGING]: (data) => this.smartInfoService.handleQueueObjectTagging(data),
|
||||||
@ -71,15 +74,14 @@ export class ProcessorService {
|
|||||||
[JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data),
|
[JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data),
|
||||||
[JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),
|
[JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),
|
||||||
[JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleQueueMetadataExtraction(data),
|
[JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleQueueMetadataExtraction(data),
|
||||||
[JobName.EXIF_EXTRACTION]: (data) => this.metadataProcessor.extractExifInfo(data),
|
[JobName.METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleMetadataExtraction(data),
|
||||||
[JobName.EXTRACT_VIDEO_METADATA]: (data) => this.metadataProcessor.extractVideoMetadata(data),
|
|
||||||
[JobName.QUEUE_RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleQueueRecognizeFaces(data),
|
[JobName.QUEUE_RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleQueueRecognizeFaces(data),
|
||||||
[JobName.RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleRecognizeFaces(data),
|
[JobName.RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleRecognizeFaces(data),
|
||||||
[JobName.GENERATE_FACE_THUMBNAIL]: (data) => this.facialRecognitionService.handleGenerateFaceThumbnail(data),
|
[JobName.GENERATE_FACE_THUMBNAIL]: (data) => this.facialRecognitionService.handleGenerateFaceThumbnail(data),
|
||||||
[JobName.PERSON_CLEANUP]: () => this.personService.handlePersonCleanup(),
|
[JobName.PERSON_CLEANUP]: () => this.personService.handlePersonCleanup(),
|
||||||
[JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(data),
|
[JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(data),
|
||||||
[JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data),
|
[JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data),
|
||||||
[JobName.SIDECAR_SYNC]: (data) => this.metadataService.handleSidecarSync(data),
|
[JobName.SIDECAR_SYNC]: () => this.metadataService.handleSidecarSync(),
|
||||||
};
|
};
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
@ -98,7 +100,14 @@ export class ProcessorService {
|
|||||||
await queue.isReady();
|
await queue.isReady();
|
||||||
|
|
||||||
queue.process(jobName, concurrency, async (job): Promise<void> => {
|
queue.process(jobName, concurrency, async (job): Promise<void> => {
|
||||||
await handler(job.data);
|
try {
|
||||||
|
const success = await handler(job.data);
|
||||||
|
if (success) {
|
||||||
|
await this.jobService.onDone({ name: jobName, data: job.data } as JobItem);
|
||||||
|
}
|
||||||
|
} catch (error: Error | any) {
|
||||||
|
this.logger.error(`Unable to run job handler: ${error}`, error?.stack, job.data);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
AssetCore,
|
|
||||||
IAssetJob,
|
|
||||||
IAssetRepository,
|
IAssetRepository,
|
||||||
IBaseJob,
|
IBaseJob,
|
||||||
|
IEntityJob,
|
||||||
IGeocodingRepository,
|
IGeocodingRepository,
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
JobName,
|
JobName,
|
||||||
@ -32,7 +31,6 @@ interface ImmichTags extends Tags {
|
|||||||
|
|
||||||
export class MetadataExtractionProcessor {
|
export class MetadataExtractionProcessor {
|
||||||
private logger = new Logger(MetadataExtractionProcessor.name);
|
private logger = new Logger(MetadataExtractionProcessor.name);
|
||||||
private assetCore: AssetCore;
|
|
||||||
private reverseGeocodingEnabled: boolean;
|
private reverseGeocodingEnabled: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -43,7 +41,6 @@ export class MetadataExtractionProcessor {
|
|||||||
|
|
||||||
configService: ConfigService,
|
configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.assetCore = new AssetCore(assetRepository, jobRepository);
|
|
||||||
this.reverseGeocodingEnabled = !configService.get('DISABLE_REVERSE_GEOCODING');
|
this.reverseGeocodingEnabled = !configService.get('DISABLE_REVERSE_GEOCODING');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,271 +67,262 @@ export class MetadataExtractionProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleQueueMetadataExtraction(job: IBaseJob) {
|
async handleQueueMetadataExtraction(job: IBaseJob) {
|
||||||
try {
|
const { force } = job;
|
||||||
const { force } = job;
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
return force
|
||||||
return force
|
? this.assetRepository.getAll(pagination)
|
||||||
? this.assetRepository.getAll(pagination)
|
: this.assetRepository.getWithout(pagination, WithoutProperty.EXIF);
|
||||||
: this.assetRepository.getWithout(pagination, WithoutProperty.EXIF);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
for await (const assets of assetPagination) {
|
for await (const assets of assetPagination) {
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
const name = asset.type === AssetType.VIDEO ? JobName.EXTRACT_VIDEO_METADATA : JobName.EXIF_EXTRACTION;
|
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id } });
|
||||||
await this.jobRepository.queue({ name, data: { asset } });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
}
|
||||||
this.logger.error(`Unable to queue metadata extraction`, error?.stack);
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleMetadataExtraction({ id }: IEntityJob) {
|
||||||
|
const [asset] = await this.assetRepository.getByIds([id]);
|
||||||
|
if (!asset || !asset.isVisible) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.type === AssetType.VIDEO) {
|
||||||
|
return this.handleVideoMetadataExtraction(asset);
|
||||||
|
} else {
|
||||||
|
return this.handlePhotoMetadataExtraction(asset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async extractExifInfo(job: IAssetJob) {
|
private async handlePhotoMetadataExtraction(asset: AssetEntity) {
|
||||||
let asset = job.asset;
|
const mediaExifData = await exiftool.read<ImmichTags>(asset.originalPath).catch((error: any) => {
|
||||||
|
this.logger.warn(
|
||||||
|
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
||||||
|
error?.stack,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
const sidecarExifData = asset.sidecarPath
|
||||||
const mediaExifData = await exiftool.read<ImmichTags>(asset.originalPath).catch((error: any) => {
|
? await exiftool.read<ImmichTags>(asset.sidecarPath).catch((error: any) => {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
||||||
error?.stack,
|
error?.stack,
|
||||||
);
|
);
|
||||||
return null;
|
|
||||||
});
|
|
||||||
const sidecarExifData = asset.sidecarPath
|
|
||||||
? await exiftool.read<ImmichTags>(asset.sidecarPath).catch((error: any) => {
|
|
||||||
this.logger.warn(
|
|
||||||
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
|
||||||
error?.stack,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const exifToDate = (exifDate: string | ExifDateTime | undefined) => {
|
|
||||||
if (!exifDate) return null;
|
|
||||||
|
|
||||||
if (typeof exifDate === 'string') {
|
|
||||||
return new Date(exifDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
return exifDate.toDate();
|
|
||||||
};
|
|
||||||
|
|
||||||
const exifTimeZone = (exifDate: string | ExifDateTime | undefined) => {
|
|
||||||
if (!exifDate) return null;
|
|
||||||
|
|
||||||
if (typeof exifDate === 'string') {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
})
|
||||||
|
: {};
|
||||||
|
|
||||||
return exifDate.zone ?? null;
|
const exifToDate = (exifDate: string | ExifDateTime | undefined) => {
|
||||||
};
|
if (!exifDate) return null;
|
||||||
|
|
||||||
const getExifProperty = <T extends keyof ImmichTags>(...properties: T[]): any | null => {
|
if (typeof exifDate === 'string') {
|
||||||
for (const property of properties) {
|
return new Date(exifDate);
|
||||||
const value = sidecarExifData?.[property] ?? mediaExifData?.[property];
|
}
|
||||||
if (value !== null && value !== undefined) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
return exifDate.toDate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const exifTimeZone = (exifDate: string | ExifDateTime | undefined) => {
|
||||||
|
if (!exifDate) return null;
|
||||||
|
|
||||||
|
if (typeof exifDate === 'string') {
|
||||||
return null;
|
return null;
|
||||||
};
|
}
|
||||||
|
|
||||||
const timeZone = exifTimeZone(getExifProperty('DateTimeOriginal', 'CreateDate') ?? asset.fileCreatedAt);
|
return exifDate.zone ?? null;
|
||||||
const fileCreatedAt = exifToDate(getExifProperty('DateTimeOriginal', 'CreateDate') ?? asset.fileCreatedAt);
|
};
|
||||||
const fileModifiedAt = exifToDate(getExifProperty('ModifyDate') ?? asset.fileModifiedAt);
|
|
||||||
const fileStats = fs.statSync(asset.originalPath);
|
|
||||||
const fileSizeInBytes = fileStats.size;
|
|
||||||
|
|
||||||
const newExif = new ExifEntity();
|
const getExifProperty = <T extends keyof ImmichTags>(...properties: T[]): any | null => {
|
||||||
newExif.assetId = asset.id;
|
for (const property of properties) {
|
||||||
newExif.fileSizeInByte = fileSizeInBytes;
|
const value = sidecarExifData?.[property] ?? mediaExifData?.[property];
|
||||||
newExif.make = getExifProperty('Make');
|
if (value !== null && value !== undefined) {
|
||||||
newExif.model = getExifProperty('Model');
|
return value;
|
||||||
newExif.exifImageHeight = getExifProperty('ExifImageHeight', 'ImageHeight');
|
|
||||||
newExif.exifImageWidth = getExifProperty('ExifImageWidth', 'ImageWidth');
|
|
||||||
newExif.exposureTime = getExifProperty('ExposureTime');
|
|
||||||
newExif.orientation = getExifProperty('Orientation')?.toString();
|
|
||||||
newExif.dateTimeOriginal = fileCreatedAt;
|
|
||||||
newExif.modifyDate = fileModifiedAt;
|
|
||||||
newExif.timeZone = timeZone;
|
|
||||||
newExif.lensModel = getExifProperty('LensModel');
|
|
||||||
newExif.fNumber = getExifProperty('FNumber');
|
|
||||||
const focalLength = getExifProperty('FocalLength');
|
|
||||||
newExif.focalLength = focalLength ? parseFloat(focalLength) : null;
|
|
||||||
// This is unusual - exifData.ISO should return a number, but experienced that sidecar XMP
|
|
||||||
// files MAY return an array of numbers instead.
|
|
||||||
const iso = getExifProperty('ISO');
|
|
||||||
newExif.iso = Array.isArray(iso) ? iso[0] : iso || null;
|
|
||||||
newExif.latitude = getExifProperty('GPSLatitude');
|
|
||||||
newExif.longitude = getExifProperty('GPSLongitude');
|
|
||||||
newExif.livePhotoCID = getExifProperty('MediaGroupUUID');
|
|
||||||
|
|
||||||
if (newExif.livePhotoCID && !asset.livePhotoVideoId) {
|
|
||||||
const motionAsset = await this.assetCore.findLivePhotoMatch({
|
|
||||||
livePhotoCID: newExif.livePhotoCID,
|
|
||||||
otherAssetId: asset.id,
|
|
||||||
ownerId: asset.ownerId,
|
|
||||||
type: AssetType.VIDEO,
|
|
||||||
});
|
|
||||||
if (motionAsset) {
|
|
||||||
await this.assetCore.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
|
|
||||||
await this.assetCore.save({ id: motionAsset.id, isVisible: false });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.applyReverseGeocoding(asset, newExif);
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
const timeZone = exifTimeZone(getExifProperty('DateTimeOriginal', 'CreateDate') ?? asset.fileCreatedAt);
|
||||||
* IF the EXIF doesn't contain the width and height of the image,
|
const fileCreatedAt = exifToDate(getExifProperty('DateTimeOriginal', 'CreateDate') ?? asset.fileCreatedAt);
|
||||||
* We will use Sharpjs to get the information.
|
const fileModifiedAt = exifToDate(getExifProperty('ModifyDate') ?? asset.fileModifiedAt);
|
||||||
*/
|
const fileStats = fs.statSync(asset.originalPath);
|
||||||
if (!newExif.exifImageHeight || !newExif.exifImageWidth || !newExif.orientation) {
|
const fileSizeInBytes = fileStats.size;
|
||||||
const metadata = await sharp(asset.originalPath).metadata();
|
|
||||||
|
|
||||||
if (newExif.exifImageHeight === null) {
|
const newExif = new ExifEntity();
|
||||||
newExif.exifImageHeight = metadata.height || null;
|
newExif.assetId = asset.id;
|
||||||
}
|
newExif.fileSizeInByte = fileSizeInBytes;
|
||||||
|
newExif.make = getExifProperty('Make');
|
||||||
|
newExif.model = getExifProperty('Model');
|
||||||
|
newExif.exifImageHeight = getExifProperty('ExifImageHeight', 'ImageHeight');
|
||||||
|
newExif.exifImageWidth = getExifProperty('ExifImageWidth', 'ImageWidth');
|
||||||
|
newExif.exposureTime = getExifProperty('ExposureTime');
|
||||||
|
newExif.orientation = getExifProperty('Orientation')?.toString();
|
||||||
|
newExif.dateTimeOriginal = fileCreatedAt;
|
||||||
|
newExif.modifyDate = fileModifiedAt;
|
||||||
|
newExif.timeZone = timeZone;
|
||||||
|
newExif.lensModel = getExifProperty('LensModel');
|
||||||
|
newExif.fNumber = getExifProperty('FNumber');
|
||||||
|
const focalLength = getExifProperty('FocalLength');
|
||||||
|
newExif.focalLength = focalLength ? parseFloat(focalLength) : null;
|
||||||
|
// This is unusual - exifData.ISO should return a number, but experienced that sidecar XMP
|
||||||
|
// files MAY return an array of numbers instead.
|
||||||
|
const iso = getExifProperty('ISO');
|
||||||
|
newExif.iso = Array.isArray(iso) ? iso[0] : iso || null;
|
||||||
|
newExif.latitude = getExifProperty('GPSLatitude');
|
||||||
|
newExif.longitude = getExifProperty('GPSLongitude');
|
||||||
|
newExif.livePhotoCID = getExifProperty('MediaGroupUUID');
|
||||||
|
|
||||||
if (newExif.exifImageWidth === null) {
|
if (newExif.livePhotoCID && !asset.livePhotoVideoId) {
|
||||||
newExif.exifImageWidth = metadata.width || null;
|
const motionAsset = await this.assetRepository.findLivePhotoMatch({
|
||||||
}
|
livePhotoCID: newExif.livePhotoCID,
|
||||||
|
otherAssetId: asset.id,
|
||||||
if (newExif.orientation === null) {
|
ownerId: asset.ownerId,
|
||||||
newExif.orientation = metadata.orientation !== undefined ? `${metadata.orientation}` : null;
|
type: AssetType.VIDEO,
|
||||||
}
|
});
|
||||||
|
if (motionAsset) {
|
||||||
|
await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
|
||||||
|
await this.assetRepository.save({ id: motionAsset.id, isVisible: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
|
|
||||||
asset = await this.assetCore.save({ id: asset.id, fileCreatedAt: fileCreatedAt?.toISOString() });
|
|
||||||
await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { asset } });
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(
|
|
||||||
`Error extracting EXIF ${error} for assetId ${asset.id} at ${asset.originalPath}`,
|
|
||||||
error?.stack,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.applyReverseGeocoding(asset, newExif);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IF the EXIF doesn't contain the width and height of the image,
|
||||||
|
* We will use Sharpjs to get the information.
|
||||||
|
*/
|
||||||
|
if (!newExif.exifImageHeight || !newExif.exifImageWidth || !newExif.orientation) {
|
||||||
|
const metadata = await sharp(asset.originalPath).metadata();
|
||||||
|
|
||||||
|
if (newExif.exifImageHeight === null) {
|
||||||
|
newExif.exifImageHeight = metadata.height || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newExif.exifImageWidth === null) {
|
||||||
|
newExif.exifImageWidth = metadata.width || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newExif.orientation === null) {
|
||||||
|
newExif.orientation = metadata.orientation !== undefined ? `${metadata.orientation}` : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
|
||||||
|
await this.assetRepository.save({ id: asset.id, fileCreatedAt: fileCreatedAt?.toISOString() });
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async extractVideoMetadata(job: IAssetJob) {
|
private async handleVideoMetadataExtraction(asset: AssetEntity) {
|
||||||
let asset = job.asset;
|
const data = await ffprobe(asset.originalPath);
|
||||||
|
const durationString = this.extractDuration(data.format.duration || asset.duration);
|
||||||
|
let fileCreatedAt = asset.fileCreatedAt;
|
||||||
|
|
||||||
if (!asset.isVisible) {
|
const videoTags = data.format.tags;
|
||||||
return;
|
if (videoTags) {
|
||||||
|
if (videoTags['com.apple.quicktime.creationdate']) {
|
||||||
|
fileCreatedAt = String(videoTags['com.apple.quicktime.creationdate']);
|
||||||
|
} else if (videoTags['creation_time']) {
|
||||||
|
fileCreatedAt = String(videoTags['creation_time']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const exifData = await exiftool.read<ImmichTags>(asset.sidecarPath || asset.originalPath).catch((error: any) => {
|
||||||
const data = await ffprobe(asset.originalPath);
|
this.logger.warn(
|
||||||
const durationString = this.extractDuration(data.format.duration || asset.duration);
|
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
||||||
let fileCreatedAt = asset.fileCreatedAt;
|
|
||||||
|
|
||||||
const videoTags = data.format.tags;
|
|
||||||
if (videoTags) {
|
|
||||||
if (videoTags['com.apple.quicktime.creationdate']) {
|
|
||||||
fileCreatedAt = String(videoTags['com.apple.quicktime.creationdate']);
|
|
||||||
} else if (videoTags['creation_time']) {
|
|
||||||
fileCreatedAt = String(videoTags['creation_time']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const exifData = await exiftool.read<ImmichTags>(asset.sidecarPath || asset.originalPath).catch((error: any) => {
|
|
||||||
this.logger.warn(
|
|
||||||
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
|
||||||
error?.stack,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const newExif = new ExifEntity();
|
|
||||||
newExif.assetId = asset.id;
|
|
||||||
newExif.fileSizeInByte = data.format.size || null;
|
|
||||||
newExif.dateTimeOriginal = fileCreatedAt ? new Date(fileCreatedAt) : null;
|
|
||||||
newExif.modifyDate = null;
|
|
||||||
newExif.timeZone = null;
|
|
||||||
newExif.latitude = null;
|
|
||||||
newExif.longitude = null;
|
|
||||||
newExif.city = null;
|
|
||||||
newExif.state = null;
|
|
||||||
newExif.country = null;
|
|
||||||
newExif.fps = null;
|
|
||||||
newExif.livePhotoCID = exifData?.ContentIdentifier || null;
|
|
||||||
|
|
||||||
if (newExif.livePhotoCID) {
|
|
||||||
const photoAsset = await this.assetCore.findLivePhotoMatch({
|
|
||||||
livePhotoCID: newExif.livePhotoCID,
|
|
||||||
ownerId: asset.ownerId,
|
|
||||||
otherAssetId: asset.id,
|
|
||||||
type: AssetType.IMAGE,
|
|
||||||
});
|
|
||||||
if (photoAsset) {
|
|
||||||
await this.assetCore.save({ id: photoAsset.id, livePhotoVideoId: asset.id });
|
|
||||||
await this.assetCore.save({ id: asset.id, isVisible: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoTags && videoTags['location']) {
|
|
||||||
const location = videoTags['location'] as string;
|
|
||||||
const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/;
|
|
||||||
const match = location.match(locationRegex);
|
|
||||||
|
|
||||||
if (match?.length === 3) {
|
|
||||||
newExif.latitude = parseFloat(match[1]);
|
|
||||||
newExif.longitude = parseFloat(match[2]);
|
|
||||||
}
|
|
||||||
} else if (videoTags && videoTags['com.apple.quicktime.location.ISO6709']) {
|
|
||||||
const location = videoTags['com.apple.quicktime.location.ISO6709'] as string;
|
|
||||||
const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/;
|
|
||||||
const match = location.match(locationRegex);
|
|
||||||
|
|
||||||
if (match?.length === 4) {
|
|
||||||
newExif.latitude = parseFloat(match[1]);
|
|
||||||
newExif.longitude = parseFloat(match[2]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newExif.longitude && newExif.latitude) {
|
|
||||||
try {
|
|
||||||
newExif.timeZone = tz_lookup(newExif.latitude, newExif.longitude);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.warn(`Error while calculating timezone from gps coordinates: ${error}`, error?.stack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.applyReverseGeocoding(asset, newExif);
|
|
||||||
|
|
||||||
for (const stream of data.streams) {
|
|
||||||
if (stream.codec_type === 'video') {
|
|
||||||
newExif.exifImageWidth = stream.width || null;
|
|
||||||
newExif.exifImageHeight = stream.height || null;
|
|
||||||
|
|
||||||
if (typeof stream.rotation === 'string') {
|
|
||||||
newExif.orientation = stream.rotation;
|
|
||||||
} else if (typeof stream.rotation === 'number') {
|
|
||||||
newExif.orientation = `${stream.rotation}`;
|
|
||||||
} else {
|
|
||||||
newExif.orientation = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stream.r_frame_rate) {
|
|
||||||
const fpsParts = stream.r_frame_rate.split('/');
|
|
||||||
|
|
||||||
if (fpsParts.length === 2) {
|
|
||||||
newExif.fps = Math.round(parseInt(fpsParts[0]) / parseInt(fpsParts[1]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
|
|
||||||
asset = await this.assetCore.save({ id: asset.id, duration: durationString, fileCreatedAt });
|
|
||||||
await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { asset } });
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(
|
|
||||||
`Error in video metadata extraction due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
|
||||||
error?.stack,
|
error?.stack,
|
||||||
);
|
);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const newExif = new ExifEntity();
|
||||||
|
newExif.assetId = asset.id;
|
||||||
|
newExif.fileSizeInByte = data.format.size || null;
|
||||||
|
newExif.dateTimeOriginal = fileCreatedAt ? new Date(fileCreatedAt) : null;
|
||||||
|
newExif.modifyDate = null;
|
||||||
|
newExif.timeZone = null;
|
||||||
|
newExif.latitude = null;
|
||||||
|
newExif.longitude = null;
|
||||||
|
newExif.city = null;
|
||||||
|
newExif.state = null;
|
||||||
|
newExif.country = null;
|
||||||
|
newExif.fps = null;
|
||||||
|
newExif.livePhotoCID = exifData?.ContentIdentifier || null;
|
||||||
|
|
||||||
|
if (newExif.livePhotoCID) {
|
||||||
|
const photoAsset = await this.assetRepository.findLivePhotoMatch({
|
||||||
|
livePhotoCID: newExif.livePhotoCID,
|
||||||
|
ownerId: asset.ownerId,
|
||||||
|
otherAssetId: asset.id,
|
||||||
|
type: AssetType.IMAGE,
|
||||||
|
});
|
||||||
|
if (photoAsset) {
|
||||||
|
await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: asset.id });
|
||||||
|
await this.assetRepository.save({ id: asset.id, isVisible: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (videoTags && videoTags['location']) {
|
||||||
|
const location = videoTags['location'] as string;
|
||||||
|
const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/;
|
||||||
|
const match = location.match(locationRegex);
|
||||||
|
|
||||||
|
if (match?.length === 3) {
|
||||||
|
newExif.latitude = parseFloat(match[1]);
|
||||||
|
newExif.longitude = parseFloat(match[2]);
|
||||||
|
}
|
||||||
|
} else if (videoTags && videoTags['com.apple.quicktime.location.ISO6709']) {
|
||||||
|
const location = videoTags['com.apple.quicktime.location.ISO6709'] as string;
|
||||||
|
const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/;
|
||||||
|
const match = location.match(locationRegex);
|
||||||
|
|
||||||
|
if (match?.length === 4) {
|
||||||
|
newExif.latitude = parseFloat(match[1]);
|
||||||
|
newExif.longitude = parseFloat(match[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newExif.longitude && newExif.latitude) {
|
||||||
|
try {
|
||||||
|
newExif.timeZone = tz_lookup(newExif.latitude, newExif.longitude);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.warn(`Error while calculating timezone from gps coordinates: ${error}`, error?.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.applyReverseGeocoding(asset, newExif);
|
||||||
|
|
||||||
|
for (const stream of data.streams) {
|
||||||
|
if (stream.codec_type === 'video') {
|
||||||
|
newExif.exifImageWidth = stream.width || null;
|
||||||
|
newExif.exifImageHeight = stream.height || null;
|
||||||
|
|
||||||
|
if (typeof stream.rotation === 'string') {
|
||||||
|
newExif.orientation = stream.rotation;
|
||||||
|
} else if (typeof stream.rotation === 'number') {
|
||||||
|
newExif.orientation = `${stream.rotation}`;
|
||||||
|
} else {
|
||||||
|
newExif.orientation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream.r_frame_rate) {
|
||||||
|
const fpsParts = stream.r_frame_rate.split('/');
|
||||||
|
|
||||||
|
if (fpsParts.length === 2) {
|
||||||
|
newExif.fps = Math.round(parseInt(fpsParts[0]) / parseInt(fpsParts[1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
|
||||||
|
await this.assetRepository.save({ id: asset.id, duration: durationString, fileCreatedAt });
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async applyReverseGeocoding(asset: AssetEntity, newExif: ExifEntity) {
|
private async applyReverseGeocoding(asset: AssetEntity, newExif: ExifEntity) {
|
||||||
|
@ -1,20 +0,0 @@
|
|||||||
import { AssetEntity } from '@app/infra/entities';
|
|
||||||
import { IJobRepository, JobName } from '../job';
|
|
||||||
import { IAssetRepository, LivePhotoSearchOptions } from './asset.repository';
|
|
||||||
|
|
||||||
export class AssetCore {
|
|
||||||
constructor(private assetRepository: IAssetRepository, private jobRepository: IJobRepository) {}
|
|
||||||
|
|
||||||
async save(asset: Partial<AssetEntity>) {
|
|
||||||
const _asset = await this.assetRepository.save(asset);
|
|
||||||
await this.jobRepository.queue({
|
|
||||||
name: JobName.SEARCH_INDEX_ASSET,
|
|
||||||
data: { ids: [_asset.id] },
|
|
||||||
});
|
|
||||||
return _asset;
|
|
||||||
}
|
|
||||||
|
|
||||||
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null> {
|
|
||||||
return this.assetRepository.findLivePhotoMatch(options);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,12 +1,9 @@
|
|||||||
import { AssetEntity, AssetType } from '@app/infra/entities';
|
import { assetEntityStub, authStub, newAssetRepositoryMock } from '../../test';
|
||||||
import { assetEntityStub, authStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
|
|
||||||
import { AssetService, IAssetRepository } from '../asset';
|
import { AssetService, IAssetRepository } from '../asset';
|
||||||
import { IJobRepository, JobName } from '../job';
|
|
||||||
|
|
||||||
describe(AssetService.name, () => {
|
describe(AssetService.name, () => {
|
||||||
let sut: AssetService;
|
let sut: AssetService;
|
||||||
let assetMock: jest.Mocked<IAssetRepository>;
|
let assetMock: jest.Mocked<IAssetRepository>;
|
||||||
let jobMock: jest.Mocked<IJobRepository>;
|
|
||||||
|
|
||||||
it('should work', () => {
|
it('should work', () => {
|
||||||
expect(sut).toBeDefined();
|
expect(sut).toBeDefined();
|
||||||
@ -14,49 +11,7 @@ describe(AssetService.name, () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetMock = newAssetRepositoryMock();
|
assetMock = newAssetRepositoryMock();
|
||||||
jobMock = newJobRepositoryMock();
|
sut = new AssetService(assetMock);
|
||||||
sut = new AssetService(assetMock, jobMock);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe(`handle asset upload`, () => {
|
|
||||||
it('should process an uploaded video', async () => {
|
|
||||||
const data = { asset: { type: AssetType.VIDEO } as AssetEntity };
|
|
||||||
|
|
||||||
await expect(sut.handleAssetUpload(data)).resolves.toBeUndefined();
|
|
||||||
|
|
||||||
expect(jobMock.queue).toHaveBeenCalledTimes(3);
|
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
|
||||||
[{ name: JobName.GENERATE_JPEG_THUMBNAIL, data }],
|
|
||||||
[{ name: JobName.VIDEO_CONVERSION, data }],
|
|
||||||
[{ name: JobName.EXTRACT_VIDEO_METADATA, data }],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should process an uploaded image', async () => {
|
|
||||||
const data = { asset: { type: AssetType.IMAGE } as AssetEntity };
|
|
||||||
|
|
||||||
await sut.handleAssetUpload(data);
|
|
||||||
|
|
||||||
expect(jobMock.queue).toHaveBeenCalledTimes(2);
|
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
|
||||||
[{ name: JobName.GENERATE_JPEG_THUMBNAIL, data }],
|
|
||||||
[{ name: JobName.EXIF_EXTRACTION, data }],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('save', () => {
|
|
||||||
it('should save an asset', async () => {
|
|
||||||
assetMock.save.mockResolvedValue(assetEntityStub.image);
|
|
||||||
|
|
||||||
await sut.save(assetEntityStub.image);
|
|
||||||
|
|
||||||
expect(assetMock.save).toHaveBeenCalledWith(assetEntityStub.image);
|
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
|
||||||
name: JobName.SEARCH_INDEX_ASSET,
|
|
||||||
data: { ids: [assetEntityStub.image.id] },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get map markers', () => {
|
describe('get map markers', () => {
|
||||||
|
@ -1,36 +1,11 @@
|
|||||||
import { AssetEntity, AssetType } from '@app/infra/entities';
|
|
||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { AuthUserDto } from '../auth';
|
import { AuthUserDto } from '../auth';
|
||||||
import { IAssetJob, IJobRepository, JobName } from '../job';
|
|
||||||
import { AssetCore } from './asset.core';
|
|
||||||
import { IAssetRepository } from './asset.repository';
|
import { IAssetRepository } from './asset.repository';
|
||||||
import { MapMarkerDto } from './dto/map-marker.dto';
|
import { MapMarkerDto } from './dto/map-marker.dto';
|
||||||
import { MapMarkerResponseDto } from './response-dto';
|
import { MapMarkerResponseDto } from './response-dto';
|
||||||
|
|
||||||
export class AssetService {
|
export class AssetService {
|
||||||
private assetCore: AssetCore;
|
constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {}
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
|
||||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
|
||||||
) {
|
|
||||||
this.assetCore = new AssetCore(assetRepository, jobRepository);
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleAssetUpload(data: IAssetJob) {
|
|
||||||
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data });
|
|
||||||
|
|
||||||
if (data.asset.type == AssetType.VIDEO) {
|
|
||||||
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data });
|
|
||||||
await this.jobRepository.queue({ name: JobName.EXTRACT_VIDEO_METADATA, data });
|
|
||||||
} else {
|
|
||||||
await this.jobRepository.queue({ name: JobName.EXIF_EXTRACTION, data });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
save(asset: Partial<AssetEntity>) {
|
|
||||||
return this.assetCore.save(asset);
|
|
||||||
}
|
|
||||||
|
|
||||||
getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
|
getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
|
||||||
return this.assetRepository.getMapMarkers(authUser.id, options);
|
return this.assetRepository.getMapMarkers(authUser.id, options);
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
export * from './asset.core';
|
|
||||||
export * from './asset.repository';
|
export * from './asset.repository';
|
||||||
export * from './asset.service';
|
export * from './asset.service';
|
||||||
export * from './response-dto';
|
export * from './response-dto';
|
||||||
|
@ -141,7 +141,7 @@ describe(FacialRecognitionService.name, () => {
|
|||||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES);
|
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES);
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.RECOGNIZE_FACES,
|
name: JobName.RECOGNIZE_FACES,
|
||||||
data: { asset: assetEntityStub.image },
|
data: { id: assetEntityStub.image.id },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -158,25 +158,22 @@ describe(FacialRecognitionService.name, () => {
|
|||||||
expect(assetMock.getAll).toHaveBeenCalled();
|
expect(assetMock.getAll).toHaveBeenCalled();
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.RECOGNIZE_FACES,
|
name: JobName.RECOGNIZE_FACES,
|
||||||
data: { asset: assetEntityStub.image },
|
data: { id: assetEntityStub.image.id },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log an error', async () => {
|
|
||||||
assetMock.getWithout.mockRejectedValue(new Error('Database unavailable'));
|
|
||||||
await sut.handleQueueRecognizeFaces({});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleRecognizeFaces', () => {
|
describe('handleRecognizeFaces', () => {
|
||||||
it('should skip when no resize path', async () => {
|
it('should skip when no resize path', async () => {
|
||||||
await sut.handleRecognizeFaces({ asset: assetEntityStub.noResizePath });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
|
||||||
|
await sut.handleRecognizeFaces({ id: assetEntityStub.noResizePath.id });
|
||||||
expect(machineLearningMock.detectFaces).not.toHaveBeenCalled();
|
expect(machineLearningMock.detectFaces).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle no results', async () => {
|
it('should handle no results', async () => {
|
||||||
machineLearningMock.detectFaces.mockResolvedValue([]);
|
machineLearningMock.detectFaces.mockResolvedValue([]);
|
||||||
await sut.handleRecognizeFaces({ asset: assetEntityStub.image });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||||
|
await sut.handleRecognizeFaces({ id: assetEntityStub.image.id });
|
||||||
expect(machineLearningMock.detectFaces).toHaveBeenCalledWith({
|
expect(machineLearningMock.detectFaces).toHaveBeenCalledWith({
|
||||||
thumbnailPath: assetEntityStub.image.resizePath,
|
thumbnailPath: assetEntityStub.image.resizePath,
|
||||||
});
|
});
|
||||||
@ -187,26 +184,23 @@ describe(FacialRecognitionService.name, () => {
|
|||||||
it('should match existing people', async () => {
|
it('should match existing people', async () => {
|
||||||
machineLearningMock.detectFaces.mockResolvedValue([face.middle]);
|
machineLearningMock.detectFaces.mockResolvedValue([face.middle]);
|
||||||
searchMock.searchFaces.mockResolvedValue(faceSearch.oneMatch);
|
searchMock.searchFaces.mockResolvedValue(faceSearch.oneMatch);
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||||
await sut.handleRecognizeFaces({ asset: assetEntityStub.image });
|
await sut.handleRecognizeFaces({ id: assetEntityStub.image.id });
|
||||||
|
|
||||||
expect(faceMock.create).toHaveBeenCalledWith({
|
expect(faceMock.create).toHaveBeenCalledWith({
|
||||||
personId: 'person-1',
|
personId: 'person-1',
|
||||||
assetId: 'asset-id',
|
assetId: 'asset-id',
|
||||||
embedding: [1, 2, 3, 4],
|
embedding: [1, 2, 3, 4],
|
||||||
});
|
});
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
|
||||||
[{ name: JobName.SEARCH_INDEX_FACE, data: { personId: 'person-1', assetId: 'asset-id' } }],
|
|
||||||
[{ name: JobName.SEARCH_INDEX_ASSET, data: { ids: ['asset-id'] } }],
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create a new person', async () => {
|
it('should create a new person', async () => {
|
||||||
machineLearningMock.detectFaces.mockResolvedValue([face.middle]);
|
machineLearningMock.detectFaces.mockResolvedValue([face.middle]);
|
||||||
searchMock.searchFaces.mockResolvedValue(faceSearch.oneRemoteMatch);
|
searchMock.searchFaces.mockResolvedValue(faceSearch.oneRemoteMatch);
|
||||||
personMock.create.mockResolvedValue(personStub.noName);
|
personMock.create.mockResolvedValue(personStub.noName);
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ asset: assetEntityStub.image });
|
await sut.handleRecognizeFaces({ id: assetEntityStub.image.id });
|
||||||
|
|
||||||
expect(personMock.create).toHaveBeenCalledWith({ ownerId: assetEntityStub.image.ownerId });
|
expect(personMock.create).toHaveBeenCalledWith({ ownerId: assetEntityStub.image.ownerId });
|
||||||
expect(faceMock.create).toHaveBeenCalledWith({
|
expect(faceMock.create).toHaveBeenCalledWith({
|
||||||
@ -234,14 +228,8 @@ describe(FacialRecognitionService.name, () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
[{ name: JobName.SEARCH_INDEX_FACE, data: { personId: 'person-1', assetId: 'asset-id' } }],
|
[{ name: JobName.SEARCH_INDEX_FACE, data: { personId: 'person-1', assetId: 'asset-id' } }],
|
||||||
[{ name: JobName.SEARCH_INDEX_ASSET, data: { ids: ['asset-id'] } }],
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log an error', async () => {
|
|
||||||
machineLearningMock.detectFaces.mockRejectedValue(new Error('machine learning unavailable'));
|
|
||||||
await sut.handleRecognizeFaces({ asset: assetEntityStub.image });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleGenerateFaceThumbnail', () => {
|
describe('handleGenerateFaceThumbnail', () => {
|
||||||
@ -317,10 +305,5 @@ describe(FacialRecognitionService.name, () => {
|
|||||||
size: 250,
|
size: 250,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log an error', async () => {
|
|
||||||
assetMock.getByIds.mockRejectedValue(new Error('Database unavailable'));
|
|
||||||
await sut.handleGenerateFaceThumbnail(face.middle);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -3,7 +3,7 @@ import { join } from 'path';
|
|||||||
import { IAssetRepository, WithoutProperty } from '../asset';
|
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||||
import { MACHINE_LEARNING_ENABLED } from '../domain.constant';
|
import { MACHINE_LEARNING_ENABLED } from '../domain.constant';
|
||||||
import { usePagination } from '../domain.util';
|
import { usePagination } from '../domain.util';
|
||||||
import { IAssetJob, IBaseJob, IFaceThumbnailJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
import { IBaseJob, IEntityJob, IFaceThumbnailJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
||||||
import { CropOptions, FACE_THUMBNAIL_SIZE, IMediaRepository } from '../media';
|
import { CropOptions, FACE_THUMBNAIL_SIZE, IMediaRepository } from '../media';
|
||||||
import { IPersonRepository } from '../person/person.repository';
|
import { IPersonRepository } from '../person/person.repository';
|
||||||
import { ISearchRepository } from '../search/search.repository';
|
import { ISearchRepository } from '../search/search.repository';
|
||||||
@ -27,123 +27,113 @@ export class FacialRecognitionService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handleQueueRecognizeFaces({ force }: IBaseJob) {
|
async handleQueueRecognizeFaces({ force }: IBaseJob) {
|
||||||
try {
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
return force
|
||||||
return force
|
? this.assetRepository.getAll(pagination)
|
||||||
? this.assetRepository.getAll(pagination)
|
: this.assetRepository.getWithout(pagination, WithoutProperty.FACES);
|
||||||
: this.assetRepository.getWithout(pagination, WithoutProperty.FACES);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (force) {
|
if (force) {
|
||||||
const people = await this.personRepository.deleteAll();
|
const people = await this.personRepository.deleteAll();
|
||||||
const faces = await this.searchRepository.deleteAllFaces();
|
const faces = await this.searchRepository.deleteAllFaces();
|
||||||
this.logger.debug(`Deleted ${people} people and ${faces} faces`);
|
this.logger.debug(`Deleted ${people} people and ${faces} faces`);
|
||||||
}
|
|
||||||
for await (const assets of assetPagination) {
|
|
||||||
for (const asset of assets) {
|
|
||||||
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: { asset } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Unable to queue recognize faces`, error?.stack);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for await (const assets of assetPagination) {
|
||||||
|
for (const asset of assets) {
|
||||||
|
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: { id: asset.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleRecognizeFaces(data: IAssetJob) {
|
async handleRecognizeFaces({ id }: IEntityJob) {
|
||||||
const { asset } = data;
|
const [asset] = await this.assetRepository.getByIds([id]);
|
||||||
|
if (!asset || !MACHINE_LEARNING_ENABLED || !asset.resizePath) {
|
||||||
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
|
return false;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const faces = await this.machineLearning.detectFaces({ thumbnailPath: asset.resizePath });
|
||||||
const faces = await this.machineLearning.detectFaces({ thumbnailPath: asset.resizePath });
|
|
||||||
|
|
||||||
this.logger.debug(`${faces.length} faces detected in ${asset.resizePath}`);
|
this.logger.debug(`${faces.length} faces detected in ${asset.resizePath}`);
|
||||||
this.logger.verbose(faces.map((face) => ({ ...face, embedding: `float[${face.embedding.length}]` })));
|
this.logger.verbose(faces.map((face) => ({ ...face, embedding: `float[${face.embedding.length}]` })));
|
||||||
|
|
||||||
for (const { embedding, ...rest } of faces) {
|
for (const { embedding, ...rest } of faces) {
|
||||||
const faceSearchResult = await this.searchRepository.searchFaces(embedding, { ownerId: asset.ownerId });
|
const faceSearchResult = await this.searchRepository.searchFaces(embedding, { ownerId: asset.ownerId });
|
||||||
|
|
||||||
let personId: string | null = null;
|
let personId: string | null = null;
|
||||||
|
|
||||||
// try to find a matching face and link to the associated person
|
// try to find a matching face and link to the associated person
|
||||||
// The closer to 0, the better the match. Range is from 0 to 2
|
// The closer to 0, the better the match. Range is from 0 to 2
|
||||||
if (faceSearchResult.total && faceSearchResult.distances[0] < 0.6) {
|
if (faceSearchResult.total && faceSearchResult.distances[0] < 0.6) {
|
||||||
this.logger.verbose(`Match face with distance ${faceSearchResult.distances[0]}`);
|
this.logger.verbose(`Match face with distance ${faceSearchResult.distances[0]}`);
|
||||||
personId = faceSearchResult.items[0].personId;
|
personId = faceSearchResult.items[0].personId;
|
||||||
}
|
|
||||||
|
|
||||||
if (!personId) {
|
|
||||||
this.logger.debug('No matches, creating a new person.');
|
|
||||||
const person = await this.personRepository.create({ ownerId: asset.ownerId });
|
|
||||||
personId = person.id;
|
|
||||||
await this.jobRepository.queue({
|
|
||||||
name: JobName.GENERATE_FACE_THUMBNAIL,
|
|
||||||
data: { assetId: asset.id, personId, ...rest },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const faceId: AssetFaceId = { assetId: asset.id, personId };
|
|
||||||
|
|
||||||
await this.faceRepository.create({ ...faceId, embedding });
|
|
||||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId });
|
|
||||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// queue all faces for asset
|
if (!personId) {
|
||||||
} catch (error: any) {
|
this.logger.debug('No matches, creating a new person.');
|
||||||
this.logger.error(`Unable run facial recognition pipeline: ${asset.id}`, error?.stack);
|
const person = await this.personRepository.create({ ownerId: asset.ownerId });
|
||||||
|
personId = person.id;
|
||||||
|
await this.jobRepository.queue({
|
||||||
|
name: JobName.GENERATE_FACE_THUMBNAIL,
|
||||||
|
data: { assetId: asset.id, personId, ...rest },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const faceId: AssetFaceId = { assetId: asset.id, personId };
|
||||||
|
|
||||||
|
await this.faceRepository.create({ ...faceId, embedding });
|
||||||
|
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleGenerateFaceThumbnail(data: IFaceThumbnailJob) {
|
async handleGenerateFaceThumbnail(data: IFaceThumbnailJob) {
|
||||||
const { assetId, personId, boundingBox, imageWidth, imageHeight } = data;
|
const { assetId, personId, boundingBox, imageWidth, imageHeight } = data;
|
||||||
|
|
||||||
try {
|
const [asset] = await this.assetRepository.getByIds([assetId]);
|
||||||
const [asset] = await this.assetRepository.getByIds([assetId]);
|
if (!asset || !asset.resizePath) {
|
||||||
if (!asset || !asset.resizePath) {
|
return false;
|
||||||
this.logger.warn(`Asset not found for facial cropping: ${assetId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.verbose(`Cropping face for person: ${personId}`);
|
|
||||||
|
|
||||||
const outputFolder = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId);
|
|
||||||
const output = join(outputFolder, `${personId}.jpeg`);
|
|
||||||
this.storageRepository.mkdirSync(outputFolder);
|
|
||||||
|
|
||||||
const { x1, y1, x2, y2 } = boundingBox;
|
|
||||||
|
|
||||||
const halfWidth = (x2 - x1) / 2;
|
|
||||||
const halfHeight = (y2 - y1) / 2;
|
|
||||||
|
|
||||||
const middleX = Math.round(x1 + halfWidth);
|
|
||||||
const middleY = Math.round(y1 + halfHeight);
|
|
||||||
|
|
||||||
// zoom out 10%
|
|
||||||
const targetHalfSize = Math.floor(Math.max(halfWidth, halfHeight) * 1.1);
|
|
||||||
|
|
||||||
// get the longest distance from the center of the image without overflowing
|
|
||||||
const newHalfSize = Math.min(
|
|
||||||
middleX - Math.max(0, middleX - targetHalfSize),
|
|
||||||
middleY - Math.max(0, middleY - targetHalfSize),
|
|
||||||
Math.min(imageWidth - 1, middleX + targetHalfSize) - middleX,
|
|
||||||
Math.min(imageHeight - 1, middleY + targetHalfSize) - middleY,
|
|
||||||
);
|
|
||||||
|
|
||||||
const cropOptions: CropOptions = {
|
|
||||||
left: middleX - newHalfSize,
|
|
||||||
top: middleY - newHalfSize,
|
|
||||||
width: newHalfSize * 2,
|
|
||||||
height: newHalfSize * 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const croppedOutput = await this.mediaRepository.crop(asset.resizePath, cropOptions);
|
|
||||||
await this.mediaRepository.resize(croppedOutput, output, { size: FACE_THUMBNAIL_SIZE, format: 'jpeg' });
|
|
||||||
await this.personRepository.update({ id: personId, thumbnailPath: output });
|
|
||||||
} catch (error: Error | any) {
|
|
||||||
this.logger.error(`Failed to crop face for asset: ${assetId}, person: ${personId} - ${error}`, error.stack);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.verbose(`Cropping face for person: ${personId}`);
|
||||||
|
|
||||||
|
const outputFolder = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId);
|
||||||
|
const output = join(outputFolder, `${personId}.jpeg`);
|
||||||
|
this.storageRepository.mkdirSync(outputFolder);
|
||||||
|
|
||||||
|
const { x1, y1, x2, y2 } = boundingBox;
|
||||||
|
|
||||||
|
const halfWidth = (x2 - x1) / 2;
|
||||||
|
const halfHeight = (y2 - y1) / 2;
|
||||||
|
|
||||||
|
const middleX = Math.round(x1 + halfWidth);
|
||||||
|
const middleY = Math.round(y1 + halfHeight);
|
||||||
|
|
||||||
|
// zoom out 10%
|
||||||
|
const targetHalfSize = Math.floor(Math.max(halfWidth, halfHeight) * 1.1);
|
||||||
|
|
||||||
|
// get the longest distance from the center of the image without overflowing
|
||||||
|
const newHalfSize = Math.min(
|
||||||
|
middleX - Math.max(0, middleX - targetHalfSize),
|
||||||
|
middleY - Math.max(0, middleY - targetHalfSize),
|
||||||
|
Math.min(imageWidth - 1, middleX + targetHalfSize) - middleX,
|
||||||
|
Math.min(imageHeight - 1, middleY + targetHalfSize) - middleY,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cropOptions: CropOptions = {
|
||||||
|
left: middleX - newHalfSize,
|
||||||
|
top: middleY - newHalfSize,
|
||||||
|
width: newHalfSize * 2,
|
||||||
|
height: newHalfSize * 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const croppedOutput = await this.mediaRepository.crop(asset.resizePath, cropOptions);
|
||||||
|
await this.mediaRepository.resize(croppedOutput, output, { size: FACE_THUMBNAIL_SIZE, format: 'jpeg' });
|
||||||
|
await this.personRepository.update({ id: personId, thumbnailPath: output });
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,9 +19,6 @@ export enum JobCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum JobName {
|
export enum JobName {
|
||||||
// upload
|
|
||||||
ASSET_UPLOADED = 'asset-uploaded',
|
|
||||||
|
|
||||||
// conversion
|
// conversion
|
||||||
QUEUE_VIDEO_CONVERSION = 'queue-video-conversion',
|
QUEUE_VIDEO_CONVERSION = 'queue-video-conversion',
|
||||||
VIDEO_CONVERSION = 'video-conversion',
|
VIDEO_CONVERSION = 'video-conversion',
|
||||||
@ -33,8 +30,7 @@ export enum JobName {
|
|||||||
|
|
||||||
// metadata
|
// metadata
|
||||||
QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction',
|
QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction',
|
||||||
EXIF_EXTRACTION = 'exif-extraction',
|
METADATA_EXTRACTION = 'metadata-extraction',
|
||||||
EXTRACT_VIDEO_METADATA = 'extract-video-metadata',
|
|
||||||
|
|
||||||
// user deletion
|
// user deletion
|
||||||
USER_DELETION = 'user-deletion',
|
USER_DELETION = 'user-deletion',
|
||||||
@ -84,7 +80,6 @@ export const JOBS_ASSET_PAGINATION_SIZE = 1000;
|
|||||||
|
|
||||||
export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
||||||
// misc
|
// misc
|
||||||
[JobName.ASSET_UPLOADED]: QueueName.BACKGROUND_TASK,
|
|
||||||
[JobName.USER_DELETE_CHECK]: QueueName.BACKGROUND_TASK,
|
[JobName.USER_DELETE_CHECK]: QueueName.BACKGROUND_TASK,
|
||||||
[JobName.USER_DELETION]: QueueName.BACKGROUND_TASK,
|
[JobName.USER_DELETION]: QueueName.BACKGROUND_TASK,
|
||||||
[JobName.DELETE_FILES]: QueueName.BACKGROUND_TASK,
|
[JobName.DELETE_FILES]: QueueName.BACKGROUND_TASK,
|
||||||
@ -101,8 +96,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
|||||||
|
|
||||||
// metadata
|
// metadata
|
||||||
[JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
|
[JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
|
||||||
[JobName.EXIF_EXTRACTION]: QueueName.METADATA_EXTRACTION,
|
[JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
|
||||||
[JobName.EXTRACT_VIDEO_METADATA]: QueueName.METADATA_EXTRACTION,
|
|
||||||
|
|
||||||
// storage template
|
// storage template
|
||||||
[JobName.STORAGE_TEMPLATE_MIGRATION]: QueueName.STORAGE_TEMPLATE_MIGRATION,
|
[JobName.STORAGE_TEMPLATE_MIGRATION]: QueueName.STORAGE_TEMPLATE_MIGRATION,
|
||||||
|
@ -1,18 +1,9 @@
|
|||||||
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities';
|
|
||||||
import { BoundingBox } from '../smart-info';
|
import { BoundingBox } from '../smart-info';
|
||||||
|
|
||||||
export interface IBaseJob {
|
export interface IBaseJob {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAlbumJob extends IBaseJob {
|
|
||||||
album: AlbumEntity;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IAssetJob extends IBaseJob {
|
|
||||||
asset: AssetEntity;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IAssetFaceJob extends IBaseJob {
|
export interface IAssetFaceJob extends IBaseJob {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
personId: string;
|
personId: string;
|
||||||
@ -26,6 +17,10 @@ export interface IFaceThumbnailJob extends IAssetFaceJob {
|
|||||||
personId: string;
|
personId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IEntityJob extends IBaseJob {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IBulkEntityJob extends IBaseJob {
|
export interface IBulkEntityJob extends IBaseJob {
|
||||||
ids: string[];
|
ids: string[];
|
||||||
}
|
}
|
||||||
@ -33,7 +28,3 @@ export interface IBulkEntityJob extends IBaseJob {
|
|||||||
export interface IDeleteFilesJob extends IBaseJob {
|
export interface IDeleteFilesJob extends IBaseJob {
|
||||||
files: Array<string | null | undefined>;
|
files: Array<string | null | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IUserDeletionJob extends IBaseJob {
|
|
||||||
user: UserEntity;
|
|
||||||
}
|
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import { JobName, QueueName } from './job.constants';
|
import { JobName, QueueName } from './job.constants';
|
||||||
import {
|
import {
|
||||||
IAssetFaceJob,
|
IAssetFaceJob,
|
||||||
IAssetJob,
|
|
||||||
IBaseJob,
|
IBaseJob,
|
||||||
IBulkEntityJob,
|
IBulkEntityJob,
|
||||||
IDeleteFilesJob,
|
IDeleteFilesJob,
|
||||||
|
IEntityJob,
|
||||||
IFaceThumbnailJob,
|
IFaceThumbnailJob,
|
||||||
IUserDeletionJob,
|
|
||||||
} from './job.interface';
|
} from './job.interface';
|
||||||
|
|
||||||
export interface JobCounts {
|
export interface JobCounts {
|
||||||
@ -24,50 +23,46 @@ export interface QueueStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type JobItem =
|
export type JobItem =
|
||||||
// Asset Upload
|
|
||||||
| { name: JobName.ASSET_UPLOADED; data: IAssetJob }
|
|
||||||
|
|
||||||
// Transcoding
|
// Transcoding
|
||||||
| { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob }
|
| { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob }
|
||||||
| { name: JobName.VIDEO_CONVERSION; data: IAssetJob }
|
| { name: JobName.VIDEO_CONVERSION; data: IEntityJob }
|
||||||
|
|
||||||
// Thumbnails
|
// Thumbnails
|
||||||
| { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob }
|
| { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob }
|
||||||
| { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IAssetJob }
|
| { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IEntityJob }
|
||||||
| { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IAssetJob }
|
| { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IEntityJob }
|
||||||
|
|
||||||
// User Deletion
|
// User Deletion
|
||||||
| { name: JobName.USER_DELETE_CHECK }
|
| { name: JobName.USER_DELETE_CHECK }
|
||||||
| { name: JobName.USER_DELETION; data: IUserDeletionJob }
|
| { name: JobName.USER_DELETION; data: IEntityJob }
|
||||||
|
|
||||||
// Storage Template
|
// Storage Template
|
||||||
| { name: JobName.STORAGE_TEMPLATE_MIGRATION }
|
| { name: JobName.STORAGE_TEMPLATE_MIGRATION }
|
||||||
| { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE; data: IAssetJob }
|
| { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE; data: IEntityJob }
|
||||||
| { name: JobName.SYSTEM_CONFIG_CHANGE }
|
| { name: JobName.SYSTEM_CONFIG_CHANGE }
|
||||||
|
|
||||||
// Metadata Extraction
|
// Metadata Extraction
|
||||||
| { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
|
| { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
|
||||||
| { name: JobName.EXIF_EXTRACTION; data: IAssetJob }
|
| { name: JobName.METADATA_EXTRACTION; data: IEntityJob }
|
||||||
| { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetJob }
|
|
||||||
|
|
||||||
// Sidecar Scanning
|
// Sidecar Scanning
|
||||||
| { name: JobName.QUEUE_SIDECAR; data: IBaseJob }
|
| { name: JobName.QUEUE_SIDECAR; data: IBaseJob }
|
||||||
| { name: JobName.SIDECAR_DISCOVERY; data: IAssetJob }
|
| { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob }
|
||||||
| { name: JobName.SIDECAR_SYNC; data: IAssetJob }
|
| { name: JobName.SIDECAR_SYNC; data: IEntityJob }
|
||||||
|
|
||||||
// Object Tagging
|
// Object Tagging
|
||||||
| { name: JobName.QUEUE_OBJECT_TAGGING; data: IBaseJob }
|
| { name: JobName.QUEUE_OBJECT_TAGGING; data: IBaseJob }
|
||||||
| { name: JobName.DETECT_OBJECTS; data: IAssetJob }
|
| { name: JobName.DETECT_OBJECTS; data: IEntityJob }
|
||||||
| { name: JobName.CLASSIFY_IMAGE; data: IAssetJob }
|
| { name: JobName.CLASSIFY_IMAGE; data: IEntityJob }
|
||||||
|
|
||||||
// Recognize Faces
|
// Recognize Faces
|
||||||
| { name: JobName.QUEUE_RECOGNIZE_FACES; data: IBaseJob }
|
| { name: JobName.QUEUE_RECOGNIZE_FACES; data: IBaseJob }
|
||||||
| { name: JobName.RECOGNIZE_FACES; data: IAssetJob }
|
| { name: JobName.RECOGNIZE_FACES; data: IEntityJob }
|
||||||
| { name: JobName.GENERATE_FACE_THUMBNAIL; data: IFaceThumbnailJob }
|
| { name: JobName.GENERATE_FACE_THUMBNAIL; data: IFaceThumbnailJob }
|
||||||
|
|
||||||
// Clip Embedding
|
// Clip Embedding
|
||||||
| { name: JobName.QUEUE_ENCODE_CLIP; data: IBaseJob }
|
| { name: JobName.QUEUE_ENCODE_CLIP; data: IBaseJob }
|
||||||
| { name: JobName.ENCODE_CLIP; data: IAssetJob }
|
| { name: JobName.ENCODE_CLIP; data: IEntityJob }
|
||||||
|
|
||||||
// Filesystem
|
// Filesystem
|
||||||
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
|
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
|
||||||
|
@ -1,14 +1,20 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { newJobRepositoryMock } from '../../test';
|
import { newAssetRepositoryMock, newCommunicationRepositoryMock, newJobRepositoryMock } from '../../test';
|
||||||
|
import { IAssetRepository } from '../asset';
|
||||||
|
import { ICommunicationRepository } from '../communication';
|
||||||
import { IJobRepository, JobCommand, JobName, JobService, QueueName } from '../job';
|
import { IJobRepository, JobCommand, JobName, JobService, QueueName } from '../job';
|
||||||
|
|
||||||
describe(JobService.name, () => {
|
describe(JobService.name, () => {
|
||||||
let sut: JobService;
|
let sut: JobService;
|
||||||
|
let assetMock: jest.Mocked<IAssetRepository>;
|
||||||
|
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
||||||
let jobMock: jest.Mocked<IJobRepository>;
|
let jobMock: jest.Mocked<IJobRepository>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
assetMock = newAssetRepositoryMock();
|
||||||
|
communicationMock = newCommunicationRepositoryMock();
|
||||||
jobMock = newJobRepositoryMock();
|
jobMock = newJobRepositoryMock();
|
||||||
sut = new JobService(jobMock);
|
sut = new JobService(assetMock, communicationMock, jobMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work', () => {
|
it('should work', () => {
|
||||||
|
@ -1,21 +1,21 @@
|
|||||||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { IAssetRepository, mapAsset } from '../asset';
|
||||||
|
import { CommunicationEvent, ICommunicationRepository } from '../communication';
|
||||||
import { assertMachineLearningEnabled } from '../domain.constant';
|
import { assertMachineLearningEnabled } from '../domain.constant';
|
||||||
import { JobCommandDto } from './dto';
|
import { JobCommandDto } from './dto';
|
||||||
import { JobCommand, JobName, QueueName } from './job.constants';
|
import { JobCommand, JobName, QueueName } from './job.constants';
|
||||||
import { IJobRepository } from './job.repository';
|
import { IJobRepository, JobItem } from './job.repository';
|
||||||
import { AllJobStatusResponseDto, JobStatusDto } from './response-dto';
|
import { AllJobStatusResponseDto, JobStatusDto } from './response-dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JobService {
|
export class JobService {
|
||||||
private logger = new Logger(JobService.name);
|
private logger = new Logger(JobService.name);
|
||||||
|
|
||||||
constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {}
|
constructor(
|
||||||
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||||
async handleNightlyJobs() {
|
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
||||||
await this.jobRepository.queue({ name: JobName.USER_DELETE_CHECK });
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP });
|
) {}
|
||||||
await this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> {
|
handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> {
|
||||||
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
|
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
|
||||||
@ -89,4 +89,51 @@ export class JobService {
|
|||||||
throw new BadRequestException(`Invalid job name: ${name}`);
|
throw new BadRequestException(`Invalid job name: ${name}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleNightlyJobs() {
|
||||||
|
await this.jobRepository.queue({ name: JobName.USER_DELETE_CHECK });
|
||||||
|
await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP });
|
||||||
|
await this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue follow up jobs
|
||||||
|
*/
|
||||||
|
async onDone(item: JobItem) {
|
||||||
|
switch (item.name) {
|
||||||
|
case JobName.SIDECAR_SYNC:
|
||||||
|
case JobName.SIDECAR_DISCOVERY:
|
||||||
|
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: item.data.id } });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case JobName.METADATA_EXTRACTION:
|
||||||
|
await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: item.data });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case JobName.GENERATE_JPEG_THUMBNAIL: {
|
||||||
|
await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: item.data });
|
||||||
|
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: item.data });
|
||||||
|
await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: item.data });
|
||||||
|
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: item.data });
|
||||||
|
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: item.data });
|
||||||
|
|
||||||
|
const [asset] = await this.assetRepository.getByIds([item.data.id]);
|
||||||
|
if (asset) {
|
||||||
|
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In addition to the above jobs, all of these should queue `SEARCH_INDEX_ASSET`
|
||||||
|
switch (item.name) {
|
||||||
|
case JobName.CLASSIFY_IMAGE:
|
||||||
|
case JobName.DETECT_OBJECTS:
|
||||||
|
case JobName.ENCODE_CLIP:
|
||||||
|
case JobName.RECOGNIZE_FACES:
|
||||||
|
case JobName.METADATA_EXTRACTION:
|
||||||
|
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [item.data.id] } });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import { AssetType, SystemConfigKey } from '@app/infra/entities';
|
import { AssetType, SystemConfigKey } from '@app/infra/entities';
|
||||||
import _ from 'lodash';
|
|
||||||
import {
|
import {
|
||||||
assetEntityStub,
|
assetEntityStub,
|
||||||
newAssetRepositoryMock,
|
newAssetRepositoryMock,
|
||||||
newCommunicationRepositoryMock,
|
|
||||||
newJobRepositoryMock,
|
newJobRepositoryMock,
|
||||||
newMediaRepositoryMock,
|
newMediaRepositoryMock,
|
||||||
newStorageRepositoryMock,
|
newStorageRepositoryMock,
|
||||||
@ -11,7 +9,6 @@ import {
|
|||||||
probeStub,
|
probeStub,
|
||||||
} from '../../test';
|
} from '../../test';
|
||||||
import { IAssetRepository, WithoutProperty } from '../asset';
|
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||||
import { ICommunicationRepository } from '../communication';
|
|
||||||
import { IJobRepository, JobName } from '../job';
|
import { IJobRepository, JobName } from '../job';
|
||||||
import { IStorageRepository } from '../storage';
|
import { IStorageRepository } from '../storage';
|
||||||
import { ISystemConfigRepository } from '../system-config';
|
import { ISystemConfigRepository } from '../system-config';
|
||||||
@ -22,7 +19,6 @@ describe(MediaService.name, () => {
|
|||||||
let sut: MediaService;
|
let sut: MediaService;
|
||||||
let assetMock: jest.Mocked<IAssetRepository>;
|
let assetMock: jest.Mocked<IAssetRepository>;
|
||||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||||
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
|
||||||
let jobMock: jest.Mocked<IJobRepository>;
|
let jobMock: jest.Mocked<IJobRepository>;
|
||||||
let mediaMock: jest.Mocked<IMediaRepository>;
|
let mediaMock: jest.Mocked<IMediaRepository>;
|
||||||
let storageMock: jest.Mocked<IStorageRepository>;
|
let storageMock: jest.Mocked<IStorageRepository>;
|
||||||
@ -30,12 +26,11 @@ describe(MediaService.name, () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetMock = newAssetRepositoryMock();
|
assetMock = newAssetRepositoryMock();
|
||||||
configMock = newSystemConfigRepositoryMock();
|
configMock = newSystemConfigRepositoryMock();
|
||||||
communicationMock = newCommunicationRepositoryMock();
|
|
||||||
jobMock = newJobRepositoryMock();
|
jobMock = newJobRepositoryMock();
|
||||||
mediaMock = newMediaRepositoryMock();
|
mediaMock = newMediaRepositoryMock();
|
||||||
storageMock = newStorageRepositoryMock();
|
storageMock = newStorageRepositoryMock();
|
||||||
|
|
||||||
sut = new MediaService(assetMock, communicationMock, jobMock, mediaMock, storageMock, configMock);
|
sut = new MediaService(assetMock, jobMock, mediaMock, storageMock, configMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
@ -55,7 +50,7 @@ describe(MediaService.name, () => {
|
|||||||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.GENERATE_JPEG_THUMBNAIL,
|
name: JobName.GENERATE_JPEG_THUMBNAIL,
|
||||||
data: { asset: assetEntityStub.image },
|
data: { id: assetEntityStub.image.id },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -71,23 +66,15 @@ describe(MediaService.name, () => {
|
|||||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
|
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.GENERATE_JPEG_THUMBNAIL,
|
name: JobName.GENERATE_JPEG_THUMBNAIL,
|
||||||
data: { asset: assetEntityStub.image },
|
data: { id: assetEntityStub.image.id },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log an error', async () => {
|
|
||||||
assetMock.getAll.mockRejectedValue(new Error('database unavailable'));
|
|
||||||
|
|
||||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
|
||||||
|
|
||||||
expect(assetMock.getAll).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleGenerateJpegThumbnail', () => {
|
describe('handleGenerateJpegThumbnail', () => {
|
||||||
it('should generate a thumbnail for an image', async () => {
|
it('should generate a thumbnail for an image', async () => {
|
||||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||||
await sut.handleGenerateJpegThumbnail({ asset: _.cloneDeep(assetEntityStub.image) });
|
await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
|
||||||
|
|
||||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||||
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', {
|
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', {
|
||||||
@ -105,7 +92,7 @@ describe(MediaService.name, () => {
|
|||||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||||
mediaMock.resize.mockRejectedValue(new Error('unsupported format'));
|
mediaMock.resize.mockRejectedValue(new Error('unsupported format'));
|
||||||
|
|
||||||
await sut.handleGenerateJpegThumbnail({ asset: _.cloneDeep(assetEntityStub.image) });
|
await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
|
||||||
|
|
||||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||||
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', {
|
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', {
|
||||||
@ -124,7 +111,7 @@ describe(MediaService.name, () => {
|
|||||||
|
|
||||||
it('should generate a thumbnail for a video', async () => {
|
it('should generate a thumbnail for a video', async () => {
|
||||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||||
await sut.handleGenerateJpegThumbnail({ asset: _.cloneDeep(assetEntityStub.video) });
|
await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.video.id });
|
||||||
|
|
||||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||||
expect(mediaMock.extractVideoThumbnail).toHaveBeenCalledWith(
|
expect(mediaMock.extractVideoThumbnail).toHaveBeenCalledWith(
|
||||||
@ -138,37 +125,22 @@ describe(MediaService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should queue some jobs', async () => {
|
it('should run successfully', async () => {
|
||||||
const asset = _.cloneDeep(assetEntityStub.image);
|
|
||||||
assetMock.getByIds.mockResolvedValue([asset]);
|
|
||||||
await sut.handleGenerateJpegThumbnail({ asset });
|
|
||||||
|
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } });
|
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.CLASSIFY_IMAGE, data: { asset } });
|
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.DETECT_OBJECTS, data: { asset } });
|
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { asset } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should log an error', async () => {
|
|
||||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||||
mediaMock.resize.mockRejectedValue(new Error('unsupported format'));
|
await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
|
||||||
mediaMock.extractThumbnailFromExif.mockRejectedValue(new Error('unsupported format'));
|
|
||||||
|
|
||||||
await sut.handleGenerateJpegThumbnail({ asset: assetEntityStub.image });
|
|
||||||
|
|
||||||
expect(assetMock.save).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleGenerateWebpThumbnail', () => {
|
describe('handleGenerateWebpThumbnail', () => {
|
||||||
it('should skip thumbnail generate if resize path is missing', async () => {
|
it('should skip thumbnail generate if resize path is missing', async () => {
|
||||||
await sut.handleGenerateWepbThumbnail({ asset: assetEntityStub.noResizePath });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
|
||||||
|
await sut.handleGenerateWepbThumbnail({ id: assetEntityStub.noResizePath.id });
|
||||||
expect(mediaMock.resize).not.toHaveBeenCalled();
|
expect(mediaMock.resize).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate a thumbnail', async () => {
|
it('should generate a thumbnail', async () => {
|
||||||
await sut.handleGenerateWepbThumbnail({ asset: assetEntityStub.image });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||||
|
await sut.handleGenerateWepbThumbnail({ id: assetEntityStub.image.id });
|
||||||
|
|
||||||
expect(mediaMock.resize).toHaveBeenCalledWith(
|
expect(mediaMock.resize).toHaveBeenCalledWith(
|
||||||
'/uploads/user-id/thumbs/path.ext',
|
'/uploads/user-id/thumbs/path.ext',
|
||||||
@ -177,14 +149,6 @@ describe(MediaService.name, () => {
|
|||||||
);
|
);
|
||||||
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', webpPath: '/uploads/user-id/thumbs/path.ext' });
|
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', webpPath: '/uploads/user-id/thumbs/path.ext' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log an error', async () => {
|
|
||||||
mediaMock.resize.mockRejectedValue(new Error('service unavailable'));
|
|
||||||
|
|
||||||
await sut.handleGenerateWepbThumbnail({ asset: assetEntityStub.image });
|
|
||||||
|
|
||||||
expect(mediaMock.resize).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleQueueVideoConversion', () => {
|
describe('handleQueueVideoConversion', () => {
|
||||||
@ -200,7 +164,7 @@ describe(MediaService.name, () => {
|
|||||||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.VIDEO_CONVERSION,
|
name: JobName.VIDEO_CONVERSION,
|
||||||
data: { asset: assetEntityStub.video },
|
data: { id: assetEntityStub.video.id },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -216,17 +180,9 @@ describe(MediaService.name, () => {
|
|||||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.ENCODED_VIDEO);
|
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.ENCODED_VIDEO);
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.VIDEO_CONVERSION,
|
name: JobName.VIDEO_CONVERSION,
|
||||||
data: { asset: assetEntityStub.video },
|
data: { id: assetEntityStub.video.id },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log an error', async () => {
|
|
||||||
assetMock.getAll.mockRejectedValue(new Error('database unavailable'));
|
|
||||||
|
|
||||||
await sut.handleQueueVideoConversion({ force: true });
|
|
||||||
|
|
||||||
expect(assetMock.getAll).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleVideoConversion', () => {
|
describe('handleVideoConversion', () => {
|
||||||
@ -234,18 +190,11 @@ describe(MediaService.name, () => {
|
|||||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log an error', async () => {
|
|
||||||
mediaMock.transcode.mockRejectedValue(new Error('unable to transcode'));
|
|
||||||
|
|
||||||
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
|
||||||
|
|
||||||
expect(storageMock.mkdirSync).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should transcode the longest stream', async () => {
|
it('should transcode the longest stream', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||||
|
|
||||||
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||||
|
|
||||||
expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext');
|
expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext');
|
||||||
expect(configMock.load).toHaveBeenCalled();
|
expect(configMock.load).toHaveBeenCalled();
|
||||||
@ -262,20 +211,23 @@ describe(MediaService.name, () => {
|
|||||||
|
|
||||||
it('should skip a video without any streams', async () => {
|
it('should skip a video without any streams', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
|
mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
|
||||||
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||||
|
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||||
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip a video without any height', async () => {
|
it('should skip a video without any height', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.noHeight);
|
mediaMock.probe.mockResolvedValue(probeStub.noHeight);
|
||||||
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||||
|
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||||
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should transcode when set to all', async () => {
|
it('should transcode when set to all', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'all' }]);
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'all' }]);
|
||||||
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||||
|
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
'upload/encoded-video/user-id/asset-id.mp4',
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
@ -289,7 +241,7 @@ describe(MediaService.name, () => {
|
|||||||
it('should transcode when optimal and too big', async () => {
|
it('should transcode when optimal and too big', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
|
||||||
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
'upload/encoded-video/user-id/asset-id.mp4',
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
@ -310,7 +262,8 @@ describe(MediaService.name, () => {
|
|||||||
it('should transcode with alternate scaling video is vertical', async () => {
|
it('should transcode with alternate scaling video is vertical', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
|
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
|
||||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
|
||||||
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||||
|
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
'upload/encoded-video/user-id/asset-id.mp4',
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
@ -331,7 +284,8 @@ describe(MediaService.name, () => {
|
|||||||
it('should transcode when audio doesnt match target', async () => {
|
it('should transcode when audio doesnt match target', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.audioStreamMp3);
|
mediaMock.probe.mockResolvedValue(probeStub.audioStreamMp3);
|
||||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
|
||||||
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||||
|
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
'upload/encoded-video/user-id/asset-id.mp4',
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
@ -352,7 +306,8 @@ describe(MediaService.name, () => {
|
|||||||
it('should transcode when container doesnt match target', async () => {
|
it('should transcode when container doesnt match target', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
|
||||||
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||||
|
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
'upload/encoded-video/user-id/asset-id.mp4',
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
@ -373,14 +328,16 @@ describe(MediaService.name, () => {
|
|||||||
it('should not transcode an invalid transcode value', async () => {
|
it('should not transcode an invalid transcode value', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'invalid' }]);
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'invalid' }]);
|
||||||
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||||
|
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||||
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set max bitrate if above 0', async () => {
|
it('should set max bitrate if above 0', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }]);
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }]);
|
||||||
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||||
|
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
'upload/encoded-video/user-id/asset-id.mp4',
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
@ -405,7 +362,8 @@ describe(MediaService.name, () => {
|
|||||||
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' },
|
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' },
|
||||||
{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true },
|
{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true },
|
||||||
]);
|
]);
|
||||||
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||||
|
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
'upload/encoded-video/user-id/asset-id.mp4',
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
@ -428,7 +386,8 @@ describe(MediaService.name, () => {
|
|||||||
it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => {
|
it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }]);
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }]);
|
||||||
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||||
|
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
'upload/encoded-video/user-id/asset-id.mp4',
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
@ -452,7 +411,8 @@ describe(MediaService.name, () => {
|
|||||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' },
|
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' },
|
||||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
|
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
|
||||||
]);
|
]);
|
||||||
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||||
|
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
'upload/encoded-video/user-id/asset-id.mp4',
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
@ -479,7 +439,8 @@ describe(MediaService.name, () => {
|
|||||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' },
|
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' },
|
||||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
|
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
|
||||||
]);
|
]);
|
||||||
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||||
|
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
'upload/encoded-video/user-id/asset-id.mp4',
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
@ -503,7 +464,8 @@ describe(MediaService.name, () => {
|
|||||||
it('should disable thread pooling for x264/x265 if thread limit is above 0', async () => {
|
it('should disable thread pooling for x264/x265 if thread limit is above 0', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]);
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]);
|
||||||
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||||
|
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
'upload/encoded-video/user-id/asset-id.mp4',
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { AssetEntity, AssetType, TranscodePreset } from '@app/infra/entities';
|
import { AssetEntity, AssetType, TranscodePreset } from '@app/infra/entities';
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { IAssetRepository, mapAsset, WithoutProperty } from '../asset';
|
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||||
import { CommunicationEvent, ICommunicationRepository } from '../communication';
|
|
||||||
import { usePagination } from '../domain.util';
|
import { usePagination } from '../domain.util';
|
||||||
import { IAssetJob, IBaseJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
import { IBaseJob, IEntityJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
||||||
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
|
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
|
||||||
import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
|
import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
|
||||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||||
@ -19,7 +18,6 @@ export class MediaService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||||
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
|
||||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
||||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||||
@ -28,155 +26,128 @@ export class MediaService {
|
|||||||
this.configCore = new SystemConfigCore(systemConfig);
|
this.configCore = new SystemConfigCore(systemConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleQueueGenerateThumbnails(job: IBaseJob): Promise<void> {
|
async handleQueueGenerateThumbnails(job: IBaseJob) {
|
||||||
try {
|
const { force } = job;
|
||||||
const { force } = job;
|
|
||||||
|
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||||
return force
|
return force
|
||||||
? this.assetRepository.getAll(pagination)
|
? this.assetRepository.getAll(pagination)
|
||||||
: this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL);
|
: this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL);
|
||||||
});
|
});
|
||||||
|
|
||||||
for await (const assets of assetPagination) {
|
for await (const assets of assetPagination) {
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } });
|
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: asset.id } });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error('Failed to queue generate thumbnail jobs', error.stack);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleGenerateJpegThumbnail(data: IAssetJob): Promise<void> {
|
async handleGenerateJpegThumbnail({ id }: IEntityJob) {
|
||||||
const [asset] = await this.assetRepository.getByIds([data.asset.id]);
|
const [asset] = await this.assetRepository.getByIds([id]);
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
this.logger.warn(
|
return false;
|
||||||
`Asset not found: ${data.asset.id} - Original Path: ${data.asset.originalPath} - Resize Path: ${data.asset.resizePath}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const resizePath = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId);
|
||||||
const resizePath = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId);
|
this.storageRepository.mkdirSync(resizePath);
|
||||||
this.storageRepository.mkdirSync(resizePath);
|
const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
|
||||||
const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
|
|
||||||
|
|
||||||
if (asset.type == AssetType.IMAGE) {
|
if (asset.type == AssetType.IMAGE) {
|
||||||
try {
|
try {
|
||||||
await this.mediaRepository.resize(asset.originalPath, jpegThumbnailPath, {
|
await this.mediaRepository.resize(asset.originalPath, jpegThumbnailPath, {
|
||||||
size: JPEG_THUMBNAIL_SIZE,
|
size: JPEG_THUMBNAIL_SIZE,
|
||||||
format: 'jpeg',
|
format: 'jpeg',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Failed to generate jpeg thumbnail using sharp, trying with exiftool-vendored (asset=${asset.id})`,
|
`Failed to generate jpeg thumbnail using sharp, trying with exiftool-vendored (asset=${asset.id})`,
|
||||||
);
|
);
|
||||||
await this.mediaRepository.extractThumbnailFromExif(asset.originalPath, jpegThumbnailPath);
|
await this.mediaRepository.extractThumbnailFromExif(asset.originalPath, jpegThumbnailPath);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.type == AssetType.VIDEO) {
|
|
||||||
this.logger.log('Start Generating Video Thumbnail');
|
|
||||||
await this.mediaRepository.extractVideoThumbnail(asset.originalPath, jpegThumbnailPath, JPEG_THUMBNAIL_SIZE);
|
|
||||||
this.logger.log(`Generating Video Thumbnail Success ${asset.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.assetRepository.save({ id: asset.id, resizePath: jpegThumbnailPath });
|
|
||||||
|
|
||||||
asset.resizePath = jpegThumbnailPath;
|
|
||||||
|
|
||||||
await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } });
|
|
||||||
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { asset } });
|
|
||||||
await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } });
|
|
||||||
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
|
|
||||||
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: { asset } });
|
|
||||||
|
|
||||||
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to generate thumbnail for asset: ${asset.id}/${asset.type}`, error.stack);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (asset.type == AssetType.VIDEO) {
|
||||||
|
this.logger.log('Start Generating Video Thumbnail');
|
||||||
|
await this.mediaRepository.extractVideoThumbnail(asset.originalPath, jpegThumbnailPath, JPEG_THUMBNAIL_SIZE);
|
||||||
|
this.logger.log(`Generating Video Thumbnail Success ${asset.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.assetRepository.save({ id: asset.id, resizePath: jpegThumbnailPath });
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleGenerateWepbThumbnail(data: IAssetJob): Promise<void> {
|
async handleGenerateWepbThumbnail({ id }: IEntityJob) {
|
||||||
const { asset } = data;
|
const [asset] = await this.assetRepository.getByIds([id]);
|
||||||
|
if (!asset || !asset.resizePath) {
|
||||||
if (!asset.resizePath) {
|
return false;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const webpPath = asset.resizePath.replace('jpeg', 'webp');
|
const webpPath = asset.resizePath.replace('jpeg', 'webp');
|
||||||
|
|
||||||
try {
|
await this.mediaRepository.resize(asset.resizePath, webpPath, { size: WEBP_THUMBNAIL_SIZE, format: 'webp' });
|
||||||
await this.mediaRepository.resize(asset.resizePath, webpPath, { size: WEBP_THUMBNAIL_SIZE, format: 'webp' });
|
await this.assetRepository.save({ id: asset.id, webpPath: webpPath });
|
||||||
await this.assetRepository.save({ id: asset.id, webpPath: webpPath });
|
|
||||||
} catch (error: any) {
|
return true;
|
||||||
this.logger.error(`Failed to generate webp thumbnail for asset: ${asset.id}`, error.stack);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleQueueVideoConversion(job: IBaseJob) {
|
async handleQueueVideoConversion(job: IBaseJob) {
|
||||||
const { force } = job;
|
const { force } = job;
|
||||||
|
|
||||||
try {
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
return force
|
||||||
return force
|
? this.assetRepository.getAll(pagination, { type: AssetType.VIDEO })
|
||||||
? this.assetRepository.getAll(pagination, { type: AssetType.VIDEO })
|
: this.assetRepository.getWithout(pagination, WithoutProperty.ENCODED_VIDEO);
|
||||||
: this.assetRepository.getWithout(pagination, WithoutProperty.ENCODED_VIDEO);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
for await (const assets of assetPagination) {
|
for await (const assets of assetPagination) {
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { asset } });
|
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error('Failed to queue video conversions', error.stack);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleVideoConversion(job: IAssetJob) {
|
async handleVideoConversion({ id }: IEntityJob) {
|
||||||
const [asset] = await this.assetRepository.getByIds([job.asset.id]);
|
const [asset] = await this.assetRepository.getByIds([id]);
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
this.logger.warn(`Asset not found: ${job.asset.id} - Original Path: ${job.asset.originalPath}`);
|
return false;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const input = asset.originalPath;
|
||||||
const input = asset.originalPath;
|
const outputFolder = this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, asset.ownerId);
|
||||||
const outputFolder = this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, asset.ownerId);
|
const output = join(outputFolder, `${asset.id}.mp4`);
|
||||||
const output = join(outputFolder, `${asset.id}.mp4`);
|
this.storageRepository.mkdirSync(outputFolder);
|
||||||
this.storageRepository.mkdirSync(outputFolder);
|
|
||||||
|
|
||||||
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input);
|
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input);
|
||||||
const mainVideoStream = this.getMainVideoStream(videoStreams);
|
const mainVideoStream = this.getMainVideoStream(videoStreams);
|
||||||
const mainAudioStream = this.getMainAudioStream(audioStreams);
|
const mainAudioStream = this.getMainAudioStream(audioStreams);
|
||||||
const containerExtension = format.formatName;
|
const containerExtension = format.formatName;
|
||||||
if (!mainVideoStream || !mainAudioStream || !containerExtension) {
|
if (!mainVideoStream || !mainAudioStream || !containerExtension) {
|
||||||
return;
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
const { ffmpeg: config } = await this.configCore.getConfig();
|
|
||||||
|
|
||||||
const required = this.isTranscodeRequired(asset, mainVideoStream, mainAudioStream, containerExtension, config);
|
|
||||||
if (!required) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputOptions = this.getFfmpegOptions(mainVideoStream, config);
|
|
||||||
const twoPass = this.eligibleForTwoPass(config);
|
|
||||||
|
|
||||||
this.logger.log(`Start encoding video ${asset.id} ${outputOptions}`);
|
|
||||||
await this.mediaRepository.transcode(input, output, { outputOptions, twoPass });
|
|
||||||
|
|
||||||
this.logger.log(`Encoding success ${asset.id}`);
|
|
||||||
|
|
||||||
await this.assetRepository.save({ id: asset.id, encodedVideoPath: output });
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to handle video conversion for asset: ${asset.id}`, error.stack);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { ffmpeg: config } = await this.configCore.getConfig();
|
||||||
|
|
||||||
|
const required = this.isTranscodeRequired(asset, mainVideoStream, mainAudioStream, containerExtension, config);
|
||||||
|
if (!required) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputOptions = this.getFfmpegOptions(mainVideoStream, config);
|
||||||
|
const twoPass = this.eligibleForTwoPass(config);
|
||||||
|
|
||||||
|
this.logger.log(`Start encoding video ${asset.id} ${outputOptions}`);
|
||||||
|
await this.mediaRepository.transcode(input, output, { outputOptions, twoPass });
|
||||||
|
|
||||||
|
this.logger.log(`Encoding success ${asset.id}`);
|
||||||
|
|
||||||
|
await this.assetRepository.save({ id: asset.id, encodedVideoPath: output });
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMainVideoStream(streams: VideoStreamInfo[]): VideoStreamInfo | null {
|
private getMainVideoStream(streams: VideoStreamInfo[]): VideoStreamInfo | null {
|
||||||
|
@ -33,7 +33,7 @@ describe(MetadataService.name, () => {
|
|||||||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.SIDECAR_SYNC,
|
name: JobName.SIDECAR_SYNC,
|
||||||
data: { asset: assetEntityStub.sidecar },
|
data: { id: assetEntityStub.sidecar.id },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -46,95 +46,59 @@ describe(MetadataService.name, () => {
|
|||||||
expect(assetMock.getWith).not.toHaveBeenCalled();
|
expect(assetMock.getWith).not.toHaveBeenCalled();
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.SIDECAR_DISCOVERY,
|
name: JobName.SIDECAR_DISCOVERY,
|
||||||
data: { asset: assetEntityStub.image },
|
data: { id: assetEntityStub.image.id },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log an error', async () => {
|
|
||||||
assetMock.getWith.mockRejectedValue(new Error('database unavailable'));
|
|
||||||
await sut.handleQueueSidecar({ force: true });
|
|
||||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleSidecarSync', () => {
|
describe('handleSidecarSync', () => {
|
||||||
it('should skip hidden assets', async () => {
|
it('should not error', async () => {
|
||||||
await sut.handleSidecarSync({ asset: assetEntityStub.livePhotoMotionAsset });
|
await sut.handleSidecarSync();
|
||||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle video assets', async () => {
|
|
||||||
await sut.handleSidecarSync({ asset: assetEntityStub.video });
|
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
|
||||||
name: JobName.EXTRACT_VIDEO_METADATA,
|
|
||||||
data: { asset: assetEntityStub.video },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle image assets', async () => {
|
|
||||||
await sut.handleSidecarSync({ asset: assetEntityStub.image });
|
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
|
||||||
name: JobName.EXIF_EXTRACTION,
|
|
||||||
data: { asset: assetEntityStub.image },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should log an error', async () => {
|
|
||||||
jobMock.queue.mockRejectedValue(new Error('queue job failed'));
|
|
||||||
await sut.handleSidecarSync({ asset: assetEntityStub.image });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleSidecarDiscovery', () => {
|
describe('handleSidecarDiscovery', () => {
|
||||||
it('should skip hidden assets', async () => {
|
it('should skip hidden assets', async () => {
|
||||||
await sut.handleSidecarDiscovery({ asset: assetEntityStub.livePhotoMotionAsset });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.livePhotoMotionAsset]);
|
||||||
|
await sut.handleSidecarDiscovery({ id: assetEntityStub.livePhotoMotionAsset.id });
|
||||||
expect(storageMock.checkFileExists).not.toHaveBeenCalled();
|
expect(storageMock.checkFileExists).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip assets with a sidecar path', async () => {
|
it('should skip assets with a sidecar path', async () => {
|
||||||
await sut.handleSidecarDiscovery({ asset: assetEntityStub.sidecar });
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.sidecar]);
|
||||||
|
await sut.handleSidecarDiscovery({ id: assetEntityStub.sidecar.id });
|
||||||
expect(storageMock.checkFileExists).not.toHaveBeenCalled();
|
expect(storageMock.checkFileExists).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should do nothing when a sidecar is not found ', async () => {
|
it('should do nothing when a sidecar is not found ', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||||
storageMock.checkFileExists.mockResolvedValue(false);
|
storageMock.checkFileExists.mockResolvedValue(false);
|
||||||
await sut.handleSidecarDiscovery({ asset: assetEntityStub.image });
|
await sut.handleSidecarDiscovery({ id: assetEntityStub.image.id });
|
||||||
expect(assetMock.save).not.toHaveBeenCalled();
|
expect(assetMock.save).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update a image asset when a sidecar is found', async () => {
|
it('should update a image asset when a sidecar is found', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||||
assetMock.save.mockResolvedValue(assetEntityStub.image);
|
assetMock.save.mockResolvedValue(assetEntityStub.image);
|
||||||
storageMock.checkFileExists.mockResolvedValue(true);
|
storageMock.checkFileExists.mockResolvedValue(true);
|
||||||
await sut.handleSidecarDiscovery({ asset: assetEntityStub.image });
|
await sut.handleSidecarDiscovery({ id: assetEntityStub.image.id });
|
||||||
expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.W_OK);
|
expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.W_OK);
|
||||||
expect(assetMock.save).toHaveBeenCalledWith({
|
expect(assetMock.save).toHaveBeenCalledWith({
|
||||||
id: assetEntityStub.image.id,
|
id: assetEntityStub.image.id,
|
||||||
sidecarPath: '/original/path.ext.xmp',
|
sidecarPath: '/original/path.ext.xmp',
|
||||||
});
|
});
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
|
||||||
name: JobName.EXIF_EXTRACTION,
|
|
||||||
data: { asset: assetEntityStub.image },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update a video asset when a sidecar is found', async () => {
|
it('should update a video asset when a sidecar is found', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||||
assetMock.save.mockResolvedValue(assetEntityStub.video);
|
assetMock.save.mockResolvedValue(assetEntityStub.video);
|
||||||
storageMock.checkFileExists.mockResolvedValue(true);
|
storageMock.checkFileExists.mockResolvedValue(true);
|
||||||
await sut.handleSidecarDiscovery({ asset: assetEntityStub.video });
|
await sut.handleSidecarDiscovery({ id: assetEntityStub.video.id });
|
||||||
expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.W_OK);
|
expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.W_OK);
|
||||||
expect(assetMock.save).toHaveBeenCalledWith({
|
expect(assetMock.save).toHaveBeenCalledWith({
|
||||||
id: assetEntityStub.image.id,
|
id: assetEntityStub.image.id,
|
||||||
sidecarPath: '/original/path.ext.xmp',
|
sidecarPath: '/original/path.ext.xmp',
|
||||||
});
|
});
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
|
||||||
name: JobName.EXTRACT_VIDEO_METADATA,
|
|
||||||
data: { asset: assetEntityStub.video },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should log an error', async () => {
|
|
||||||
storageMock.checkFileExists.mockRejectedValue(new Error('bad permission'));
|
|
||||||
await sut.handleSidecarDiscovery({ asset: assetEntityStub.image });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,77 +1,54 @@
|
|||||||
import { AssetType } from '@app/infra/entities';
|
import { Inject } from '@nestjs/common';
|
||||||
import { Inject, Logger } from '@nestjs/common';
|
|
||||||
import { constants } from 'fs/promises';
|
import { constants } from 'fs/promises';
|
||||||
import { AssetCore, IAssetRepository, WithoutProperty, WithProperty } from '../asset';
|
import { IAssetRepository, WithoutProperty, WithProperty } from '../asset';
|
||||||
import { usePagination } from '../domain.util';
|
import { usePagination } from '../domain.util';
|
||||||
import { IAssetJob, IBaseJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
import { IBaseJob, IEntityJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
||||||
import { IStorageRepository } from '../storage';
|
import { IStorageRepository } from '../storage';
|
||||||
|
|
||||||
export class MetadataService {
|
export class MetadataService {
|
||||||
private logger = new Logger(MetadataService.name);
|
|
||||||
private assetCore: AssetCore;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||||
) {
|
) {}
|
||||||
this.assetCore = new AssetCore(assetRepository, jobRepository);
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleQueueSidecar(job: IBaseJob) {
|
async handleQueueSidecar(job: IBaseJob) {
|
||||||
try {
|
const { force } = job;
|
||||||
const { force } = job;
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
return force
|
||||||
return force
|
? this.assetRepository.getWith(pagination, WithProperty.SIDECAR)
|
||||||
? this.assetRepository.getWith(pagination, WithProperty.SIDECAR)
|
: this.assetRepository.getWithout(pagination, WithoutProperty.SIDECAR);
|
||||||
: this.assetRepository.getWithout(pagination, WithoutProperty.SIDECAR);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
for await (const assets of assetPagination) {
|
for await (const assets of assetPagination) {
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
const name = force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY;
|
const name = force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY;
|
||||||
await this.jobRepository.queue({ name, data: { asset } });
|
await this.jobRepository.queue({ name, data: { id: asset.id } });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Unable to queue sidecar scanning`, error?.stack);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleSidecarSync(job: IAssetJob) {
|
async handleSidecarSync() {
|
||||||
const { asset } = job;
|
// TODO: optimize to only queue assets with recent xmp changes
|
||||||
if (!asset.isVisible) {
|
return true;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const name = asset.type === AssetType.VIDEO ? JobName.EXTRACT_VIDEO_METADATA : JobName.EXIF_EXTRACTION;
|
|
||||||
await this.jobRepository.queue({ name, data: { asset } });
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Unable to queue metadata extraction`, error?.stack);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleSidecarDiscovery(job: IAssetJob) {
|
async handleSidecarDiscovery({ id }: IEntityJob) {
|
||||||
let { asset } = job;
|
const [asset] = await this.assetRepository.getByIds([id]);
|
||||||
if (!asset.isVisible || asset.sidecarPath) {
|
if (!asset || !asset.isVisible || asset.sidecarPath) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const sidecarPath = `${asset.originalPath}.xmp`;
|
||||||
const sidecarPath = `${asset.originalPath}.xmp`;
|
const exists = await this.storageRepository.checkFileExists(sidecarPath, constants.W_OK);
|
||||||
const exists = await this.storageRepository.checkFileExists(sidecarPath, constants.W_OK);
|
if (!exists) {
|
||||||
if (!exists) {
|
return false;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
asset = await this.assetCore.save({ id: asset.id, sidecarPath });
|
|
||||||
// TODO: optimize to only queue assets with recent xmp changes
|
|
||||||
const name = asset.type === AssetType.VIDEO ? JobName.EXTRACT_VIDEO_METADATA : JobName.EXIF_EXTRACTION;
|
|
||||||
await this.jobRepository.queue({ name, data: { asset } });
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Unable to queue metadata extraction: ${error}`, error?.stack);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.assetRepository.save({ id: asset.id, sidecarPath });
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -122,14 +122,5 @@ describe(PersonService.name, () => {
|
|||||||
data: { files: ['/path/to/thumbnail'] },
|
data: { files: ['/path/to/thumbnail'] },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log an error', async () => {
|
|
||||||
personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]);
|
|
||||||
personMock.delete.mockRejectedValue(new Error('database unavailable'));
|
|
||||||
|
|
||||||
await sut.handlePersonCleanup();
|
|
||||||
|
|
||||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -67,7 +67,7 @@ export class PersonService {
|
|||||||
return mapPerson(person);
|
return mapPerson(person);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handlePersonCleanup(): Promise<void> {
|
async handlePersonCleanup() {
|
||||||
const people = await this.repository.getAllWithoutFaces();
|
const people = await this.repository.getAllWithoutFaces();
|
||||||
for (const person of people) {
|
for (const person of people) {
|
||||||
this.logger.debug(`Person ${person.name || person.id} no longer has any faces, deleting.`);
|
this.logger.debug(`Person ${person.name || person.id} no longer has any faces, deleting.`);
|
||||||
@ -78,5 +78,7 @@ export class PersonService {
|
|||||||
this.logger.error(`Unable to delete person: ${error}`, error?.stack);
|
this.logger.error(`Unable to delete person: ${error}`, error?.stack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -204,18 +204,6 @@ describe(SearchService.name, () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log an error', async () => {
|
|
||||||
assetMock.getAll.mockResolvedValue({
|
|
||||||
items: [assetEntityStub.image],
|
|
||||||
hasNextPage: false,
|
|
||||||
});
|
|
||||||
searchMock.importAssets.mockRejectedValue(new Error('import failed'));
|
|
||||||
|
|
||||||
await sut.handleIndexAssets();
|
|
||||||
|
|
||||||
expect(searchMock.importAssets).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should skip if search is disabled', async () => {
|
it('should skip if search is disabled', async () => {
|
||||||
const sut = makeSut('false');
|
const sut = makeSut('false');
|
||||||
|
|
||||||
@ -250,15 +238,6 @@ describe(SearchService.name, () => {
|
|||||||
|
|
||||||
expect(searchMock.importAlbums).toHaveBeenCalledWith([albumStub.empty], true);
|
expect(searchMock.importAlbums).toHaveBeenCalledWith([albumStub.empty], true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log an error', async () => {
|
|
||||||
albumMock.getAll.mockResolvedValue([albumStub.empty]);
|
|
||||||
searchMock.importAlbums.mockRejectedValue(new Error('import failed'));
|
|
||||||
|
|
||||||
await sut.handleIndexAlbums();
|
|
||||||
|
|
||||||
expect(searchMock.importAlbums).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleIndexAlbum', () => {
|
describe('handleIndexAlbum', () => {
|
||||||
@ -325,15 +304,6 @@ describe(SearchService.name, () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log an error', async () => {
|
|
||||||
faceMock.getAll.mockResolvedValue([faceStub.face1]);
|
|
||||||
searchMock.importFaces.mockRejectedValue(new Error('import failed'));
|
|
||||||
|
|
||||||
await sut.handleIndexFaces();
|
|
||||||
|
|
||||||
expect(searchMock.importFaces).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should skip if search is disabled', async () => {
|
it('should skip if search is disabled', async () => {
|
||||||
const sut = makeSut('false');
|
const sut = makeSut('false');
|
||||||
|
|
||||||
|
@ -137,122 +137,128 @@ export class SearchService {
|
|||||||
|
|
||||||
async handleIndexAlbums() {
|
async handleIndexAlbums() {
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const albums = this.patchAlbums(await this.albumRepository.getAll());
|
||||||
const albums = this.patchAlbums(await this.albumRepository.getAll());
|
this.logger.log(`Indexing ${albums.length} albums`);
|
||||||
this.logger.log(`Indexing ${albums.length} albums`);
|
await this.searchRepository.importAlbums(albums, true);
|
||||||
await this.searchRepository.importAlbums(albums, true);
|
|
||||||
} catch (error: any) {
|
return true;
|
||||||
this.logger.error(`Unable to index all albums`, error?.stack);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleIndexAssets() {
|
async handleIndexAssets() {
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// TODO: do this in batches based on searchIndexVersion
|
||||||
// TODO: do this in batches based on searchIndexVersion
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
this.assetRepository.getAll(pagination, { isVisible: true }),
|
||||||
this.assetRepository.getAll(pagination, { isVisible: true }),
|
);
|
||||||
);
|
|
||||||
|
|
||||||
for await (const assets of assetPagination) {
|
for await (const assets of assetPagination) {
|
||||||
this.logger.debug(`Indexing ${assets.length} assets`);
|
this.logger.debug(`Indexing ${assets.length} assets`);
|
||||||
|
|
||||||
const patchedAssets = this.patchAssets(assets);
|
const patchedAssets = this.patchAssets(assets);
|
||||||
await this.searchRepository.importAssets(patchedAssets, false);
|
await this.searchRepository.importAssets(patchedAssets, false);
|
||||||
}
|
|
||||||
|
|
||||||
await this.searchRepository.importAssets([], true);
|
|
||||||
|
|
||||||
this.logger.debug('Finished re-indexing all assets');
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Unable to index all assets`, error?.stack);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.searchRepository.importAssets([], true);
|
||||||
|
|
||||||
|
this.logger.debug('Finished re-indexing all assets');
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleIndexFaces() {
|
async handleIndexFaces() {
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// TODO: do this in batches based on searchIndexVersion
|
||||||
// TODO: do this in batches based on searchIndexVersion
|
const faces = this.patchFaces(await this.faceRepository.getAll());
|
||||||
const faces = this.patchFaces(await this.faceRepository.getAll());
|
this.logger.log(`Indexing ${faces.length} faces`);
|
||||||
this.logger.log(`Indexing ${faces.length} faces`);
|
|
||||||
|
|
||||||
const chunkSize = 1000;
|
const chunkSize = 1000;
|
||||||
for (let i = 0; i < faces.length; i += chunkSize) {
|
for (let i = 0; i < faces.length; i += chunkSize) {
|
||||||
await this.searchRepository.importFaces(faces.slice(i, i + chunkSize), false);
|
await this.searchRepository.importFaces(faces.slice(i, i + chunkSize), false);
|
||||||
}
|
|
||||||
|
|
||||||
await this.searchRepository.importFaces([], true);
|
|
||||||
|
|
||||||
this.logger.debug('Finished re-indexing all faces');
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Unable to index all faces`, error?.stack);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.searchRepository.importFaces([], true);
|
||||||
|
|
||||||
|
this.logger.debug('Finished re-indexing all faces');
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleIndexAlbum({ ids }: IBulkEntityJob) {
|
handleIndexAlbum({ ids }: IBulkEntityJob) {
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
this.albumQueue.upsert.add(id);
|
this.albumQueue.upsert.add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleIndexAsset({ ids }: IBulkEntityJob) {
|
handleIndexAsset({ ids }: IBulkEntityJob) {
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
this.assetQueue.upsert.add(id);
|
this.assetQueue.upsert.add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleIndexFace({ assetId, personId }: IAssetFaceJob) {
|
async handleIndexFace({ assetId, personId }: IAssetFaceJob) {
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// immediately push to typesense
|
// immediately push to typesense
|
||||||
await this.searchRepository.importFaces(await this.idsToFaces([{ assetId, personId }]), false);
|
await this.searchRepository.importFaces(await this.idsToFaces([{ assetId, personId }]), false);
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRemoveAlbum({ ids }: IBulkEntityJob) {
|
handleRemoveAlbum({ ids }: IBulkEntityJob) {
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
this.albumQueue.delete.add(id);
|
this.albumQueue.delete.add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRemoveAsset({ ids }: IBulkEntityJob) {
|
handleRemoveAsset({ ids }: IBulkEntityJob) {
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
this.assetQueue.delete.add(id);
|
this.assetQueue.delete.add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRemoveFace({ assetId, personId }: IAssetFaceJob) {
|
handleRemoveFace({ assetId, personId }: IAssetFaceJob) {
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.faceQueue.delete.add(this.asKey({ assetId, personId }));
|
this.faceQueue.delete.add(this.asKey({ assetId, personId }));
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async flush() {
|
private async flush() {
|
||||||
|
@ -30,6 +30,8 @@ describe(SmartInfoService.name, () => {
|
|||||||
jobMock = newJobRepositoryMock();
|
jobMock = newJobRepositoryMock();
|
||||||
machineMock = newMachineLearningRepositoryMock();
|
machineMock = newMachineLearningRepositoryMock();
|
||||||
sut = new SmartInfoService(assetMock, jobMock, smartMock, machineMock);
|
sut = new SmartInfoService(assetMock, jobMock, smartMock, machineMock);
|
||||||
|
|
||||||
|
assetMock.getByIds.mockResolvedValue([asset]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work', () => {
|
it('should work', () => {
|
||||||
@ -46,8 +48,8 @@ describe(SmartInfoService.name, () => {
|
|||||||
await sut.handleQueueObjectTagging({ force: false });
|
await sut.handleQueueObjectTagging({ force: false });
|
||||||
|
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
expect(jobMock.queue.mock.calls).toEqual([
|
||||||
[{ name: JobName.CLASSIFY_IMAGE, data: { asset: assetEntityStub.image } }],
|
[{ name: JobName.CLASSIFY_IMAGE, data: { id: assetEntityStub.image.id } }],
|
||||||
[{ name: JobName.DETECT_OBJECTS, data: { asset: assetEntityStub.image } }],
|
[{ name: JobName.DETECT_OBJECTS, data: { id: assetEntityStub.image.id } }],
|
||||||
]);
|
]);
|
||||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.OBJECT_TAGS);
|
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.OBJECT_TAGS);
|
||||||
});
|
});
|
||||||
@ -61,8 +63,8 @@ describe(SmartInfoService.name, () => {
|
|||||||
await sut.handleQueueObjectTagging({ force: true });
|
await sut.handleQueueObjectTagging({ force: true });
|
||||||
|
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
expect(jobMock.queue.mock.calls).toEqual([
|
||||||
[{ name: JobName.CLASSIFY_IMAGE, data: { asset: assetEntityStub.image } }],
|
[{ name: JobName.CLASSIFY_IMAGE, data: { id: assetEntityStub.image.id } }],
|
||||||
[{ name: JobName.DETECT_OBJECTS, data: { asset: assetEntityStub.image } }],
|
[{ name: JobName.DETECT_OBJECTS, data: { id: assetEntityStub.image.id } }],
|
||||||
]);
|
]);
|
||||||
expect(assetMock.getAll).toHaveBeenCalled();
|
expect(assetMock.getAll).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@ -70,7 +72,10 @@ describe(SmartInfoService.name, () => {
|
|||||||
|
|
||||||
describe('handleTagImage', () => {
|
describe('handleTagImage', () => {
|
||||||
it('should skip assets without a resize path', async () => {
|
it('should skip assets without a resize path', async () => {
|
||||||
await sut.handleClassifyImage({ asset: { resizePath: '' } as AssetEntity });
|
const asset = { resizePath: '' } as AssetEntity;
|
||||||
|
assetMock.getByIds.mockResolvedValue([asset]);
|
||||||
|
|
||||||
|
await sut.handleClassifyImage({ id: asset.id });
|
||||||
|
|
||||||
expect(smartMock.upsert).not.toHaveBeenCalled();
|
expect(smartMock.upsert).not.toHaveBeenCalled();
|
||||||
expect(machineMock.classifyImage).not.toHaveBeenCalled();
|
expect(machineMock.classifyImage).not.toHaveBeenCalled();
|
||||||
@ -79,7 +84,7 @@ describe(SmartInfoService.name, () => {
|
|||||||
it('should save the returned tags', async () => {
|
it('should save the returned tags', async () => {
|
||||||
machineMock.classifyImage.mockResolvedValue(['tag1', 'tag2', 'tag3']);
|
machineMock.classifyImage.mockResolvedValue(['tag1', 'tag2', 'tag3']);
|
||||||
|
|
||||||
await sut.handleClassifyImage({ asset });
|
await sut.handleClassifyImage({ id: asset.id });
|
||||||
|
|
||||||
expect(machineMock.classifyImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' });
|
expect(machineMock.classifyImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' });
|
||||||
expect(smartMock.upsert).toHaveBeenCalledWith({
|
expect(smartMock.upsert).toHaveBeenCalledWith({
|
||||||
@ -88,18 +93,10 @@ describe(SmartInfoService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle an error with the machine learning pipeline', async () => {
|
|
||||||
machineMock.classifyImage.mockRejectedValue(new Error('Unable to read thumbnail'));
|
|
||||||
|
|
||||||
await sut.handleClassifyImage({ asset });
|
|
||||||
|
|
||||||
expect(smartMock.upsert).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should no update the smart info if no tags were returned', async () => {
|
it('should no update the smart info if no tags were returned', async () => {
|
||||||
machineMock.classifyImage.mockResolvedValue([]);
|
machineMock.classifyImage.mockResolvedValue([]);
|
||||||
|
|
||||||
await sut.handleClassifyImage({ asset });
|
await sut.handleClassifyImage({ id: asset.id });
|
||||||
|
|
||||||
expect(machineMock.classifyImage).toHaveBeenCalled();
|
expect(machineMock.classifyImage).toHaveBeenCalled();
|
||||||
expect(smartMock.upsert).not.toHaveBeenCalled();
|
expect(smartMock.upsert).not.toHaveBeenCalled();
|
||||||
@ -108,7 +105,10 @@ describe(SmartInfoService.name, () => {
|
|||||||
|
|
||||||
describe('handleDetectObjects', () => {
|
describe('handleDetectObjects', () => {
|
||||||
it('should skip assets without a resize path', async () => {
|
it('should skip assets without a resize path', async () => {
|
||||||
await sut.handleDetectObjects({ asset: { resizePath: '' } as AssetEntity });
|
const asset = { resizePath: '' } as AssetEntity;
|
||||||
|
assetMock.getByIds.mockResolvedValue([asset]);
|
||||||
|
|
||||||
|
await sut.handleDetectObjects({ id: asset.id });
|
||||||
|
|
||||||
expect(smartMock.upsert).not.toHaveBeenCalled();
|
expect(smartMock.upsert).not.toHaveBeenCalled();
|
||||||
expect(machineMock.detectObjects).not.toHaveBeenCalled();
|
expect(machineMock.detectObjects).not.toHaveBeenCalled();
|
||||||
@ -117,7 +117,7 @@ describe(SmartInfoService.name, () => {
|
|||||||
it('should save the returned objects', async () => {
|
it('should save the returned objects', async () => {
|
||||||
machineMock.detectObjects.mockResolvedValue(['obj1', 'obj2', 'obj3']);
|
machineMock.detectObjects.mockResolvedValue(['obj1', 'obj2', 'obj3']);
|
||||||
|
|
||||||
await sut.handleDetectObjects({ asset });
|
await sut.handleDetectObjects({ id: asset.id });
|
||||||
|
|
||||||
expect(machineMock.detectObjects).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' });
|
expect(machineMock.detectObjects).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' });
|
||||||
expect(smartMock.upsert).toHaveBeenCalledWith({
|
expect(smartMock.upsert).toHaveBeenCalledWith({
|
||||||
@ -126,18 +126,10 @@ describe(SmartInfoService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle an error with the machine learning pipeline', async () => {
|
|
||||||
machineMock.detectObjects.mockRejectedValue(new Error('Unable to read thumbnail'));
|
|
||||||
|
|
||||||
await sut.handleDetectObjects({ asset });
|
|
||||||
|
|
||||||
expect(smartMock.upsert).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should no update the smart info if no objects were returned', async () => {
|
it('should no update the smart info if no objects were returned', async () => {
|
||||||
machineMock.detectObjects.mockResolvedValue([]);
|
machineMock.detectObjects.mockResolvedValue([]);
|
||||||
|
|
||||||
await sut.handleDetectObjects({ asset });
|
await sut.handleDetectObjects({ id: asset.id });
|
||||||
|
|
||||||
expect(machineMock.detectObjects).toHaveBeenCalled();
|
expect(machineMock.detectObjects).toHaveBeenCalled();
|
||||||
expect(smartMock.upsert).not.toHaveBeenCalled();
|
expect(smartMock.upsert).not.toHaveBeenCalled();
|
||||||
@ -153,7 +145,7 @@ describe(SmartInfoService.name, () => {
|
|||||||
|
|
||||||
await sut.handleQueueEncodeClip({ force: false });
|
await sut.handleQueueEncodeClip({ force: false });
|
||||||
|
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { asset: assetEntityStub.image } });
|
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { id: assetEntityStub.image.id } });
|
||||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.CLIP_ENCODING);
|
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.CLIP_ENCODING);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -165,14 +157,17 @@ describe(SmartInfoService.name, () => {
|
|||||||
|
|
||||||
await sut.handleQueueEncodeClip({ force: true });
|
await sut.handleQueueEncodeClip({ force: true });
|
||||||
|
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { asset: assetEntityStub.image } });
|
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { id: assetEntityStub.image.id } });
|
||||||
expect(assetMock.getAll).toHaveBeenCalled();
|
expect(assetMock.getAll).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleEncodeClip', () => {
|
describe('handleEncodeClip', () => {
|
||||||
it('should skip assets without a resize path', async () => {
|
it('should skip assets without a resize path', async () => {
|
||||||
await sut.handleEncodeClip({ asset: { resizePath: '' } as AssetEntity });
|
const asset = { resizePath: '' } as AssetEntity;
|
||||||
|
assetMock.getByIds.mockResolvedValue([asset]);
|
||||||
|
|
||||||
|
await sut.handleEncodeClip({ id: asset.id });
|
||||||
|
|
||||||
expect(smartMock.upsert).not.toHaveBeenCalled();
|
expect(smartMock.upsert).not.toHaveBeenCalled();
|
||||||
expect(machineMock.encodeImage).not.toHaveBeenCalled();
|
expect(machineMock.encodeImage).not.toHaveBeenCalled();
|
||||||
@ -181,7 +176,7 @@ describe(SmartInfoService.name, () => {
|
|||||||
it('should save the returned objects', async () => {
|
it('should save the returned objects', async () => {
|
||||||
machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
|
machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
|
||||||
|
|
||||||
await sut.handleEncodeClip({ asset });
|
await sut.handleEncodeClip({ id: asset.id });
|
||||||
|
|
||||||
expect(machineMock.encodeImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' });
|
expect(machineMock.encodeImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' });
|
||||||
expect(smartMock.upsert).toHaveBeenCalledWith({
|
expect(smartMock.upsert).toHaveBeenCalledWith({
|
||||||
@ -189,13 +184,5 @@ describe(SmartInfoService.name, () => {
|
|||||||
clipEmbedding: [0.01, 0.02, 0.03],
|
clipEmbedding: [0.01, 0.02, 0.03],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle an error with the machine learning pipeline', async () => {
|
|
||||||
machineMock.encodeImage.mockRejectedValue(new Error('Unable to read thumbnail'));
|
|
||||||
|
|
||||||
await sut.handleEncodeClip({ asset });
|
|
||||||
|
|
||||||
expect(smartMock.upsert).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -2,7 +2,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
|
|||||||
import { IAssetRepository, WithoutProperty } from '../asset';
|
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||||
import { MACHINE_LEARNING_ENABLED } from '../domain.constant';
|
import { MACHINE_LEARNING_ENABLED } from '../domain.constant';
|
||||||
import { usePagination } from '../domain.util';
|
import { usePagination } from '../domain.util';
|
||||||
import { IAssetJob, IBaseJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
import { IBaseJob, IEntityJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
||||||
import { IMachineLearningRepository } from './machine-learning.interface';
|
import { IMachineLearningRepository } from './machine-learning.interface';
|
||||||
import { ISmartInfoRepository } from './smart-info.repository';
|
import { ISmartInfoRepository } from './smart-info.repository';
|
||||||
|
|
||||||
@ -18,91 +18,82 @@ export class SmartInfoService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handleQueueObjectTagging({ force }: IBaseJob) {
|
async handleQueueObjectTagging({ force }: IBaseJob) {
|
||||||
try {
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
return force
|
||||||
return force
|
? this.assetRepository.getAll(pagination)
|
||||||
? this.assetRepository.getAll(pagination)
|
: this.assetRepository.getWithout(pagination, WithoutProperty.OBJECT_TAGS);
|
||||||
: this.assetRepository.getWithout(pagination, WithoutProperty.OBJECT_TAGS);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
for await (const assets of assetPagination) {
|
for await (const assets of assetPagination) {
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { asset } });
|
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { id: asset.id } });
|
||||||
await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } });
|
await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { id: asset.id } });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Unable to queue object tagging`, error?.stack);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleDetectObjects(data: IAssetJob) {
|
async handleDetectObjects({ id }: IEntityJob) {
|
||||||
const { asset } = data;
|
const [asset] = await this.assetRepository.getByIds([id]);
|
||||||
|
|
||||||
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
|
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const objects = await this.machineLearning.detectObjects({ thumbnailPath: asset.resizePath });
|
||||||
const objects = await this.machineLearning.detectObjects({ thumbnailPath: asset.resizePath });
|
if (objects.length === 0) {
|
||||||
if (objects.length > 0) {
|
return false;
|
||||||
await this.repository.upsert({ assetId: asset.id, objects });
|
|
||||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Unable run object detection pipeline: ${asset.id}`, error?.stack);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.repository.upsert({ assetId: asset.id, objects });
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleClassifyImage(data: IAssetJob) {
|
async handleClassifyImage({ id }: IEntityJob) {
|
||||||
const { asset } = data;
|
const [asset] = await this.assetRepository.getByIds([id]);
|
||||||
|
|
||||||
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
|
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const tags = await this.machineLearning.classifyImage({ thumbnailPath: asset.resizePath });
|
||||||
const tags = await this.machineLearning.classifyImage({ thumbnailPath: asset.resizePath });
|
if (tags.length === 0) {
|
||||||
if (tags.length > 0) {
|
return false;
|
||||||
await this.repository.upsert({ assetId: asset.id, tags });
|
|
||||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Unable to run image tagging pipeline: ${asset.id}`, error?.stack);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.repository.upsert({ assetId: asset.id, tags });
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleQueueEncodeClip({ force }: IBaseJob) {
|
async handleQueueEncodeClip({ force }: IBaseJob) {
|
||||||
try {
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
return force
|
||||||
return force
|
? this.assetRepository.getAll(pagination)
|
||||||
? this.assetRepository.getAll(pagination)
|
: this.assetRepository.getWithout(pagination, WithoutProperty.CLIP_ENCODING);
|
||||||
: this.assetRepository.getWithout(pagination, WithoutProperty.CLIP_ENCODING);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
for await (const assets of assetPagination) {
|
for await (const assets of assetPagination) {
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
|
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { id: asset.id } });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Unable to queue clip encoding`, error?.stack);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleEncodeClip(data: IAssetJob) {
|
async handleEncodeClip({ id }: IEntityJob) {
|
||||||
const { asset } = data;
|
const [asset] = await this.assetRepository.getByIds([id]);
|
||||||
|
|
||||||
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
|
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const clipEmbedding = await this.machineLearning.encodeImage({ thumbnailPath: asset.resizePath });
|
||||||
const clipEmbedding = await this.machineLearning.encodeImage({ thumbnailPath: asset.resizePath });
|
await this.repository.upsert({ assetId: asset.id, clipEmbedding: clipEmbedding });
|
||||||
await this.repository.upsert({ assetId: asset.id, clipEmbedding: clipEmbedding });
|
|
||||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
|
return true;
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Unable run clip encoding pipeline: ${asset.id}`, error?.stack);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -195,11 +195,4 @@ describe(StorageTemplateService.name, () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle an error', async () => {
|
|
||||||
storageMock.removeEmptyDirs.mockRejectedValue(new Error('Read only filesystem'));
|
|
||||||
userMock.getList.mockResolvedValue([]);
|
|
||||||
|
|
||||||
await sut.handleMigration();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@ -3,7 +3,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
|
|||||||
import { IAssetRepository } from '../asset/asset.repository';
|
import { IAssetRepository } from '../asset/asset.repository';
|
||||||
import { APP_MEDIA_LOCATION } from '../domain.constant';
|
import { APP_MEDIA_LOCATION } from '../domain.constant';
|
||||||
import { getLivePhotoMotionFilename, usePagination } from '../domain.util';
|
import { getLivePhotoMotionFilename, usePagination } from '../domain.util';
|
||||||
import { IAssetJob, JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
||||||
import { IStorageRepository } from '../storage/storage.repository';
|
import { IStorageRepository } from '../storage/storage.repository';
|
||||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||||
import { IUserRepository } from '../user/user.repository';
|
import { IUserRepository } from '../user/user.repository';
|
||||||
@ -29,24 +29,22 @@ export class StorageTemplateService {
|
|||||||
this.core = new StorageTemplateCore(configRepository, config, storageRepository);
|
this.core = new StorageTemplateCore(configRepository, config, storageRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleMigrationSingle(data: IAssetJob) {
|
async handleMigrationSingle({ id }: IEntityJob) {
|
||||||
const { asset } = data;
|
const [asset] = await this.assetRepository.getByIds([id]);
|
||||||
|
|
||||||
try {
|
const user = await this.userRepository.get(asset.ownerId);
|
||||||
const user = await this.userRepository.get(asset.ownerId);
|
const storageLabel = user?.storageLabel || null;
|
||||||
const storageLabel = user?.storageLabel || null;
|
const filename = asset.originalFileName || asset.id;
|
||||||
const filename = asset.originalFileName || asset.id;
|
await this.moveAsset(asset, { storageLabel, filename });
|
||||||
await this.moveAsset(asset, { storageLabel, filename });
|
|
||||||
|
|
||||||
// move motion part of live photo
|
// move motion part of live photo
|
||||||
if (asset.livePhotoVideoId) {
|
if (asset.livePhotoVideoId) {
|
||||||
const [livePhotoVideo] = await this.assetRepository.getByIds([asset.livePhotoVideoId]);
|
const [livePhotoVideo] = await this.assetRepository.getByIds([asset.livePhotoVideoId]);
|
||||||
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
|
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
|
||||||
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
|
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error('Error running single template migration', error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleMigration() {
|
async handleMigration() {
|
||||||
@ -69,11 +67,11 @@ export class StorageTemplateService {
|
|||||||
|
|
||||||
this.logger.debug('Cleaning up empty directories...');
|
this.logger.debug('Cleaning up empty directories...');
|
||||||
await this.storageRepository.removeEmptyDirs(APP_MEDIA_LOCATION);
|
await this.storageRepository.removeEmptyDirs(APP_MEDIA_LOCATION);
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error('Error running template migration', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
console.timeEnd('migrating-time');
|
console.timeEnd('migrating-time');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: use asset core (once in domain)
|
// TODO: use asset core (once in domain)
|
||||||
|
@ -11,6 +11,7 @@ export class StorageService {
|
|||||||
async handleDeleteFiles(job: IDeleteFilesJob) {
|
async handleDeleteFiles(job: IDeleteFilesJob) {
|
||||||
const { files } = job;
|
const { files } = job;
|
||||||
|
|
||||||
|
// TODO: one job per file
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
continue;
|
continue;
|
||||||
@ -22,5 +23,7 @@ export class StorageService {
|
|||||||
this.logger.warn('Unable to remove file from disk', error);
|
this.logger.warn('Unable to remove file from disk', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -46,6 +46,7 @@ export class SystemConfigService {
|
|||||||
|
|
||||||
async refreshConfig() {
|
async refreshConfig() {
|
||||||
await this.core.refreshConfig();
|
await this.core.refreshConfig();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
addValidator(validator: SystemConfigValidator) {
|
addValidator(validator: SystemConfigValidator) {
|
||||||
|
@ -455,21 +455,22 @@ describe(UserService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should queue user ready for deletion', async () => {
|
it('should queue user ready for deletion', async () => {
|
||||||
const user = { deletedAt: makeDeletedAt(10) };
|
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) };
|
||||||
userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]);
|
userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]);
|
||||||
|
|
||||||
await sut.handleUserDeleteCheck();
|
await sut.handleUserDeleteCheck();
|
||||||
|
|
||||||
expect(userMock.getDeletedUsers).toHaveBeenCalled();
|
expect(userMock.getDeletedUsers).toHaveBeenCalled();
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.USER_DELETION, data: { user } });
|
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.USER_DELETION, data: { id: user.id } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleUserDelete', () => {
|
describe('handleUserDelete', () => {
|
||||||
it('should skip users not ready for deletion', async () => {
|
it('should skip users not ready for deletion', async () => {
|
||||||
const user = { deletedAt: makeDeletedAt(5) } as UserEntity;
|
const user = { id: 'user-1', deletedAt: makeDeletedAt(5) } as UserEntity;
|
||||||
|
userMock.get.mockResolvedValue(user);
|
||||||
|
|
||||||
await sut.handleUserDelete({ user });
|
await sut.handleUserDelete({ id: user.id });
|
||||||
|
|
||||||
expect(storageMock.unlinkDir).not.toHaveBeenCalled();
|
expect(storageMock.unlinkDir).not.toHaveBeenCalled();
|
||||||
expect(userMock.delete).not.toHaveBeenCalled();
|
expect(userMock.delete).not.toHaveBeenCalled();
|
||||||
@ -477,8 +478,9 @@ describe(UserService.name, () => {
|
|||||||
|
|
||||||
it('should delete the user and associated assets', async () => {
|
it('should delete the user and associated assets', async () => {
|
||||||
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity;
|
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity;
|
||||||
|
userMock.get.mockResolvedValue(user);
|
||||||
|
|
||||||
await sut.handleUserDelete({ user });
|
await sut.handleUserDelete({ id: user.id });
|
||||||
|
|
||||||
const options = { force: true, recursive: true };
|
const options = { force: true, recursive: true };
|
||||||
|
|
||||||
@ -494,22 +496,13 @@ describe(UserService.name, () => {
|
|||||||
|
|
||||||
it('should delete the library path for a storage label', async () => {
|
it('should delete the library path for a storage label', async () => {
|
||||||
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserEntity;
|
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserEntity;
|
||||||
|
userMock.get.mockResolvedValue(user);
|
||||||
|
|
||||||
await sut.handleUserDelete({ user });
|
await sut.handleUserDelete({ id: user.id });
|
||||||
|
|
||||||
const options = { force: true, recursive: true };
|
const options = { force: true, recursive: true };
|
||||||
|
|
||||||
expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options);
|
expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle an error', async () => {
|
|
||||||
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity;
|
|
||||||
|
|
||||||
storageMock.unlinkDir.mockRejectedValue(new Error('Read only filesystem'));
|
|
||||||
|
|
||||||
await sut.handleUserDelete({ user });
|
|
||||||
|
|
||||||
expect(userMock.delete).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -6,7 +6,7 @@ import { IAlbumRepository } from '../album/album.repository';
|
|||||||
import { IAssetRepository } from '../asset/asset.repository';
|
import { IAssetRepository } from '../asset/asset.repository';
|
||||||
import { AuthUserDto } from '../auth';
|
import { AuthUserDto } from '../auth';
|
||||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||||
import { IJobRepository, IUserDeletionJob, JobName } from '../job';
|
import { IEntityJob, IJobRepository, JobName } from '../job';
|
||||||
import { StorageCore, StorageFolder } from '../storage';
|
import { StorageCore, StorageFolder } from '../storage';
|
||||||
import { IStorageRepository } from '../storage/storage.repository';
|
import { IStorageRepository } from '../storage/storage.repository';
|
||||||
import { IUserRepository } from '../user/user.repository';
|
import { IUserRepository } from '../user/user.repository';
|
||||||
@ -138,44 +138,47 @@ export class UserService {
|
|||||||
const users = await this.userRepository.getDeletedUsers();
|
const users = await this.userRepository.getDeletedUsers();
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
if (this.isReadyForDeletion(user)) {
|
if (this.isReadyForDeletion(user)) {
|
||||||
await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { user } });
|
await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { id: user.id } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleUserDelete(data: IUserDeletionJob) {
|
async handleUserDelete({ id }: IEntityJob) {
|
||||||
const { user } = data;
|
const user = await this.userRepository.get(id, true);
|
||||||
|
if (!user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// just for extra protection here
|
// just for extra protection here
|
||||||
if (!this.isReadyForDeletion(user)) {
|
if (!this.isReadyForDeletion(user)) {
|
||||||
this.logger.warn(`Skipped user that was not ready for deletion: id=${user.id}`);
|
this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Deleting user: ${user.id}`);
|
this.logger.log(`Deleting user: ${user.id}`);
|
||||||
|
|
||||||
try {
|
const folders = [
|
||||||
const folders = [
|
this.storageCore.getLibraryFolder(user),
|
||||||
this.storageCore.getLibraryFolder(user),
|
this.storageCore.getFolderLocation(StorageFolder.UPLOAD, user.id),
|
||||||
this.storageCore.getFolderLocation(StorageFolder.UPLOAD, user.id),
|
this.storageCore.getFolderLocation(StorageFolder.PROFILE, user.id),
|
||||||
this.storageCore.getFolderLocation(StorageFolder.PROFILE, user.id),
|
this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, user.id),
|
||||||
this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, user.id),
|
this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, user.id),
|
||||||
this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, user.id),
|
];
|
||||||
];
|
|
||||||
|
|
||||||
for (const folder of folders) {
|
for (const folder of folders) {
|
||||||
this.logger.warn(`Removing user from filesystem: ${folder}`);
|
this.logger.warn(`Removing user from filesystem: ${folder}`);
|
||||||
await this.storageRepository.unlinkDir(folder, { recursive: true, force: true });
|
await this.storageRepository.unlinkDir(folder, { recursive: true, force: true });
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.warn(`Removing user from database: ${user.id}`);
|
|
||||||
|
|
||||||
await this.albumRepository.deleteAll(user.id);
|
|
||||||
await this.assetRepository.deleteAll(user.id);
|
|
||||||
await this.userRepository.delete(user, true);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to remove user`, error, { id: user.id });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.warn(`Removing user from database: ${user.id}`);
|
||||||
|
|
||||||
|
await this.albumRepository.deleteAll(user.id);
|
||||||
|
await this.assetRepository.deleteAll(user.id);
|
||||||
|
await this.userRepository.delete(user, true);
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isReadyForDeletion(user: UserEntity): boolean {
|
private isReadyForDeletion(user: UserEntity): boolean {
|
||||||
|
@ -4,7 +4,7 @@ export const newStorageRepositoryMock = (): jest.Mocked<IStorageRepository> => {
|
|||||||
return {
|
return {
|
||||||
createReadStream: jest.fn(),
|
createReadStream: jest.fn(),
|
||||||
unlink: jest.fn(),
|
unlink: jest.fn(),
|
||||||
unlinkDir: jest.fn(),
|
unlinkDir: jest.fn().mockResolvedValue(true),
|
||||||
removeEmptyDirs: jest.fn(),
|
removeEmptyDirs: jest.fn(),
|
||||||
moveFile: jest.fn(),
|
moveFile: jest.fn(),
|
||||||
checkFileExists: jest.fn(),
|
checkFileExists: jest.fn(),
|
||||||
|
@ -45,9 +45,6 @@ export class JobRepository implements IJobRepository {
|
|||||||
|
|
||||||
private getJobOptions(item: JobItem): JobOptions | null {
|
private getJobOptions(item: JobItem): JobOptions | null {
|
||||||
switch (item.name) {
|
switch (item.name) {
|
||||||
case JobName.ASSET_UPLOADED:
|
|
||||||
return { jobId: item.data.asset.id };
|
|
||||||
|
|
||||||
case JobName.GENERATE_FACE_THUMBNAIL:
|
case JobName.GENERATE_FACE_THUMBNAIL:
|
||||||
return { priority: 1 };
|
return { priority: 1 };
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user