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
33 changed files with 807 additions and 1082 deletions
|
@ -1,5 +1,5 @@
|
|||
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 { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
|
||||
import { parse } from 'node:path';
|
||||
|
@ -43,7 +43,10 @@ export class AssetCore {
|
|||
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;
|
||||
}
|
||||
|
|
|
@ -328,8 +328,9 @@ describe('AssetService', () => {
|
|||
});
|
||||
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[{ name: JobName.ASSET_UPLOADED, data: { asset: assetEntityStub.livePhotoMotionAsset } }],
|
||||
[{ name: JobName.ASSET_UPLOADED, data: { asset: assetEntityStub.livePhotoStillAsset } }],
|
||||
[{ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: assetEntityStub.livePhotoMotionAsset.id } }],
|
||||
[{ name: JobName.VIDEO_CONVERSION, data: { id: assetEntityStub.livePhotoMotionAsset.id } }],
|
||||
[{ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: assetEntityStub.livePhotoStillAsset.id } }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import {
|
||||
AssetService,
|
||||
FacialRecognitionService,
|
||||
IDeleteFilesJob,
|
||||
JobItem,
|
||||
JobName,
|
||||
JobService,
|
||||
JOBS_TO_QUEUE,
|
||||
MediaService,
|
||||
MetadataService,
|
||||
|
@ -16,12 +18,12 @@ import {
|
|||
UserService,
|
||||
} from '@app/domain';
|
||||
import { getQueueToken } from '@nestjs/bull';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { Queue } from 'bull';
|
||||
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()
|
||||
export class ProcessorService {
|
||||
|
@ -30,8 +32,8 @@ export class ProcessorService {
|
|||
// TODO refactor to domain
|
||||
private metadataProcessor: MetadataExtractionProcessor,
|
||||
|
||||
private assetService: AssetService,
|
||||
private facialRecognitionService: FacialRecognitionService,
|
||||
private jobService: JobService,
|
||||
private mediaService: MediaService,
|
||||
private metadataService: MetadataService,
|
||||
private personService: PersonService,
|
||||
|
@ -43,9 +45,10 @@ export class ProcessorService {
|
|||
private userService: UserService,
|
||||
) {}
|
||||
|
||||
private logger = new Logger(ProcessorService.name);
|
||||
|
||||
private handlers: Record<JobName, JobHandler> = {
|
||||
[JobName.ASSET_UPLOADED]: (data) => this.assetService.handleAssetUpload(data),
|
||||
[JobName.DELETE_FILES]: (data) => this.storageService.handleDeleteFiles(data),
|
||||
[JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data),
|
||||
[JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(),
|
||||
[JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(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.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),
|
||||
[JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleQueueMetadataExtraction(data),
|
||||
[JobName.EXIF_EXTRACTION]: (data) => this.metadataProcessor.extractExifInfo(data),
|
||||
[JobName.EXTRACT_VIDEO_METADATA]: (data) => this.metadataProcessor.extractVideoMetadata(data),
|
||||
[JobName.METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleMetadataExtraction(data),
|
||||
[JobName.QUEUE_RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleQueueRecognizeFaces(data),
|
||||
[JobName.RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleRecognizeFaces(data),
|
||||
[JobName.GENERATE_FACE_THUMBNAIL]: (data) => this.facialRecognitionService.handleGenerateFaceThumbnail(data),
|
||||
[JobName.PERSON_CLEANUP]: () => this.personService.handlePersonCleanup(),
|
||||
[JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(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() {
|
||||
|
@ -98,7 +100,14 @@ export class ProcessorService {
|
|||
await queue.isReady();
|
||||
|
||||
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 {
|
||||
AssetCore,
|
||||
IAssetJob,
|
||||
IAssetRepository,
|
||||
IBaseJob,
|
||||
IEntityJob,
|
||||
IGeocodingRepository,
|
||||
IJobRepository,
|
||||
JobName,
|
||||
|
@ -32,7 +31,6 @@ interface ImmichTags extends Tags {
|
|||
|
||||
export class MetadataExtractionProcessor {
|
||||
private logger = new Logger(MetadataExtractionProcessor.name);
|
||||
private assetCore: AssetCore;
|
||||
private reverseGeocodingEnabled: boolean;
|
||||
|
||||
constructor(
|
||||
|
@ -43,7 +41,6 @@ export class MetadataExtractionProcessor {
|
|||
|
||||
configService: ConfigService,
|
||||
) {
|
||||
this.assetCore = new AssetCore(assetRepository, jobRepository);
|
||||
this.reverseGeocodingEnabled = !configService.get('DISABLE_REVERSE_GEOCODING');
|
||||
}
|
||||
|
||||
|
@ -70,271 +67,262 @@ export class MetadataExtractionProcessor {
|
|||
}
|
||||
|
||||
async handleQueueMetadataExtraction(job: IBaseJob) {
|
||||
try {
|
||||
const { force } = job;
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.EXIF);
|
||||
});
|
||||
const { force } = job;
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.EXIF);
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
const name = asset.type === AssetType.VIDEO ? JobName.EXTRACT_VIDEO_METADATA : JobName.EXIF_EXTRACTION;
|
||||
await this.jobRepository.queue({ name, data: { asset } });
|
||||
}
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id } });
|
||||
}
|
||||
} 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) {
|
||||
let asset = job.asset;
|
||||
private async handlePhotoMetadataExtraction(asset: AssetEntity) {
|
||||
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 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;
|
||||
});
|
||||
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') {
|
||||
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;
|
||||
}
|
||||
})
|
||||
: {};
|
||||
|
||||
return exifDate.zone ?? null;
|
||||
};
|
||||
const exifToDate = (exifDate: string | ExifDateTime | undefined) => {
|
||||
if (!exifDate) return null;
|
||||
|
||||
const getExifProperty = <T extends keyof ImmichTags>(...properties: T[]): any | null => {
|
||||
for (const property of properties) {
|
||||
const value = sidecarExifData?.[property] ?? mediaExifData?.[property];
|
||||
if (value !== null && value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
const timeZone = exifTimeZone(getExifProperty('DateTimeOriginal', 'CreateDate') ?? asset.fileCreatedAt);
|
||||
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;
|
||||
return exifDate.zone ?? null;
|
||||
};
|
||||
|
||||
const newExif = new ExifEntity();
|
||||
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.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 });
|
||||
const getExifProperty = <T extends keyof ImmichTags>(...properties: T[]): any | null => {
|
||||
for (const property of properties) {
|
||||
const value = sidecarExifData?.[property] ?? mediaExifData?.[property];
|
||||
if (value !== null && value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
await this.applyReverseGeocoding(asset, newExif);
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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();
|
||||
const timeZone = exifTimeZone(getExifProperty('DateTimeOriginal', 'CreateDate') ?? asset.fileCreatedAt);
|
||||
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;
|
||||
|
||||
if (newExif.exifImageHeight === null) {
|
||||
newExif.exifImageHeight = metadata.height || null;
|
||||
}
|
||||
const newExif = new ExifEntity();
|
||||
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) {
|
||||
newExif.exifImageWidth = metadata.width || null;
|
||||
}
|
||||
|
||||
if (newExif.orientation === null) {
|
||||
newExif.orientation = metadata.orientation !== undefined ? `${metadata.orientation}` : null;
|
||||
}
|
||||
if (newExif.livePhotoCID && !asset.livePhotoVideoId) {
|
||||
const motionAsset = await this.assetRepository.findLivePhotoMatch({
|
||||
livePhotoCID: newExif.livePhotoCID,
|
||||
otherAssetId: asset.id,
|
||||
ownerId: asset.ownerId,
|
||||
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) {
|
||||
let asset = job.asset;
|
||||
private async handleVideoMetadataExtraction(asset: AssetEntity) {
|
||||
const data = await ffprobe(asset.originalPath);
|
||||
const durationString = this.extractDuration(data.format.duration || asset.duration);
|
||||
let fileCreatedAt = asset.fileCreatedAt;
|
||||
|
||||
if (!asset.isVisible) {
|
||||
return;
|
||||
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']);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await ffprobe(asset.originalPath);
|
||||
const durationString = this.extractDuration(data.format.duration || asset.duration);
|
||||
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}`,
|
||||
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.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) {
|
||||
|
|
|
@ -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, newJobRepositoryMock } from '../../test';
|
||||
import { assetEntityStub, authStub, newAssetRepositoryMock } from '../../test';
|
||||
import { AssetService, IAssetRepository } from '../asset';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
|
||||
describe(AssetService.name, () => {
|
||||
let sut: AssetService;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
|
@ -14,49 +11,7 @@ describe(AssetService.name, () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
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] },
|
||||
});
|
||||
});
|
||||
sut = new AssetService(assetMock);
|
||||
});
|
||||
|
||||
describe('get map markers', () => {
|
||||
|
|
|
@ -1,36 +1,11 @@
|
|||
import { AssetEntity, AssetType } from '@app/infra/entities';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { IAssetJob, IJobRepository, JobName } from '../job';
|
||||
import { AssetCore } from './asset.core';
|
||||
import { IAssetRepository } from './asset.repository';
|
||||
import { MapMarkerDto } from './dto/map-marker.dto';
|
||||
import { MapMarkerResponseDto } from './response-dto';
|
||||
|
||||
export class AssetService {
|
||||
private assetCore: AssetCore;
|
||||
|
||||
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);
|
||||
}
|
||||
constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {}
|
||||
|
||||
getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
|
||||
return this.assetRepository.getMapMarkers(authUser.id, options);
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
export * from './asset.core';
|
||||
export * from './asset.repository';
|
||||
export * from './asset.service';
|
||||
export * from './response-dto';
|
||||
|
|
|
@ -141,7 +141,7 @@ describe(FacialRecognitionService.name, () => {
|
|||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
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(jobMock.queue).toHaveBeenCalledWith({
|
||||
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', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it('should handle no results', async () => {
|
||||
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({
|
||||
thumbnailPath: assetEntityStub.image.resizePath,
|
||||
});
|
||||
|
@ -187,26 +184,23 @@ describe(FacialRecognitionService.name, () => {
|
|||
it('should match existing people', async () => {
|
||||
machineLearningMock.detectFaces.mockResolvedValue([face.middle]);
|
||||
searchMock.searchFaces.mockResolvedValue(faceSearch.oneMatch);
|
||||
|
||||
await sut.handleRecognizeFaces({ asset: assetEntityStub.image });
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
await sut.handleRecognizeFaces({ id: assetEntityStub.image.id });
|
||||
|
||||
expect(faceMock.create).toHaveBeenCalledWith({
|
||||
personId: 'person-1',
|
||||
assetId: 'asset-id',
|
||||
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 () => {
|
||||
machineLearningMock.detectFaces.mockResolvedValue([face.middle]);
|
||||
searchMock.searchFaces.mockResolvedValue(faceSearch.oneRemoteMatch);
|
||||
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(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_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', () => {
|
||||
|
@ -317,10 +305,5 @@ describe(FacialRecognitionService.name, () => {
|
|||
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 { MACHINE_LEARNING_ENABLED } from '../domain.constant';
|
||||
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 { IPersonRepository } from '../person/person.repository';
|
||||
import { ISearchRepository } from '../search/search.repository';
|
||||
|
@ -27,123 +27,113 @@ export class FacialRecognitionService {
|
|||
) {}
|
||||
|
||||
async handleQueueRecognizeFaces({ force }: IBaseJob) {
|
||||
try {
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.FACES);
|
||||
});
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.FACES);
|
||||
});
|
||||
|
||||
if (force) {
|
||||
const people = await this.personRepository.deleteAll();
|
||||
const faces = await this.searchRepository.deleteAllFaces();
|
||||
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);
|
||||
if (force) {
|
||||
const people = await this.personRepository.deleteAll();
|
||||
const faces = await this.searchRepository.deleteAllFaces();
|
||||
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: { id: asset.id } });
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleRecognizeFaces(data: IAssetJob) {
|
||||
const { asset } = data;
|
||||
|
||||
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
|
||||
return;
|
||||
async handleRecognizeFaces({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset || !MACHINE_LEARNING_ENABLED || !asset.resizePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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.verbose(faces.map((face) => ({ ...face, embedding: `float[${face.embedding.length}]` })));
|
||||
this.logger.debug(`${faces.length} faces detected in ${asset.resizePath}`);
|
||||
this.logger.verbose(faces.map((face) => ({ ...face, embedding: `float[${face.embedding.length}]` })));
|
||||
|
||||
for (const { embedding, ...rest } of faces) {
|
||||
const faceSearchResult = await this.searchRepository.searchFaces(embedding, { ownerId: asset.ownerId });
|
||||
for (const { embedding, ...rest } of faces) {
|
||||
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
|
||||
// The closer to 0, the better the match. Range is from 0 to 2
|
||||
if (faceSearchResult.total && faceSearchResult.distances[0] < 0.6) {
|
||||
this.logger.verbose(`Match face with distance ${faceSearchResult.distances[0]}`);
|
||||
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] } });
|
||||
// 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
|
||||
if (faceSearchResult.total && faceSearchResult.distances[0] < 0.6) {
|
||||
this.logger.verbose(`Match face with distance ${faceSearchResult.distances[0]}`);
|
||||
personId = faceSearchResult.items[0].personId;
|
||||
}
|
||||
|
||||
// queue all faces for asset
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Unable run facial recognition pipeline: ${asset.id}`, error?.stack);
|
||||
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 });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleGenerateFaceThumbnail(data: IFaceThumbnailJob) {
|
||||
const { assetId, personId, boundingBox, imageWidth, imageHeight } = data;
|
||||
|
||||
try {
|
||||
const [asset] = await this.assetRepository.getByIds([assetId]);
|
||||
if (!asset || !asset.resizePath) {
|
||||
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);
|
||||
const [asset] = await this.assetRepository.getByIds([assetId]);
|
||||
if (!asset || !asset.resizePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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 {
|
||||
// upload
|
||||
ASSET_UPLOADED = 'asset-uploaded',
|
||||
|
||||
// conversion
|
||||
QUEUE_VIDEO_CONVERSION = 'queue-video-conversion',
|
||||
VIDEO_CONVERSION = 'video-conversion',
|
||||
|
@ -33,8 +30,7 @@ export enum JobName {
|
|||
|
||||
// metadata
|
||||
QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction',
|
||||
EXIF_EXTRACTION = 'exif-extraction',
|
||||
EXTRACT_VIDEO_METADATA = 'extract-video-metadata',
|
||||
METADATA_EXTRACTION = 'metadata-extraction',
|
||||
|
||||
// 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> = {
|
||||
// misc
|
||||
[JobName.ASSET_UPLOADED]: QueueName.BACKGROUND_TASK,
|
||||
[JobName.USER_DELETE_CHECK]: QueueName.BACKGROUND_TASK,
|
||||
[JobName.USER_DELETION]: QueueName.BACKGROUND_TASK,
|
||||
[JobName.DELETE_FILES]: QueueName.BACKGROUND_TASK,
|
||||
|
@ -101,8 +96,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
|||
|
||||
// metadata
|
||||
[JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
|
||||
[JobName.EXIF_EXTRACTION]: QueueName.METADATA_EXTRACTION,
|
||||
[JobName.EXTRACT_VIDEO_METADATA]: QueueName.METADATA_EXTRACTION,
|
||||
[JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
|
||||
|
||||
// storage template
|
||||
[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';
|
||||
|
||||
export interface IBaseJob {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface IAlbumJob extends IBaseJob {
|
||||
album: AlbumEntity;
|
||||
}
|
||||
|
||||
export interface IAssetJob extends IBaseJob {
|
||||
asset: AssetEntity;
|
||||
}
|
||||
|
||||
export interface IAssetFaceJob extends IBaseJob {
|
||||
assetId: string;
|
||||
personId: string;
|
||||
|
@ -26,6 +17,10 @@ export interface IFaceThumbnailJob extends IAssetFaceJob {
|
|||
personId: string;
|
||||
}
|
||||
|
||||
export interface IEntityJob extends IBaseJob {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface IBulkEntityJob extends IBaseJob {
|
||||
ids: string[];
|
||||
}
|
||||
|
@ -33,7 +28,3 @@ export interface IBulkEntityJob extends IBaseJob {
|
|||
export interface IDeleteFilesJob extends IBaseJob {
|
||||
files: Array<string | null | undefined>;
|
||||
}
|
||||
|
||||
export interface IUserDeletionJob extends IBaseJob {
|
||||
user: UserEntity;
|
||||
}
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
import { JobName, QueueName } from './job.constants';
|
||||
import {
|
||||
IAssetFaceJob,
|
||||
IAssetJob,
|
||||
IBaseJob,
|
||||
IBulkEntityJob,
|
||||
IDeleteFilesJob,
|
||||
IEntityJob,
|
||||
IFaceThumbnailJob,
|
||||
IUserDeletionJob,
|
||||
} from './job.interface';
|
||||
|
||||
export interface JobCounts {
|
||||
|
@ -24,50 +23,46 @@ export interface QueueStatus {
|
|||
}
|
||||
|
||||
export type JobItem =
|
||||
// Asset Upload
|
||||
| { name: JobName.ASSET_UPLOADED; data: IAssetJob }
|
||||
|
||||
// Transcoding
|
||||
| { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob }
|
||||
| { name: JobName.VIDEO_CONVERSION; data: IAssetJob }
|
||||
| { name: JobName.VIDEO_CONVERSION; data: IEntityJob }
|
||||
|
||||
// Thumbnails
|
||||
| { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob }
|
||||
| { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IAssetJob }
|
||||
| { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IAssetJob }
|
||||
| { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IEntityJob }
|
||||
| { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IEntityJob }
|
||||
|
||||
// User Deletion
|
||||
| { name: JobName.USER_DELETE_CHECK }
|
||||
| { name: JobName.USER_DELETION; data: IUserDeletionJob }
|
||||
| { name: JobName.USER_DELETION; data: IEntityJob }
|
||||
|
||||
// Storage Template
|
||||
| { 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 }
|
||||
|
||||
// Metadata Extraction
|
||||
| { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
|
||||
| { name: JobName.EXIF_EXTRACTION; data: IAssetJob }
|
||||
| { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetJob }
|
||||
| { name: JobName.METADATA_EXTRACTION; data: IEntityJob }
|
||||
|
||||
// Sidecar Scanning
|
||||
| { name: JobName.QUEUE_SIDECAR; data: IBaseJob }
|
||||
| { name: JobName.SIDECAR_DISCOVERY; data: IAssetJob }
|
||||
| { name: JobName.SIDECAR_SYNC; data: IAssetJob }
|
||||
| { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob }
|
||||
| { name: JobName.SIDECAR_SYNC; data: IEntityJob }
|
||||
|
||||
// Object Tagging
|
||||
| { name: JobName.QUEUE_OBJECT_TAGGING; data: IBaseJob }
|
||||
| { name: JobName.DETECT_OBJECTS; data: IAssetJob }
|
||||
| { name: JobName.CLASSIFY_IMAGE; data: IAssetJob }
|
||||
| { name: JobName.DETECT_OBJECTS; data: IEntityJob }
|
||||
| { name: JobName.CLASSIFY_IMAGE; data: IEntityJob }
|
||||
|
||||
// Recognize Faces
|
||||
| { 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 }
|
||||
|
||||
// Clip Embedding
|
||||
| { name: JobName.QUEUE_ENCODE_CLIP; data: IBaseJob }
|
||||
| { name: JobName.ENCODE_CLIP; data: IAssetJob }
|
||||
| { name: JobName.ENCODE_CLIP; data: IEntityJob }
|
||||
|
||||
// Filesystem
|
||||
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
|
||||
|
|
|
@ -1,14 +1,20 @@
|
|||
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';
|
||||
|
||||
describe(JobService.name, () => {
|
||||
let sut: JobService;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
communicationMock = newCommunicationRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
sut = new JobService(jobMock);
|
||||
sut = new JobService(assetMock, communicationMock, jobMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
|
|
|
@ -1,21 +1,21 @@
|
|||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { IAssetRepository, mapAsset } from '../asset';
|
||||
import { CommunicationEvent, ICommunicationRepository } from '../communication';
|
||||
import { assertMachineLearningEnabled } from '../domain.constant';
|
||||
import { JobCommandDto } from './dto';
|
||||
import { JobCommand, JobName, QueueName } from './job.constants';
|
||||
import { IJobRepository } from './job.repository';
|
||||
import { IJobRepository, JobItem } from './job.repository';
|
||||
import { AllJobStatusResponseDto, JobStatusDto } from './response-dto';
|
||||
|
||||
@Injectable()
|
||||
export class JobService {
|
||||
private logger = new Logger(JobService.name);
|
||||
|
||||
constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {}
|
||||
|
||||
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 } });
|
||||
}
|
||||
constructor(
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
) {}
|
||||
|
||||
handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> {
|
||||
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
|
||||
|
@ -89,4 +89,51 @@ export class JobService {
|
|||
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 _ from 'lodash';
|
||||
import {
|
||||
assetEntityStub,
|
||||
newAssetRepositoryMock,
|
||||
newCommunicationRepositoryMock,
|
||||
newJobRepositoryMock,
|
||||
newMediaRepositoryMock,
|
||||
newStorageRepositoryMock,
|
||||
|
@ -11,7 +9,6 @@ import {
|
|||
probeStub,
|
||||
} from '../../test';
|
||||
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||
import { ICommunicationRepository } from '../communication';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { IStorageRepository } from '../storage';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
|
@ -22,7 +19,6 @@ describe(MediaService.name, () => {
|
|||
let sut: MediaService;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let mediaMock: jest.Mocked<IMediaRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
|
@ -30,12 +26,11 @@ describe(MediaService.name, () => {
|
|||
beforeEach(async () => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
communicationMock = newCommunicationRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
mediaMock = newMediaRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
|
||||
sut = new MediaService(assetMock, communicationMock, jobMock, mediaMock, storageMock, configMock);
|
||||
sut = new MediaService(assetMock, jobMock, mediaMock, storageMock, configMock);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
|
@ -55,7 +50,7 @@ describe(MediaService.name, () => {
|
|||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
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(jobMock.queue).toHaveBeenCalledWith({
|
||||
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', () => {
|
||||
it('should generate a thumbnail for an image', async () => {
|
||||
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(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]);
|
||||
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(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 () => {
|
||||
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(mediaMock.extractVideoThumbnail).toHaveBeenCalledWith(
|
||||
|
@ -138,37 +125,22 @@ describe(MediaService.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should queue some jobs', 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 () => {
|
||||
it('should run successfully', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
mediaMock.resize.mockRejectedValue(new Error('unsupported format'));
|
||||
mediaMock.extractThumbnailFromExif.mockRejectedValue(new Error('unsupported format'));
|
||||
|
||||
await sut.handleGenerateJpegThumbnail({ asset: assetEntityStub.image });
|
||||
|
||||
expect(assetMock.save).not.toHaveBeenCalled();
|
||||
await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGenerateWebpThumbnail', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
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(
|
||||
'/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' });
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
@ -200,7 +164,7 @@ describe(MediaService.name, () => {
|
|||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
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(jobMock.queue).toHaveBeenCalledWith({
|
||||
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', () => {
|
||||
|
@ -234,18 +190,11 @@ describe(MediaService.name, () => {
|
|||
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 () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
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(configMock.load).toHaveBeenCalled();
|
||||
|
@ -262,20 +211,23 @@ describe(MediaService.name, () => {
|
|||
|
||||
it('should skip a video without any streams', async () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it('should skip a video without any height', async () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it('should transcode when set to all', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
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(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
|
@ -289,7 +241,7 @@ describe(MediaService.name, () => {
|
|||
it('should transcode when optimal and too big', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||
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(
|
||||
'/original/path.ext',
|
||||
'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 () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
|
||||
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(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
|
@ -331,7 +284,8 @@ describe(MediaService.name, () => {
|
|||
it('should transcode when audio doesnt match target', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.audioStreamMp3);
|
||||
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(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
|
@ -352,7 +306,8 @@ describe(MediaService.name, () => {
|
|||
it('should transcode when container doesnt match target', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
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(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
|
@ -373,14 +328,16 @@ describe(MediaService.name, () => {
|
|||
it('should not transcode an invalid transcode value', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||
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();
|
||||
});
|
||||
|
||||
it('should set max bitrate if above 0', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
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(
|
||||
'/original/path.ext',
|
||||
'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_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(
|
||||
'/original/path.ext',
|
||||
'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 () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
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(
|
||||
'/original/path.ext',
|
||||
'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_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(
|
||||
'/original/path.ext',
|
||||
'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_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(
|
||||
'/original/path.ext',
|
||||
'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 () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
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(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import { AssetEntity, AssetType, TranscodePreset } from '@app/infra/entities';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { join } from 'path';
|
||||
import { IAssetRepository, mapAsset, WithoutProperty } from '../asset';
|
||||
import { CommunicationEvent, ICommunicationRepository } from '../communication';
|
||||
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||
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 { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
|
@ -19,7 +18,6 @@ export class MediaService {
|
|||
|
||||
constructor(
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
|
@ -28,155 +26,128 @@ export class MediaService {
|
|||
this.configCore = new SystemConfigCore(systemConfig);
|
||||
}
|
||||
|
||||
async handleQueueGenerateThumbnails(job: IBaseJob): Promise<void> {
|
||||
try {
|
||||
const { force } = job;
|
||||
async handleQueueGenerateThumbnails(job: IBaseJob) {
|
||||
const { force } = job;
|
||||
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL);
|
||||
});
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL);
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } });
|
||||
}
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
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> {
|
||||
const [asset] = await this.assetRepository.getByIds([data.asset.id]);
|
||||
|
||||
async handleGenerateJpegThumbnail({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset) {
|
||||
this.logger.warn(
|
||||
`Asset not found: ${data.asset.id} - Original Path: ${data.asset.originalPath} - Resize Path: ${data.asset.resizePath}`,
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const resizePath = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId);
|
||||
this.storageRepository.mkdirSync(resizePath);
|
||||
const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
|
||||
const resizePath = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId);
|
||||
this.storageRepository.mkdirSync(resizePath);
|
||||
const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
|
||||
|
||||
if (asset.type == AssetType.IMAGE) {
|
||||
try {
|
||||
await this.mediaRepository.resize(asset.originalPath, jpegThumbnailPath, {
|
||||
size: JPEG_THUMBNAIL_SIZE,
|
||||
format: 'jpeg',
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to generate jpeg thumbnail using sharp, trying with exiftool-vendored (asset=${asset.id})`,
|
||||
);
|
||||
await this.mediaRepository.extractThumbnailFromExif(asset.originalPath, jpegThumbnailPath);
|
||||
}
|
||||
if (asset.type == AssetType.IMAGE) {
|
||||
try {
|
||||
await this.mediaRepository.resize(asset.originalPath, jpegThumbnailPath, {
|
||||
size: JPEG_THUMBNAIL_SIZE,
|
||||
format: 'jpeg',
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to generate jpeg thumbnail using sharp, trying with exiftool-vendored (asset=${asset.id})`,
|
||||
);
|
||||
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> {
|
||||
const { asset } = data;
|
||||
|
||||
if (!asset.resizePath) {
|
||||
return;
|
||||
async handleGenerateWepbThumbnail({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset || !asset.resizePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const webpPath = asset.resizePath.replace('jpeg', 'webp');
|
||||
|
||||
try {
|
||||
await this.mediaRepository.resize(asset.resizePath, webpPath, { size: WEBP_THUMBNAIL_SIZE, format: 'webp' });
|
||||
await this.assetRepository.save({ id: asset.id, webpPath: webpPath });
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to generate webp thumbnail for asset: ${asset.id}`, error.stack);
|
||||
}
|
||||
await this.mediaRepository.resize(asset.resizePath, webpPath, { size: WEBP_THUMBNAIL_SIZE, format: 'webp' });
|
||||
await this.assetRepository.save({ id: asset.id, webpPath: webpPath });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleQueueVideoConversion(job: IBaseJob) {
|
||||
const { force } = job;
|
||||
|
||||
try {
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination, { type: AssetType.VIDEO })
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.ENCODED_VIDEO);
|
||||
});
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination, { type: AssetType.VIDEO })
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.ENCODED_VIDEO);
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { asset } });
|
||||
}
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
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) {
|
||||
const [asset] = await this.assetRepository.getByIds([job.asset.id]);
|
||||
|
||||
async handleVideoConversion({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset) {
|
||||
this.logger.warn(`Asset not found: ${job.asset.id} - Original Path: ${job.asset.originalPath}`);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const input = asset.originalPath;
|
||||
const outputFolder = this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, asset.ownerId);
|
||||
const output = join(outputFolder, `${asset.id}.mp4`);
|
||||
this.storageRepository.mkdirSync(outputFolder);
|
||||
const input = asset.originalPath;
|
||||
const outputFolder = this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, asset.ownerId);
|
||||
const output = join(outputFolder, `${asset.id}.mp4`);
|
||||
this.storageRepository.mkdirSync(outputFolder);
|
||||
|
||||
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input);
|
||||
const mainVideoStream = this.getMainVideoStream(videoStreams);
|
||||
const mainAudioStream = this.getMainAudioStream(audioStreams);
|
||||
const containerExtension = format.formatName;
|
||||
if (!mainVideoStream || !mainAudioStream || !containerExtension) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input);
|
||||
const mainVideoStream = this.getMainVideoStream(videoStreams);
|
||||
const mainAudioStream = this.getMainAudioStream(audioStreams);
|
||||
const containerExtension = format.formatName;
|
||||
if (!mainVideoStream || !mainAudioStream || !containerExtension) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
@ -33,7 +33,7 @@ describe(MetadataService.name, () => {
|
|||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
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(jobMock.queue).toHaveBeenCalledWith({
|
||||
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', () => {
|
||||
it('should skip hidden assets', async () => {
|
||||
await sut.handleSidecarSync({ asset: assetEntityStub.livePhotoMotionAsset });
|
||||
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 });
|
||||
it('should not error', async () => {
|
||||
await sut.handleSidecarSync();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSidecarDiscovery', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('should do nothing when a sidecar is not found ', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
storageMock.checkFileExists.mockResolvedValue(false);
|
||||
await sut.handleSidecarDiscovery({ asset: assetEntityStub.image });
|
||||
await sut.handleSidecarDiscovery({ id: assetEntityStub.image.id });
|
||||
expect(assetMock.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update a image asset when a sidecar is found', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
assetMock.save.mockResolvedValue(assetEntityStub.image);
|
||||
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(assetMock.save).toHaveBeenCalledWith({
|
||||
id: assetEntityStub.image.id,
|
||||
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 () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
assetMock.save.mockResolvedValue(assetEntityStub.video);
|
||||
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(assetMock.save).toHaveBeenCalledWith({
|
||||
id: assetEntityStub.image.id,
|
||||
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, Logger } from '@nestjs/common';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { constants } from 'fs/promises';
|
||||
import { AssetCore, IAssetRepository, WithoutProperty, WithProperty } from '../asset';
|
||||
import { IAssetRepository, WithoutProperty, WithProperty } from '../asset';
|
||||
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';
|
||||
|
||||
export class MetadataService {
|
||||
private logger = new Logger(MetadataService.name);
|
||||
private assetCore: AssetCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
) {
|
||||
this.assetCore = new AssetCore(assetRepository, jobRepository);
|
||||
}
|
||||
) {}
|
||||
|
||||
async handleQueueSidecar(job: IBaseJob) {
|
||||
try {
|
||||
const { force } = job;
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getWith(pagination, WithProperty.SIDECAR)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.SIDECAR);
|
||||
});
|
||||
const { force } = job;
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getWith(pagination, WithProperty.SIDECAR)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.SIDECAR);
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
const name = force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY;
|
||||
await this.jobRepository.queue({ name, data: { asset } });
|
||||
}
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
const name = force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY;
|
||||
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) {
|
||||
const { asset } = job;
|
||||
if (!asset.isVisible) {
|
||||
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 handleSidecarSync() {
|
||||
// TODO: optimize to only queue assets with recent xmp changes
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleSidecarDiscovery(job: IAssetJob) {
|
||||
let { asset } = job;
|
||||
if (!asset.isVisible || asset.sidecarPath) {
|
||||
return;
|
||||
async handleSidecarDiscovery({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset || !asset.isVisible || asset.sidecarPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const sidecarPath = `${asset.originalPath}.xmp`;
|
||||
const exists = await this.storageRepository.checkFileExists(sidecarPath, constants.W_OK);
|
||||
if (!exists) {
|
||||
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;
|
||||
const sidecarPath = `${asset.originalPath}.xmp`;
|
||||
const exists = await this.storageRepository.checkFileExists(sidecarPath, constants.W_OK);
|
||||
if (!exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.assetRepository.save({ id: asset.id, sidecarPath });
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -122,14 +122,5 @@ describe(PersonService.name, () => {
|
|||
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);
|
||||
}
|
||||
|
||||
async handlePersonCleanup(): Promise<void> {
|
||||
async handlePersonCleanup() {
|
||||
const people = await this.repository.getAllWithoutFaces();
|
||||
for (const person of people) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const sut = makeSut('false');
|
||||
|
||||
|
@ -250,15 +238,6 @@ describe(SearchService.name, () => {
|
|||
|
||||
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', () => {
|
||||
|
@ -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 () => {
|
||||
const sut = makeSut('false');
|
||||
|
||||
|
|
|
@ -137,122 +137,128 @@ export class SearchService {
|
|||
|
||||
async handleIndexAlbums() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const albums = this.patchAlbums(await this.albumRepository.getAll());
|
||||
this.logger.log(`Indexing ${albums.length} albums`);
|
||||
await this.searchRepository.importAlbums(albums, true);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Unable to index all albums`, error?.stack);
|
||||
}
|
||||
const albums = this.patchAlbums(await this.albumRepository.getAll());
|
||||
this.logger.log(`Indexing ${albums.length} albums`);
|
||||
await this.searchRepository.importAlbums(albums, true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleIndexAssets() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: do this in batches based on searchIndexVersion
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||
this.assetRepository.getAll(pagination, { isVisible: true }),
|
||||
);
|
||||
// TODO: do this in batches based on searchIndexVersion
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||
this.assetRepository.getAll(pagination, { isVisible: true }),
|
||||
);
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
this.logger.debug(`Indexing ${assets.length} assets`);
|
||||
for await (const assets of assetPagination) {
|
||||
this.logger.debug(`Indexing ${assets.length} assets`);
|
||||
|
||||
const patchedAssets = this.patchAssets(assets);
|
||||
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);
|
||||
const patchedAssets = this.patchAssets(assets);
|
||||
await this.searchRepository.importAssets(patchedAssets, false);
|
||||
}
|
||||
|
||||
await this.searchRepository.importAssets([], true);
|
||||
|
||||
this.logger.debug('Finished re-indexing all assets');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async handleIndexFaces() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: do this in batches based on searchIndexVersion
|
||||
const faces = this.patchFaces(await this.faceRepository.getAll());
|
||||
this.logger.log(`Indexing ${faces.length} faces`);
|
||||
// TODO: do this in batches based on searchIndexVersion
|
||||
const faces = this.patchFaces(await this.faceRepository.getAll());
|
||||
this.logger.log(`Indexing ${faces.length} faces`);
|
||||
|
||||
const chunkSize = 1000;
|
||||
for (let i = 0; i < faces.length; i += chunkSize) {
|
||||
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);
|
||||
const chunkSize = 1000;
|
||||
for (let i = 0; i < faces.length; i += chunkSize) {
|
||||
await this.searchRepository.importFaces(faces.slice(i, i + chunkSize), false);
|
||||
}
|
||||
|
||||
await this.searchRepository.importFaces([], true);
|
||||
|
||||
this.logger.debug('Finished re-indexing all faces');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
handleIndexAlbum({ ids }: IBulkEntityJob) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
this.albumQueue.upsert.add(id);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
handleIndexAsset({ ids }: IBulkEntityJob) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
this.assetQueue.upsert.add(id);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleIndexFace({ assetId, personId }: IAssetFaceJob) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// immediately push to typesense
|
||||
await this.searchRepository.importFaces(await this.idsToFaces([{ assetId, personId }]), false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
handleRemoveAlbum({ ids }: IBulkEntityJob) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
this.albumQueue.delete.add(id);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
handleRemoveAsset({ ids }: IBulkEntityJob) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
this.assetQueue.delete.add(id);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
handleRemoveFace({ assetId, personId }: IAssetFaceJob) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.faceQueue.delete.add(this.asKey({ assetId, personId }));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async flush() {
|
||||
|
|
|
@ -30,6 +30,8 @@ describe(SmartInfoService.name, () => {
|
|||
jobMock = newJobRepositoryMock();
|
||||
machineMock = newMachineLearningRepositoryMock();
|
||||
sut = new SmartInfoService(assetMock, jobMock, smartMock, machineMock);
|
||||
|
||||
assetMock.getByIds.mockResolvedValue([asset]);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
|
@ -46,8 +48,8 @@ describe(SmartInfoService.name, () => {
|
|||
await sut.handleQueueObjectTagging({ force: false });
|
||||
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[{ name: JobName.CLASSIFY_IMAGE, data: { asset: assetEntityStub.image } }],
|
||||
[{ name: JobName.DETECT_OBJECTS, data: { asset: assetEntityStub.image } }],
|
||||
[{ name: JobName.CLASSIFY_IMAGE, data: { id: assetEntityStub.image.id } }],
|
||||
[{ name: JobName.DETECT_OBJECTS, data: { id: assetEntityStub.image.id } }],
|
||||
]);
|
||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.OBJECT_TAGS);
|
||||
});
|
||||
|
@ -61,8 +63,8 @@ describe(SmartInfoService.name, () => {
|
|||
await sut.handleQueueObjectTagging({ force: true });
|
||||
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[{ name: JobName.CLASSIFY_IMAGE, data: { asset: assetEntityStub.image } }],
|
||||
[{ name: JobName.DETECT_OBJECTS, data: { asset: assetEntityStub.image } }],
|
||||
[{ name: JobName.CLASSIFY_IMAGE, data: { id: assetEntityStub.image.id } }],
|
||||
[{ name: JobName.DETECT_OBJECTS, data: { id: assetEntityStub.image.id } }],
|
||||
]);
|
||||
expect(assetMock.getAll).toHaveBeenCalled();
|
||||
});
|
||||
|
@ -70,7 +72,10 @@ describe(SmartInfoService.name, () => {
|
|||
|
||||
describe('handleTagImage', () => {
|
||||
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(machineMock.classifyImage).not.toHaveBeenCalled();
|
||||
|
@ -79,7 +84,7 @@ describe(SmartInfoService.name, () => {
|
|||
it('should save the returned tags', async () => {
|
||||
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(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 () => {
|
||||
machineMock.classifyImage.mockResolvedValue([]);
|
||||
|
||||
await sut.handleClassifyImage({ asset });
|
||||
await sut.handleClassifyImage({ id: asset.id });
|
||||
|
||||
expect(machineMock.classifyImage).toHaveBeenCalled();
|
||||
expect(smartMock.upsert).not.toHaveBeenCalled();
|
||||
|
@ -108,7 +105,10 @@ describe(SmartInfoService.name, () => {
|
|||
|
||||
describe('handleDetectObjects', () => {
|
||||
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(machineMock.detectObjects).not.toHaveBeenCalled();
|
||||
|
@ -117,7 +117,7 @@ describe(SmartInfoService.name, () => {
|
|||
it('should save the returned objects', async () => {
|
||||
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(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 () => {
|
||||
machineMock.detectObjects.mockResolvedValue([]);
|
||||
|
||||
await sut.handleDetectObjects({ asset });
|
||||
await sut.handleDetectObjects({ id: asset.id });
|
||||
|
||||
expect(machineMock.detectObjects).toHaveBeenCalled();
|
||||
expect(smartMock.upsert).not.toHaveBeenCalled();
|
||||
|
@ -153,7 +145,7 @@ describe(SmartInfoService.name, () => {
|
|||
|
||||
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);
|
||||
});
|
||||
|
||||
|
@ -165,14 +157,17 @@ describe(SmartInfoService.name, () => {
|
|||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleEncodeClip', () => {
|
||||
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(machineMock.encodeImage).not.toHaveBeenCalled();
|
||||
|
@ -181,7 +176,7 @@ describe(SmartInfoService.name, () => {
|
|||
it('should save the returned objects', async () => {
|
||||
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(smartMock.upsert).toHaveBeenCalledWith({
|
||||
|
@ -189,13 +184,5 @@ describe(SmartInfoService.name, () => {
|
|||
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 { MACHINE_LEARNING_ENABLED } from '../domain.constant';
|
||||
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 { ISmartInfoRepository } from './smart-info.repository';
|
||||
|
||||
|
@ -18,91 +18,82 @@ export class SmartInfoService {
|
|||
) {}
|
||||
|
||||
async handleQueueObjectTagging({ force }: IBaseJob) {
|
||||
try {
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.OBJECT_TAGS);
|
||||
});
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.OBJECT_TAGS);
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { asset } });
|
||||
await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } });
|
||||
}
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { id: asset.id } });
|
||||
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) {
|
||||
const { asset } = data;
|
||||
async handleDetectObjects({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
|
||||
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const objects = await this.machineLearning.detectObjects({ thumbnailPath: asset.resizePath });
|
||||
if (objects.length > 0) {
|
||||
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);
|
||||
const objects = await this.machineLearning.detectObjects({ thumbnailPath: asset.resizePath });
|
||||
if (objects.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.repository.upsert({ assetId: asset.id, objects });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleClassifyImage(data: IAssetJob) {
|
||||
const { asset } = data;
|
||||
async handleClassifyImage({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
|
||||
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const tags = await this.machineLearning.classifyImage({ thumbnailPath: asset.resizePath });
|
||||
if (tags.length > 0) {
|
||||
await this.repository.upsert({ assetId: asset.id, tags });
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Unable to run image tagging pipeline: ${asset.id}`, error?.stack);
|
||||
const tags = await this.machineLearning.classifyImage({ thumbnailPath: asset.resizePath });
|
||||
if (tags.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.repository.upsert({ assetId: asset.id, tags });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleQueueEncodeClip({ force }: IBaseJob) {
|
||||
try {
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.CLIP_ENCODING);
|
||||
});
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.CLIP_ENCODING);
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
|
||||
}
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
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) {
|
||||
const { asset } = data;
|
||||
async handleEncodeClip({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
|
||||
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const clipEmbedding = await this.machineLearning.encodeImage({ thumbnailPath: asset.resizePath });
|
||||
await this.repository.upsert({ assetId: asset.id, clipEmbedding: clipEmbedding });
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Unable run clip encoding pipeline: ${asset.id}`, error?.stack);
|
||||
}
|
||||
const clipEmbedding = await this.machineLearning.encodeImage({ thumbnailPath: asset.resizePath });
|
||||
await this.repository.upsert({ assetId: asset.id, clipEmbedding: clipEmbedding });
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 { APP_MEDIA_LOCATION } from '../domain.constant';
|
||||
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 { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository } from '../user/user.repository';
|
||||
|
@ -29,24 +29,22 @@ export class StorageTemplateService {
|
|||
this.core = new StorageTemplateCore(configRepository, config, storageRepository);
|
||||
}
|
||||
|
||||
async handleMigrationSingle(data: IAssetJob) {
|
||||
const { asset } = data;
|
||||
async handleMigrationSingle({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
|
||||
try {
|
||||
const user = await this.userRepository.get(asset.ownerId);
|
||||
const storageLabel = user?.storageLabel || null;
|
||||
const filename = asset.originalFileName || asset.id;
|
||||
await this.moveAsset(asset, { storageLabel, filename });
|
||||
const user = await this.userRepository.get(asset.ownerId);
|
||||
const storageLabel = user?.storageLabel || null;
|
||||
const filename = asset.originalFileName || asset.id;
|
||||
await this.moveAsset(asset, { storageLabel, filename });
|
||||
|
||||
// move motion part of live photo
|
||||
if (asset.livePhotoVideoId) {
|
||||
const [livePhotoVideo] = await this.assetRepository.getByIds([asset.livePhotoVideoId]);
|
||||
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
|
||||
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error('Error running single template migration', error);
|
||||
// move motion part of live photo
|
||||
if (asset.livePhotoVideoId) {
|
||||
const [livePhotoVideo] = await this.assetRepository.getByIds([asset.livePhotoVideoId]);
|
||||
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
|
||||
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleMigration() {
|
||||
|
@ -69,11 +67,11 @@ export class StorageTemplateService {
|
|||
|
||||
this.logger.debug('Cleaning up empty directories...');
|
||||
await this.storageRepository.removeEmptyDirs(APP_MEDIA_LOCATION);
|
||||
} catch (error: any) {
|
||||
this.logger.error('Error running template migration', error);
|
||||
} finally {
|
||||
console.timeEnd('migrating-time');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO: use asset core (once in domain)
|
||||
|
|
|
@ -11,6 +11,7 @@ export class StorageService {
|
|||
async handleDeleteFiles(job: IDeleteFilesJob) {
|
||||
const { files } = job;
|
||||
|
||||
// TODO: one job per file
|
||||
for (const file of files) {
|
||||
if (!file) {
|
||||
continue;
|
||||
|
@ -22,5 +23,7 @@ export class StorageService {
|
|||
this.logger.warn('Unable to remove file from disk', error);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,6 +46,7 @@ export class SystemConfigService {
|
|||
|
||||
async refreshConfig() {
|
||||
await this.core.refreshConfig();
|
||||
return true;
|
||||
}
|
||||
|
||||
addValidator(validator: SystemConfigValidator) {
|
||||
|
|
|
@ -455,21 +455,22 @@ describe(UserService.name, () => {
|
|||
});
|
||||
|
||||
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[]);
|
||||
|
||||
await sut.handleUserDeleteCheck();
|
||||
|
||||
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', () => {
|
||||
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(userMock.delete).not.toHaveBeenCalled();
|
||||
|
@ -477,8 +478,9 @@ describe(UserService.name, () => {
|
|||
|
||||
it('should delete the user and associated assets', async () => {
|
||||
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 };
|
||||
|
||||
|
@ -494,22 +496,13 @@ describe(UserService.name, () => {
|
|||
|
||||
it('should delete the library path for a storage label', async () => {
|
||||
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 };
|
||||
|
||||
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 { AuthUserDto } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||
import { IJobRepository, IUserDeletionJob, JobName } from '../job';
|
||||
import { IEntityJob, IJobRepository, JobName } from '../job';
|
||||
import { StorageCore, StorageFolder } from '../storage';
|
||||
import { IStorageRepository } from '../storage/storage.repository';
|
||||
import { IUserRepository } from '../user/user.repository';
|
||||
|
@ -138,44 +138,47 @@ export class UserService {
|
|||
const users = await this.userRepository.getDeletedUsers();
|
||||
for (const user of users) {
|
||||
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) {
|
||||
const { user } = data;
|
||||
async handleUserDelete({ id }: IEntityJob) {
|
||||
const user = await this.userRepository.get(id, true);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// just for extra protection here
|
||||
if (!this.isReadyForDeletion(user)) {
|
||||
this.logger.warn(`Skipped user that was not ready for deletion: id=${user.id}`);
|
||||
return;
|
||||
this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.log(`Deleting user: ${user.id}`);
|
||||
|
||||
try {
|
||||
const folders = [
|
||||
this.storageCore.getLibraryFolder(user),
|
||||
this.storageCore.getFolderLocation(StorageFolder.UPLOAD, user.id),
|
||||
this.storageCore.getFolderLocation(StorageFolder.PROFILE, user.id),
|
||||
this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, user.id),
|
||||
this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, user.id),
|
||||
];
|
||||
const folders = [
|
||||
this.storageCore.getLibraryFolder(user),
|
||||
this.storageCore.getFolderLocation(StorageFolder.UPLOAD, user.id),
|
||||
this.storageCore.getFolderLocation(StorageFolder.PROFILE, user.id),
|
||||
this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, user.id),
|
||||
this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, user.id),
|
||||
];
|
||||
|
||||
for (const folder of folders) {
|
||||
this.logger.warn(`Removing user from filesystem: ${folder}`);
|
||||
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 });
|
||||
for (const folder of folders) {
|
||||
this.logger.warn(`Removing user from filesystem: ${folder}`);
|
||||
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);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private isReadyForDeletion(user: UserEntity): boolean {
|
||||
|
|
|
@ -4,7 +4,7 @@ export const newStorageRepositoryMock = (): jest.Mocked<IStorageRepository> => {
|
|||
return {
|
||||
createReadStream: jest.fn(),
|
||||
unlink: jest.fn(),
|
||||
unlinkDir: jest.fn(),
|
||||
unlinkDir: jest.fn().mockResolvedValue(true),
|
||||
removeEmptyDirs: jest.fn(),
|
||||
moveFile: jest.fn(),
|
||||
checkFileExists: jest.fn(),
|
||||
|
|
|
@ -45,9 +45,6 @@ export class JobRepository implements IJobRepository {
|
|||
|
||||
private getJobOptions(item: JobItem): JobOptions | null {
|
||||
switch (item.name) {
|
||||
case JobName.ASSET_UPLOADED:
|
||||
return { jobId: item.data.asset.id };
|
||||
|
||||
case JobName.GENERATE_FACE_THUMBNAIL:
|
||||
return { priority: 1 };
|
||||
|
||||
|
|
Loading…
Reference in a new issue