mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
feat: facial recognition (#2180)
This commit is contained in:
@@ -18,6 +18,7 @@ export enum WithoutProperty {
|
||||
EXIF = 'exif',
|
||||
CLIP_ENCODING = 'clip-embedding',
|
||||
OBJECT_TAGS = 'object-tags',
|
||||
FACES = 'faces',
|
||||
}
|
||||
|
||||
export const IAssetRepository = 'IAssetRepository';
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { AssetEntity, AssetType } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { mapFace, PersonResponseDto } from '../../person';
|
||||
import { mapTag, TagResponseDto } from '../../tag';
|
||||
import { ExifResponseDto, mapExif } from './exif-response.dto';
|
||||
import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
|
||||
import { mapSmartInfo, SmartInfoResponseDto } from './smart-info-response.dto';
|
||||
|
||||
export class AssetResponseDto {
|
||||
id!: string;
|
||||
@@ -28,6 +29,7 @@ export class AssetResponseDto {
|
||||
smartInfo?: SmartInfoResponseDto;
|
||||
livePhotoVideoId?: string | null;
|
||||
tags?: TagResponseDto[];
|
||||
people?: PersonResponseDto[];
|
||||
}
|
||||
|
||||
export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
||||
@@ -53,6 +55,7 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
||||
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
tags: entity.tags?.map(mapTag),
|
||||
people: entity.faces?.map(mapFace),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,5 +82,6 @@ export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
|
||||
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
tags: entity.tags?.map(mapTag),
|
||||
people: entity.faces?.map(mapFace),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,27 +3,31 @@ import { AlbumService } from './album';
|
||||
import { APIKeyService } from './api-key';
|
||||
import { AssetService } from './asset';
|
||||
import { AuthService } from './auth';
|
||||
import { FacialRecognitionService } from './facial-recognition';
|
||||
import { JobService } from './job';
|
||||
import { MediaService } from './media';
|
||||
import { OAuthService } from './oauth';
|
||||
import { PartnerService } from './partner';
|
||||
import { PersonService } from './person';
|
||||
import { SearchService } from './search';
|
||||
import { ServerInfoService } from './server-info';
|
||||
import { ShareService } from './share';
|
||||
import { SmartInfoService } from './smart-info';
|
||||
import { StorageService } from './storage';
|
||||
import { StorageTemplateService } from './storage-template';
|
||||
import { UserService } from './user';
|
||||
import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
|
||||
import { UserService } from './user';
|
||||
|
||||
const providers: Provider[] = [
|
||||
AlbumService,
|
||||
APIKeyService,
|
||||
AssetService,
|
||||
AuthService,
|
||||
FacialRecognitionService,
|
||||
JobService,
|
||||
MediaService,
|
||||
OAuthService,
|
||||
PersonService,
|
||||
PartnerService,
|
||||
SearchService,
|
||||
ServerInfoService,
|
||||
|
||||
14
server/libs/domain/src/facial-recognition/face.repository.ts
Normal file
14
server/libs/domain/src/facial-recognition/face.repository.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { AssetFaceEntity } from '@app/infra/entities';
|
||||
|
||||
export const IFaceRepository = 'IFaceRepository';
|
||||
|
||||
export interface AssetFaceId {
|
||||
assetId: string;
|
||||
personId: string;
|
||||
}
|
||||
|
||||
export interface IFaceRepository {
|
||||
getAll(): Promise<AssetFaceEntity[]>;
|
||||
getByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
|
||||
create(entity: Partial<AssetFaceEntity>): Promise<AssetFaceEntity>;
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import {
|
||||
assetEntityStub,
|
||||
faceStub,
|
||||
newAssetRepositoryMock,
|
||||
newFaceRepositoryMock,
|
||||
newJobRepositoryMock,
|
||||
newMachineLearningRepositoryMock,
|
||||
newMediaRepositoryMock,
|
||||
newPersonRepositoryMock,
|
||||
newSearchRepositoryMock,
|
||||
newStorageRepositoryMock,
|
||||
personStub,
|
||||
} from '../../test';
|
||||
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { IMediaRepository } from '../media';
|
||||
import { IPersonRepository } from '../person';
|
||||
import { ISearchRepository } from '../search';
|
||||
import { IMachineLearningRepository } from '../smart-info';
|
||||
import { IStorageRepository } from '../storage';
|
||||
import { IFaceRepository } from './face.repository';
|
||||
import { FacialRecognitionService } from './facial-recognition.services';
|
||||
|
||||
const croppedFace = Buffer.from('Cropped Face');
|
||||
|
||||
const face = {
|
||||
start: {
|
||||
assetId: 'asset-1',
|
||||
personId: 'person-1',
|
||||
boundingBox: {
|
||||
x1: 5,
|
||||
y1: 5,
|
||||
x2: 505,
|
||||
y2: 505,
|
||||
},
|
||||
imageHeight: 1000,
|
||||
imageWidth: 1000,
|
||||
},
|
||||
middle: {
|
||||
assetId: 'asset-1',
|
||||
personId: 'person-1',
|
||||
boundingBox: {
|
||||
x1: 100,
|
||||
y1: 100,
|
||||
x2: 200,
|
||||
y2: 200,
|
||||
},
|
||||
imageHeight: 500,
|
||||
imageWidth: 400,
|
||||
embedding: [1, 2, 3, 4],
|
||||
score: 0.2,
|
||||
},
|
||||
end: {
|
||||
assetId: 'asset-1',
|
||||
personId: 'person-1',
|
||||
boundingBox: {
|
||||
x1: 300,
|
||||
y1: 300,
|
||||
x2: 495,
|
||||
y2: 495,
|
||||
},
|
||||
imageHeight: 500,
|
||||
imageWidth: 500,
|
||||
},
|
||||
};
|
||||
|
||||
const faceSearch = {
|
||||
noMatch: {
|
||||
total: 0,
|
||||
count: 0,
|
||||
page: 1,
|
||||
items: [],
|
||||
distances: [],
|
||||
facets: [],
|
||||
},
|
||||
oneMatch: {
|
||||
total: 1,
|
||||
count: 1,
|
||||
page: 1,
|
||||
items: [faceStub.face1],
|
||||
distances: [0.1],
|
||||
facets: [],
|
||||
},
|
||||
oneRemoteMatch: {
|
||||
total: 1,
|
||||
count: 1,
|
||||
page: 1,
|
||||
items: [faceStub.face1],
|
||||
distances: [0.8],
|
||||
facets: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe(FacialRecognitionService.name, () => {
|
||||
let sut: FacialRecognitionService;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let faceMock: jest.Mocked<IFaceRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let machineLearningMock: jest.Mocked<IMachineLearningRepository>;
|
||||
let mediaMock: jest.Mocked<IMediaRepository>;
|
||||
let personMock: jest.Mocked<IPersonRepository>;
|
||||
let searchMock: jest.Mocked<ISearchRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
faceMock = newFaceRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
machineLearningMock = newMachineLearningRepositoryMock();
|
||||
mediaMock = newMediaRepositoryMock();
|
||||
personMock = newPersonRepositoryMock();
|
||||
searchMock = newSearchRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
|
||||
mediaMock.crop.mockResolvedValue(croppedFace);
|
||||
|
||||
sut = new FacialRecognitionService(
|
||||
assetMock,
|
||||
faceMock,
|
||||
jobMock,
|
||||
machineLearningMock,
|
||||
mediaMock,
|
||||
personMock,
|
||||
searchMock,
|
||||
storageMock,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('handleQueueRecognizeFaces', () => {
|
||||
it('should queue missing assets', async () => {
|
||||
assetMock.getWithout.mockResolvedValue([assetEntityStub.image]);
|
||||
await sut.handleQueueRecognizeFaces({});
|
||||
|
||||
expect(assetMock.getWithout).toHaveBeenCalledWith(WithoutProperty.FACES);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.RECOGNIZE_FACES,
|
||||
data: { asset: assetEntityStub.image },
|
||||
});
|
||||
});
|
||||
|
||||
it('should queue all assets', async () => {
|
||||
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
|
||||
personMock.deleteAll.mockResolvedValue(5);
|
||||
searchMock.deleteAllFaces.mockResolvedValue(100);
|
||||
|
||||
await sut.handleQueueRecognizeFaces({ force: true });
|
||||
|
||||
expect(assetMock.getAll).toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.RECOGNIZE_FACES,
|
||||
data: { asset: assetEntityStub.image },
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
expect(machineLearningMock.detectFaces).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle no results', async () => {
|
||||
machineLearningMock.detectFaces.mockResolvedValue([]);
|
||||
await sut.handleRecognizeFaces({ asset: assetEntityStub.image });
|
||||
expect(machineLearningMock.detectFaces).toHaveBeenCalledWith({
|
||||
thumbnailPath: assetEntityStub.image.resizePath,
|
||||
});
|
||||
expect(faceMock.create).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should match existing people', async () => {
|
||||
machineLearningMock.detectFaces.mockResolvedValue([face.middle]);
|
||||
searchMock.searchFaces.mockResolvedValue(faceSearch.oneMatch);
|
||||
|
||||
await sut.handleRecognizeFaces({ asset: assetEntityStub.image });
|
||||
|
||||
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);
|
||||
|
||||
await sut.handleRecognizeFaces({ asset: assetEntityStub.image });
|
||||
|
||||
expect(personMock.create).toHaveBeenCalledWith({ ownerId: assetEntityStub.image.ownerId });
|
||||
expect(faceMock.create).toHaveBeenCalledWith({
|
||||
personId: 'person-1',
|
||||
assetId: 'asset-id',
|
||||
embedding: [1, 2, 3, 4],
|
||||
});
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
name: JobName.GENERATE_FACE_THUMBNAIL,
|
||||
data: {
|
||||
assetId: 'asset-1',
|
||||
personId: 'person-1',
|
||||
boundingBox: {
|
||||
x1: 100,
|
||||
y1: 100,
|
||||
x2: 200,
|
||||
y2: 200,
|
||||
},
|
||||
imageHeight: 500,
|
||||
imageWidth: 400,
|
||||
score: 0.2,
|
||||
},
|
||||
},
|
||||
],
|
||||
[{ 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', () => {
|
||||
it('should skip an asset not found', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([]);
|
||||
|
||||
await sut.handleGenerateFaceThumbnail(face.middle);
|
||||
|
||||
expect(mediaMock.crop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip an asset without a thumbnail', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
|
||||
|
||||
await sut.handleGenerateFaceThumbnail(face.middle);
|
||||
|
||||
expect(mediaMock.crop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should generate a thumbnail', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
|
||||
await sut.handleGenerateFaceThumbnail(face.middle);
|
||||
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']);
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', {
|
||||
left: 95,
|
||||
top: 95,
|
||||
width: 110,
|
||||
height: 110,
|
||||
});
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/person-1.jpeg', {
|
||||
format: 'jpeg',
|
||||
size: 250,
|
||||
});
|
||||
expect(personMock.update).toHaveBeenCalledWith({
|
||||
id: 'person-1',
|
||||
thumbnailPath: 'upload/thumbs/user-id/person-1.jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate a thumbnail without going negative', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
|
||||
await sut.handleGenerateFaceThumbnail(face.start);
|
||||
|
||||
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 510,
|
||||
height: 510,
|
||||
});
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/person-1.jpeg', {
|
||||
format: 'jpeg',
|
||||
size: 250,
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate a thumbnail without overflowing', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
|
||||
await sut.handleGenerateFaceThumbnail(face.end);
|
||||
|
||||
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', {
|
||||
left: 297,
|
||||
top: 297,
|
||||
width: 202,
|
||||
height: 202,
|
||||
});
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/person-1.jpeg', {
|
||||
format: 'jpeg',
|
||||
size: 250,
|
||||
});
|
||||
});
|
||||
|
||||
it('should log an error', async () => {
|
||||
assetMock.getByIds.mockRejectedValue(new Error('Database unavailable'));
|
||||
await sut.handleGenerateFaceThumbnail(face.middle);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { join } from 'path';
|
||||
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||
import { MACHINE_LEARNING_ENABLED } from '../domain.constant';
|
||||
import { IAssetJob, IBaseJob, IFaceThumbnailJob, IJobRepository, JobName } from '../job';
|
||||
import { CropOptions, FACE_THUMBNAIL_SIZE, IMediaRepository } from '../media';
|
||||
import { IPersonRepository } from '../person/person.repository';
|
||||
import { ISearchRepository } from '../search/search.repository';
|
||||
import { IMachineLearningRepository } from '../smart-info';
|
||||
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
|
||||
import { AssetFaceId, IFaceRepository } from './face.repository';
|
||||
|
||||
export class FacialRecognitionService {
|
||||
private logger = new Logger(FacialRecognitionService.name);
|
||||
private storageCore = new StorageCore();
|
||||
|
||||
constructor(
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IFaceRepository) private faceRepository: IFaceRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
|
||||
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
||||
@Inject(IPersonRepository) private personRepository: IPersonRepository,
|
||||
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
) {}
|
||||
|
||||
async handleQueueRecognizeFaces({ force }: IBaseJob) {
|
||||
try {
|
||||
const assets = force
|
||||
? await this.assetRepository.getAll()
|
||||
: await this.assetRepository.getWithout(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 (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);
|
||||
}
|
||||
}
|
||||
|
||||
async handleRecognizeFaces(data: IAssetJob) {
|
||||
const { asset } = data;
|
||||
|
||||
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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}]` })));
|
||||
|
||||
for (const { embedding, ...rest } of faces) {
|
||||
const faceSearchResult = await this.searchRepository.searchFaces(embedding, { ownerId: asset.ownerId });
|
||||
|
||||
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] } });
|
||||
}
|
||||
|
||||
// queue all faces for asset
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Unable run facial recognition pipeline: ${asset.id}`, error?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
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 null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
server/libs/domain/src/facial-recognition/index.ts
Normal file
2
server/libs/domain/src/facial-recognition/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './facial-recognition.services';
|
||||
export * from './face.repository';
|
||||
@@ -8,10 +8,12 @@ export * from './domain.config';
|
||||
export * from './domain.constant';
|
||||
export * from './domain.module';
|
||||
export * from './domain.util';
|
||||
export * from './facial-recognition';
|
||||
export * from './job';
|
||||
export * from './media';
|
||||
export * from './metadata';
|
||||
export * from './oauth';
|
||||
export * from './person';
|
||||
export * from './search';
|
||||
export * from './server-info';
|
||||
export * from './partner';
|
||||
|
||||
@@ -3,6 +3,7 @@ export enum QueueName {
|
||||
METADATA_EXTRACTION = 'metadata-extraction-queue',
|
||||
VIDEO_CONVERSION = 'video-conversion-queue',
|
||||
OBJECT_TAGGING = 'object-tagging-queue',
|
||||
RECOGNIZE_FACES = 'recognize-faces-queue',
|
||||
CLIP_ENCODING = 'clip-encoding-queue',
|
||||
BACKGROUND_TASK = 'background-task-queue',
|
||||
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue',
|
||||
@@ -48,16 +49,25 @@ export enum JobName {
|
||||
DETECT_OBJECTS = 'detect-objects',
|
||||
CLASSIFY_IMAGE = 'classify-image',
|
||||
|
||||
// facial recognition
|
||||
QUEUE_RECOGNIZE_FACES = 'queue-recognize-faces',
|
||||
RECOGNIZE_FACES = 'recognize-faces',
|
||||
GENERATE_FACE_THUMBNAIL = 'generate-face-thumbnail',
|
||||
PERSON_CLEANUP = 'person-cleanup',
|
||||
|
||||
// cleanup
|
||||
DELETE_FILES = 'delete-files',
|
||||
|
||||
// search
|
||||
SEARCH_INDEX_ASSETS = 'search-index-assets',
|
||||
SEARCH_INDEX_ASSET = 'search-index-asset',
|
||||
SEARCH_INDEX_FACE = 'search-index-face',
|
||||
SEARCH_INDEX_FACES = 'search-index-faces',
|
||||
SEARCH_INDEX_ALBUMS = 'search-index-albums',
|
||||
SEARCH_INDEX_ALBUM = 'search-index-album',
|
||||
SEARCH_REMOVE_ALBUM = 'search-remove-album',
|
||||
SEARCH_REMOVE_ASSET = 'search-remove-asset',
|
||||
SEARCH_REMOVE_FACE = 'search-remove-face',
|
||||
|
||||
// clip
|
||||
QUEUE_ENCODE_CLIP = 'queue-clip-encode',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities';
|
||||
import { BoundingBox } from '../smart-info';
|
||||
|
||||
export interface IBaseJob {
|
||||
force?: boolean;
|
||||
@@ -12,6 +13,19 @@ export interface IAssetJob extends IBaseJob {
|
||||
asset: AssetEntity;
|
||||
}
|
||||
|
||||
export interface IAssetFaceJob extends IBaseJob {
|
||||
assetId: string;
|
||||
personId: string;
|
||||
}
|
||||
|
||||
export interface IFaceThumbnailJob extends IAssetFaceJob {
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
boundingBox: BoundingBox;
|
||||
assetId: string;
|
||||
personId: string;
|
||||
}
|
||||
|
||||
export interface IBulkEntityJob extends IBaseJob {
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { JobName, QueueName } from './job.constants';
|
||||
import {
|
||||
IAssetFaceJob,
|
||||
IAssetJob,
|
||||
IAssetUploadedJob,
|
||||
IBaseJob,
|
||||
IBulkEntityJob,
|
||||
IDeleteFilesJob,
|
||||
IFaceThumbnailJob,
|
||||
IUserDeletionJob,
|
||||
} from './job.interface';
|
||||
|
||||
@@ -54,6 +56,11 @@ export type JobItem =
|
||||
| { name: JobName.DETECT_OBJECTS; data: IAssetJob }
|
||||
| { name: JobName.CLASSIFY_IMAGE; data: IAssetJob }
|
||||
|
||||
// Recognize Faces
|
||||
| { name: JobName.QUEUE_RECOGNIZE_FACES; data: IBaseJob }
|
||||
| { name: JobName.RECOGNIZE_FACES; data: IAssetJob }
|
||||
| { name: JobName.GENERATE_FACE_THUMBNAIL; data: IFaceThumbnailJob }
|
||||
|
||||
// Clip Embedding
|
||||
| { name: JobName.QUEUE_ENCODE_CLIP; data: IBaseJob }
|
||||
| { name: JobName.ENCODE_CLIP; data: IAssetJob }
|
||||
@@ -61,13 +68,19 @@ export type JobItem =
|
||||
// Filesystem
|
||||
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
|
||||
|
||||
// Asset Deletion
|
||||
| { name: JobName.PERSON_CLEANUP }
|
||||
|
||||
// Search
|
||||
| { name: JobName.SEARCH_INDEX_ASSETS }
|
||||
| { name: JobName.SEARCH_INDEX_ASSET; data: IBulkEntityJob }
|
||||
| { name: JobName.SEARCH_INDEX_FACES }
|
||||
| { name: JobName.SEARCH_INDEX_FACE; data: IAssetFaceJob }
|
||||
| { name: JobName.SEARCH_INDEX_ALBUMS }
|
||||
| { name: JobName.SEARCH_INDEX_ALBUM; data: IBulkEntityJob }
|
||||
| { name: JobName.SEARCH_REMOVE_ASSET; data: IBulkEntityJob }
|
||||
| { name: JobName.SEARCH_REMOVE_ALBUM; data: IBulkEntityJob };
|
||||
| { name: JobName.SEARCH_REMOVE_ALBUM; data: IBulkEntityJob }
|
||||
| { name: JobName.SEARCH_REMOVE_FACE; data: IAssetFaceJob };
|
||||
|
||||
export const IJobRepository = 'IJobRepository';
|
||||
|
||||
|
||||
@@ -15,6 +15,17 @@ describe(JobService.name, () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('handleNightlyJobs', () => {
|
||||
it('should run the scheduled jobs', async () => {
|
||||
await sut.handleNightlyJobs();
|
||||
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[{ name: JobName.USER_DELETE_CHECK }],
|
||||
[{ name: JobName.PERSON_CLEANUP }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllJobStatus', () => {
|
||||
it('should get all job statuses', async () => {
|
||||
jobMock.getJobCounts.mockResolvedValue({
|
||||
@@ -54,6 +65,7 @@ describe(JobService.name, () => {
|
||||
'storage-template-migration-queue': expectedJobStatus,
|
||||
'thumbnail-generation-queue': expectedJobStatus,
|
||||
'video-conversion-queue': expectedJobStatus,
|
||||
'recognize-faces-queue': expectedJobStatus,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,11 @@ export class JobService {
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> {
|
||||
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
|
||||
|
||||
@@ -73,6 +78,9 @@ export class JobService {
|
||||
case QueueName.THUMBNAIL_GENERATION:
|
||||
return this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force } });
|
||||
|
||||
case QueueName.RECOGNIZE_FACES:
|
||||
return this.jobRepository.queue({ name: JobName.QUEUE_RECOGNIZE_FACES, data: { force } });
|
||||
|
||||
default:
|
||||
throw new BadRequestException(`Invalid job name: ${name}`);
|
||||
}
|
||||
|
||||
@@ -53,4 +53,7 @@ export class AllJobStatusResponseDto implements Record<QueueName, JobStatusDto>
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.SEARCH]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.RECOGNIZE_FACES]!: JobStatusDto;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './media.constant';
|
||||
export * from './media.repository';
|
||||
export * from './media.service';
|
||||
|
||||
3
server/libs/domain/src/media/media.constant.ts
Normal file
3
server/libs/domain/src/media/media.constant.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const JPEG_THUMBNAIL_SIZE = 1440;
|
||||
export const WEBP_THUMBNAIL_SIZE = 250;
|
||||
export const FACE_THUMBNAIL_SIZE = 250;
|
||||
@@ -31,10 +31,18 @@ export interface VideoInfo {
|
||||
audioStreams: AudioStreamInfo[];
|
||||
}
|
||||
|
||||
export interface CropOptions {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface IMediaRepository {
|
||||
// image
|
||||
extractThumbnailFromExif(input: string, output: string): Promise<void>;
|
||||
resize(input: string, output: string, options: ResizeOptions): Promise<void>;
|
||||
resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>;
|
||||
crop(input: string, options: CropOptions): Promise<Buffer>;
|
||||
|
||||
// video
|
||||
extractVideoThumbnail(input: string, output: string, size: number): Promise<void>;
|
||||
|
||||
@@ -34,6 +34,7 @@ describe(MediaService.name, () => {
|
||||
jobMock = newJobRepositoryMock();
|
||||
mediaMock = newMediaRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
|
||||
sut = new MediaService(assetMock, communicationMock, jobMock, mediaMock, storageMock, configMock);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job';
|
||||
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
|
||||
import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { JPEG_THUMBNAIL_SIZE, WEBP_THUMBNAIL_SIZE } from './media.constant';
|
||||
import { AudioStreamInfo, IMediaRepository, VideoStreamInfo } from './media.repository';
|
||||
|
||||
@Injectable()
|
||||
@@ -57,11 +58,10 @@ export class MediaService {
|
||||
this.storageRepository.mkdirSync(resizePath);
|
||||
const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
|
||||
|
||||
const thumbnailDimension = 1440;
|
||||
if (asset.type == AssetType.IMAGE) {
|
||||
try {
|
||||
await this.mediaRepository.resize(asset.originalPath, jpegThumbnailPath, {
|
||||
size: thumbnailDimension,
|
||||
size: JPEG_THUMBNAIL_SIZE,
|
||||
format: 'jpeg',
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -74,7 +74,7 @@ export class MediaService {
|
||||
|
||||
if (asset.type == AssetType.VIDEO) {
|
||||
this.logger.log('Start Generating Video Thumbnail');
|
||||
await this.mediaRepository.extractVideoThumbnail(asset.originalPath, jpegThumbnailPath, thumbnailDimension);
|
||||
await this.mediaRepository.extractVideoThumbnail(asset.originalPath, jpegThumbnailPath, JPEG_THUMBNAIL_SIZE);
|
||||
this.logger.log(`Generating Video Thumbnail Success ${asset.id}`);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ export class MediaService {
|
||||
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) {
|
||||
@@ -103,7 +104,7 @@ export class MediaService {
|
||||
const webpPath = asset.resizePath.replace('jpeg', 'webp');
|
||||
|
||||
try {
|
||||
await this.mediaRepository.resize(asset.resizePath, webpPath, { size: 250, format: 'webp' });
|
||||
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);
|
||||
|
||||
1
server/libs/domain/src/person/dto/index.ts
Normal file
1
server/libs/domain/src/person/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './person-update.dto';
|
||||
7
server/libs/domain/src/person/dto/person-update.dto.ts
Normal file
7
server/libs/domain/src/person/dto/person-update.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class PersonUpdateDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
name!: string;
|
||||
}
|
||||
4
server/libs/domain/src/person/index.ts
Normal file
4
server/libs/domain/src/person/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './dto';
|
||||
export * from './person.repository';
|
||||
export * from './person.service';
|
||||
export * from './response-dto';
|
||||
19
server/libs/domain/src/person/person.repository.ts
Normal file
19
server/libs/domain/src/person/person.repository.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { AssetEntity, PersonEntity } from '@app/infra/entities';
|
||||
|
||||
export const IPersonRepository = 'IPersonRepository';
|
||||
|
||||
export interface PersonSearchOptions {
|
||||
minimumFaceCount: number;
|
||||
}
|
||||
|
||||
export interface IPersonRepository {
|
||||
getAll(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>;
|
||||
getAllWithoutFaces(): Promise<PersonEntity[]>;
|
||||
getById(userId: string, personId: string): Promise<PersonEntity | null>;
|
||||
getAssets(userId: string, id: string): Promise<AssetEntity[]>;
|
||||
|
||||
create(entity: Partial<PersonEntity>): Promise<PersonEntity>;
|
||||
update(entity: Partial<PersonEntity>): Promise<PersonEntity>;
|
||||
delete(entity: PersonEntity): Promise<PersonEntity | null>;
|
||||
deleteAll(): Promise<number>;
|
||||
}
|
||||
135
server/libs/domain/src/person/person.service.spec.ts
Normal file
135
server/libs/domain/src/person/person.service.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { IJobRepository, JobName } from '..';
|
||||
import {
|
||||
assetEntityStub,
|
||||
authStub,
|
||||
newJobRepositoryMock,
|
||||
newPersonRepositoryMock,
|
||||
newStorageRepositoryMock,
|
||||
personStub,
|
||||
} from '../../test';
|
||||
import { IStorageRepository } from '../storage';
|
||||
import { IPersonRepository } from './person.repository';
|
||||
import { PersonService } from './person.service';
|
||||
import { PersonResponseDto } from './response-dto';
|
||||
|
||||
const responseDto: PersonResponseDto = {
|
||||
id: 'person-1',
|
||||
name: 'Person 1',
|
||||
thumbnailPath: '/path/to/thumbnail',
|
||||
};
|
||||
|
||||
describe(PersonService.name, () => {
|
||||
let sut: PersonService;
|
||||
let personMock: jest.Mocked<IPersonRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
personMock = newPersonRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
sut = new PersonService(personMock, storageMock, jobMock);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should get all people with thumbnails', async () => {
|
||||
personMock.getAll.mockResolvedValue([personStub.withName, personStub.noThumbnail]);
|
||||
await expect(sut.getAll(authStub.admin)).resolves.toEqual([responseDto]);
|
||||
expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('should throw a bad request when person is not found', async () => {
|
||||
personMock.getById.mockResolvedValue(null);
|
||||
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should get a person by id', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.withName);
|
||||
await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto);
|
||||
expect(personMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThumbnail', () => {
|
||||
it('should throw an error when personId is invalid', async () => {
|
||||
personMock.getById.mockResolvedValue(null);
|
||||
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(storageMock.createReadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error when person has no thumbnail', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.noThumbnail);
|
||||
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(storageMock.createReadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should serve the thumbnail', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.noName);
|
||||
await sut.getThumbnail(authStub.admin, 'person-1');
|
||||
expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail', 'image/jpeg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAssets', () => {
|
||||
it("should return a person's assets", async () => {
|
||||
personMock.getAssets.mockResolvedValue([assetEntityStub.image, assetEntityStub.video]);
|
||||
await sut.getAssets(authStub.admin, 'person-1');
|
||||
expect(personMock.getAssets).toHaveBeenCalledWith('admin_id', 'person-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should throw an error when personId is invalid', async () => {
|
||||
personMock.getById.mockResolvedValue(null);
|
||||
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(personMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should update a person's name", async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.noName);
|
||||
personMock.update.mockResolvedValue(personStub.withName);
|
||||
personMock.getAssets.mockResolvedValue([assetEntityStub.image]);
|
||||
|
||||
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto);
|
||||
|
||||
expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1');
|
||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' });
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.SEARCH_INDEX_ASSET,
|
||||
data: { ids: [assetEntityStub.image.id] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePersonCleanup', () => {
|
||||
it('should delete people without faces', async () => {
|
||||
personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]);
|
||||
|
||||
await sut.handlePersonCleanup();
|
||||
|
||||
expect(personMock.delete).toHaveBeenCalledWith(personStub.noName);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.DELETE_FILES,
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
82
server/libs/domain/src/person/person.service.ts
Normal file
82
server/libs/domain/src/person/person.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { AssetResponseDto, mapAsset } from '../asset';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { ImmichReadStream, IStorageRepository } from '../storage';
|
||||
import { PersonUpdateDto } from './dto';
|
||||
import { IPersonRepository } from './person.repository';
|
||||
import { mapPerson, PersonResponseDto } from './response-dto';
|
||||
|
||||
@Injectable()
|
||||
export class PersonService {
|
||||
readonly logger = new Logger(PersonService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(IPersonRepository) private repository: IPersonRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
) {}
|
||||
|
||||
async getAll(authUser: AuthUserDto): Promise<PersonResponseDto[]> {
|
||||
const people = await this.repository.getAll(authUser.id, { minimumFaceCount: 1 });
|
||||
const named = people.filter((person) => !!person.name);
|
||||
const unnamed = people.filter((person) => !person.name);
|
||||
return (
|
||||
[...named, ...unnamed]
|
||||
// with thumbnails
|
||||
.filter((person) => !!person.thumbnailPath)
|
||||
.map((person) => mapPerson(person))
|
||||
);
|
||||
}
|
||||
|
||||
async getById(authUser: AuthUserDto, personId: string): Promise<PersonResponseDto> {
|
||||
const person = await this.repository.getById(authUser.id, personId);
|
||||
if (!person) {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
return mapPerson(person);
|
||||
}
|
||||
|
||||
async getThumbnail(authUser: AuthUserDto, personId: string): Promise<ImmichReadStream> {
|
||||
const person = await this.repository.getById(authUser.id, personId);
|
||||
if (!person || !person.thumbnailPath) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return this.storageRepository.createReadStream(person.thumbnailPath, 'image/jpeg');
|
||||
}
|
||||
|
||||
async getAssets(authUser: AuthUserDto, personId: string): Promise<AssetResponseDto[]> {
|
||||
const assets = await this.repository.getAssets(authUser.id, personId);
|
||||
return assets.map(mapAsset);
|
||||
}
|
||||
|
||||
async update(authUser: AuthUserDto, personId: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
||||
const exists = await this.repository.getById(authUser.id, personId);
|
||||
if (!exists) {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
const person = await this.repository.update({ id: personId, name: dto.name });
|
||||
|
||||
const relatedAsset = await this.getAssets(authUser, personId);
|
||||
const assetIds = relatedAsset.map((asset) => asset.id);
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: assetIds } });
|
||||
|
||||
return mapPerson(person);
|
||||
}
|
||||
|
||||
async handlePersonCleanup(): Promise<void> {
|
||||
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.`);
|
||||
try {
|
||||
await this.repository.delete(person);
|
||||
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [person.thumbnailPath] } });
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(`Unable to delete person: ${error}`, error?.stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
server/libs/domain/src/person/response-dto/index.ts
Normal file
1
server/libs/domain/src/person/response-dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './person-response.dto';
|
||||
@@ -0,0 +1,19 @@
|
||||
import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
|
||||
|
||||
export class PersonResponseDto {
|
||||
id!: string;
|
||||
name!: string;
|
||||
thumbnailPath!: string;
|
||||
}
|
||||
|
||||
export function mapPerson(person: PersonEntity): PersonResponseDto {
|
||||
return {
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
thumbnailPath: person.thumbnailPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapFace(face: AssetFaceEntity): PersonResponseDto {
|
||||
return mapPerson(face.person);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { AlbumEntity, AssetEntity, AssetType } from '@app/infra/entities';
|
||||
import { AlbumEntity, AssetEntity, AssetFaceEntity, AssetType } from '@app/infra/entities';
|
||||
|
||||
export enum SearchCollection {
|
||||
ASSETS = 'assets',
|
||||
ALBUMS = 'albums',
|
||||
FACES = 'faces',
|
||||
}
|
||||
|
||||
export enum SearchStrategy {
|
||||
@@ -10,6 +11,10 @@ export enum SearchStrategy {
|
||||
TEXT = 'TEXT',
|
||||
}
|
||||
|
||||
export interface SearchFaceFilter {
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export interface SearchFilter {
|
||||
id?: string;
|
||||
userId: string;
|
||||
@@ -37,6 +42,8 @@ export interface SearchResult<T> {
|
||||
page: number;
|
||||
/** items for page */
|
||||
items: T[];
|
||||
/** score */
|
||||
distances: number[];
|
||||
facets: SearchFacet[];
|
||||
}
|
||||
|
||||
@@ -56,6 +63,13 @@ export interface SearchExploreItem<T> {
|
||||
}>;
|
||||
}
|
||||
|
||||
export type OwnedFaceEntity = Pick<AssetFaceEntity, 'assetId' | 'personId' | 'embedding'> & {
|
||||
/** computed as assetId|personId */
|
||||
id: string;
|
||||
/** copied from asset.id */
|
||||
ownerId: string;
|
||||
};
|
||||
|
||||
export type SearchCollectionIndexStatus = Record<SearchCollection, boolean>;
|
||||
|
||||
export const ISearchRepository = 'ISearchRepository';
|
||||
@@ -66,13 +80,17 @@ export interface ISearchRepository {
|
||||
|
||||
importAlbums(items: AlbumEntity[], done: boolean): Promise<void>;
|
||||
importAssets(items: AssetEntity[], done: boolean): Promise<void>;
|
||||
importFaces(items: OwnedFaceEntity[], done: boolean): Promise<void>;
|
||||
|
||||
deleteAlbums(ids: string[]): Promise<void>;
|
||||
deleteAssets(ids: string[]): Promise<void>;
|
||||
deleteFaces(ids: string[]): Promise<void>;
|
||||
deleteAllFaces(): Promise<number>;
|
||||
|
||||
searchAlbums(query: string, filters: SearchFilter): Promise<SearchResult<AlbumEntity>>;
|
||||
searchAssets(query: string, filters: SearchFilter): Promise<SearchResult<AssetEntity>>;
|
||||
vectorSearch(query: number[], filters: SearchFilter): Promise<SearchResult<AssetEntity>>;
|
||||
searchFaces(query: number[], filters: SearchFaceFilter): Promise<SearchResult<AssetFaceEntity>>;
|
||||
|
||||
explore(userId: string): Promise<SearchExploreItem<AssetEntity>[]>;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
assetEntityStub,
|
||||
asyncTick,
|
||||
authStub,
|
||||
faceStub,
|
||||
newAlbumRepositoryMock,
|
||||
newAssetRepositoryMock,
|
||||
newFaceRepositoryMock,
|
||||
newJobRepositoryMock,
|
||||
newMachineLearningRepositoryMock,
|
||||
newSearchRepositoryMock,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
} from '../../test';
|
||||
import { IAlbumRepository } from '../album/album.repository';
|
||||
import { IAssetRepository } from '../asset/asset.repository';
|
||||
import { IFaceRepository } from '../facial-recognition';
|
||||
import { JobName } from '../job';
|
||||
import { IJobRepository } from '../job/job.repository';
|
||||
import { IMachineLearningRepository } from '../smart-info';
|
||||
@@ -28,20 +31,29 @@ describe(SearchService.name, () => {
|
||||
let sut: SearchService;
|
||||
let albumMock: jest.Mocked<IAlbumRepository>;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let faceMock: jest.Mocked<IFaceRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let machineMock: jest.Mocked<IMachineLearningRepository>;
|
||||
let searchMock: jest.Mocked<ISearchRepository>;
|
||||
let configMock: jest.Mocked<ConfigService>;
|
||||
|
||||
const makeSut = (value?: string) => {
|
||||
if (value) {
|
||||
configMock.get.mockReturnValue(value);
|
||||
}
|
||||
return new SearchService(albumMock, assetMock, faceMock, jobMock, machineMock, searchMock, configMock);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
albumMock = newAlbumRepositoryMock();
|
||||
assetMock = newAssetRepositoryMock();
|
||||
faceMock = newFaceRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
machineMock = newMachineLearningRepositoryMock();
|
||||
searchMock = newSearchRepositoryMock();
|
||||
configMock = { get: jest.fn() } as unknown as jest.Mocked<ConfigService>;
|
||||
|
||||
sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
||||
sut = makeSut();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -80,8 +92,7 @@ describe(SearchService.name, () => {
|
||||
});
|
||||
|
||||
it('should be disabled via an env variable', () => {
|
||||
configMock.get.mockReturnValue('false');
|
||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
||||
const sut = makeSut('false');
|
||||
|
||||
expect(sut.isEnabled()).toBe(false);
|
||||
});
|
||||
@@ -93,8 +104,7 @@ describe(SearchService.name, () => {
|
||||
});
|
||||
|
||||
it('should return the config when search is disabled', () => {
|
||||
configMock.get.mockReturnValue('false');
|
||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
||||
const sut = makeSut('false');
|
||||
|
||||
expect(sut.getConfig()).toEqual({ enabled: false });
|
||||
});
|
||||
@@ -102,8 +112,7 @@ describe(SearchService.name, () => {
|
||||
|
||||
describe(`bootstrap`, () => {
|
||||
it('should skip when search is disabled', async () => {
|
||||
configMock.get.mockReturnValue('false');
|
||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
||||
const sut = makeSut('false');
|
||||
|
||||
await sut.bootstrap();
|
||||
|
||||
@@ -115,7 +124,7 @@ describe(SearchService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip schema migration if not needed', async () => {
|
||||
searchMock.checkMigrationStatus.mockResolvedValue({ assets: false, albums: false });
|
||||
searchMock.checkMigrationStatus.mockResolvedValue({ assets: false, albums: false, faces: false });
|
||||
await sut.bootstrap();
|
||||
|
||||
expect(searchMock.setup).toHaveBeenCalled();
|
||||
@@ -123,21 +132,21 @@ describe(SearchService.name, () => {
|
||||
});
|
||||
|
||||
it('should do schema migration if needed', async () => {
|
||||
searchMock.checkMigrationStatus.mockResolvedValue({ assets: true, albums: true });
|
||||
searchMock.checkMigrationStatus.mockResolvedValue({ assets: true, albums: true, faces: true });
|
||||
await sut.bootstrap();
|
||||
|
||||
expect(searchMock.setup).toHaveBeenCalled();
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[{ name: JobName.SEARCH_INDEX_ASSETS }],
|
||||
[{ name: JobName.SEARCH_INDEX_ALBUMS }],
|
||||
[{ name: JobName.SEARCH_INDEX_FACES }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should throw an error is search is disabled', async () => {
|
||||
configMock.get.mockReturnValue('false');
|
||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
||||
const sut = makeSut('false');
|
||||
|
||||
await expect(sut.search(authStub.admin, {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
@@ -157,6 +166,7 @@ describe(SearchService.name, () => {
|
||||
page: 1,
|
||||
items: [],
|
||||
facets: [],
|
||||
distances: [],
|
||||
},
|
||||
assets: {
|
||||
total: 0,
|
||||
@@ -164,6 +174,7 @@ describe(SearchService.name, () => {
|
||||
page: 1,
|
||||
items: [],
|
||||
facets: [],
|
||||
distances: [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -202,8 +213,7 @@ describe(SearchService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip if search is disabled', async () => {
|
||||
configMock.get.mockReturnValue('false');
|
||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
||||
const sut = makeSut('false');
|
||||
|
||||
await sut.handleIndexAssets();
|
||||
|
||||
@@ -214,8 +224,7 @@ describe(SearchService.name, () => {
|
||||
|
||||
describe('handleIndexAsset', () => {
|
||||
it('should skip if search is disabled', () => {
|
||||
configMock.get.mockReturnValue('false');
|
||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
||||
const sut = makeSut('false');
|
||||
sut.handleIndexAsset({ ids: [assetEntityStub.image.id] });
|
||||
});
|
||||
|
||||
@@ -226,8 +235,7 @@ describe(SearchService.name, () => {
|
||||
|
||||
describe('handleIndexAlbums', () => {
|
||||
it('should skip if search is disabled', () => {
|
||||
configMock.get.mockReturnValue('false');
|
||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
||||
const sut = makeSut('false');
|
||||
sut.handleIndexAlbums();
|
||||
});
|
||||
|
||||
@@ -251,8 +259,7 @@ describe(SearchService.name, () => {
|
||||
|
||||
describe('handleIndexAlbum', () => {
|
||||
it('should skip if search is disabled', () => {
|
||||
configMock.get.mockReturnValue('false');
|
||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
||||
const sut = makeSut('false');
|
||||
sut.handleIndexAlbum({ ids: [albumStub.empty.id] });
|
||||
});
|
||||
|
||||
@@ -263,8 +270,7 @@ describe(SearchService.name, () => {
|
||||
|
||||
describe('handleRemoveAlbum', () => {
|
||||
it('should skip if search is disabled', () => {
|
||||
configMock.get.mockReturnValue('false');
|
||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
||||
const sut = makeSut('false');
|
||||
sut.handleRemoveAlbum({ ids: ['album1'] });
|
||||
});
|
||||
|
||||
@@ -275,8 +281,7 @@ describe(SearchService.name, () => {
|
||||
|
||||
describe('handleRemoveAsset', () => {
|
||||
it('should skip if search is disabled', () => {
|
||||
configMock.get.mockReturnValue('false');
|
||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
||||
const sut = makeSut('false');
|
||||
sut.handleRemoveAsset({ ids: ['asset1'] });
|
||||
});
|
||||
|
||||
@@ -285,6 +290,84 @@ describe(SearchService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleIndexFaces', () => {
|
||||
it('should call done, even when there are no faces', async () => {
|
||||
faceMock.getAll.mockResolvedValue([]);
|
||||
|
||||
await sut.handleIndexFaces();
|
||||
|
||||
expect(searchMock.importFaces).toHaveBeenCalledWith([], true);
|
||||
});
|
||||
|
||||
it('should index all the faces', async () => {
|
||||
faceMock.getAll.mockResolvedValue([faceStub.face1]);
|
||||
|
||||
await sut.handleIndexFaces();
|
||||
|
||||
expect(searchMock.importFaces.mock.calls).toEqual([
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'asset-id|person-1',
|
||||
ownerId: 'user-id',
|
||||
assetId: 'asset-id',
|
||||
personId: 'person-1',
|
||||
embedding: [1, 2, 3, 4],
|
||||
},
|
||||
],
|
||||
false,
|
||||
],
|
||||
[[], true],
|
||||
]);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
await sut.handleIndexFaces();
|
||||
|
||||
expect(searchMock.importFaces).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleIndexAsset', () => {
|
||||
it('should skip if search is disabled', () => {
|
||||
const sut = makeSut('false');
|
||||
sut.handleIndexFace({ assetId: 'asset-1', personId: 'person-1' });
|
||||
|
||||
expect(searchMock.importFaces).not.toHaveBeenCalled();
|
||||
expect(faceMock.getByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should index the face', () => {
|
||||
faceMock.getByIds.mockResolvedValue([faceStub.face1]);
|
||||
|
||||
sut.handleIndexFace({ assetId: 'asset-1', personId: 'person-1' });
|
||||
|
||||
expect(faceMock.getByIds).toHaveBeenCalledWith([{ assetId: 'asset-1', personId: 'person-1' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleRemoveFace', () => {
|
||||
it('should skip if search is disabled', () => {
|
||||
const sut = makeSut('false');
|
||||
sut.handleRemoveFace({ assetId: 'asset-1', personId: 'person-1' });
|
||||
});
|
||||
|
||||
it('should remove the face', () => {
|
||||
sut.handleRemoveFace({ assetId: 'asset-1', personId: 'person-1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('flush', () => {
|
||||
it('should flush queued album updates', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlbumEntity, AssetEntity } from '@app/infra/entities';
|
||||
import { AlbumEntity, AssetEntity, AssetFaceEntity } from '@app/infra/entities';
|
||||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { mapAlbum } from '../album';
|
||||
@@ -7,12 +7,14 @@ import { mapAsset } from '../asset';
|
||||
import { IAssetRepository } from '../asset/asset.repository';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { MACHINE_LEARNING_ENABLED } from '../domain.constant';
|
||||
import { IBulkEntityJob, IJobRepository, JobName } from '../job';
|
||||
import { AssetFaceId, IFaceRepository } from '../facial-recognition';
|
||||
import { IAssetFaceJob, IBulkEntityJob, IJobRepository, JobName } from '../job';
|
||||
import { IMachineLearningRepository } from '../smart-info';
|
||||
import { SearchDto } from './dto';
|
||||
import { SearchConfigResponseDto, SearchResponseDto } from './response-dto';
|
||||
import {
|
||||
ISearchRepository,
|
||||
OwnedFaceEntity,
|
||||
SearchCollection,
|
||||
SearchExploreItem,
|
||||
SearchResult,
|
||||
@@ -40,9 +42,15 @@ export class SearchService {
|
||||
delete: new Set(),
|
||||
};
|
||||
|
||||
private faceQueue: SyncQueue = {
|
||||
upsert: new Set(),
|
||||
delete: new Set(),
|
||||
};
|
||||
|
||||
constructor(
|
||||
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IFaceRepository) private faceRepository: IFaceRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
|
||||
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
|
||||
@@ -88,6 +96,10 @@ export class SearchService {
|
||||
this.logger.debug('Queueing job to re-index all albums');
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUMS });
|
||||
}
|
||||
if (migrationStatus[SearchCollection.FACES]) {
|
||||
this.logger.debug('Queueing job to re-index all faces');
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACES });
|
||||
}
|
||||
}
|
||||
|
||||
async getExploreData(authUser: AuthUserDto): Promise<SearchExploreItem<AssetEntity>[]> {
|
||||
@@ -159,6 +171,29 @@ export class SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
async handleIndexFaces() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
handleIndexAlbum({ ids }: IBulkEntityJob) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
@@ -179,6 +214,15 @@ export class SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
async handleIndexFace({ assetId, personId }: IAssetFaceJob) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// immediately push to typesense
|
||||
await this.searchRepository.importFaces(await this.idsToFaces([{ assetId, personId }]), false);
|
||||
}
|
||||
|
||||
handleRemoveAlbum({ ids }: IBulkEntityJob) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
@@ -199,6 +243,14 @@ export class SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
handleRemoveFace({ assetId, personId }: IAssetFaceJob) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.faceQueue.delete.add(this.asKey({ assetId, personId }));
|
||||
}
|
||||
|
||||
private async flush() {
|
||||
if (this.albumQueue.upsert.size > 0) {
|
||||
const ids = [...this.albumQueue.upsert.keys()];
|
||||
@@ -229,6 +281,21 @@ export class SearchService {
|
||||
await this.searchRepository.deleteAssets(ids);
|
||||
this.assetQueue.delete.clear();
|
||||
}
|
||||
|
||||
if (this.faceQueue.upsert.size > 0) {
|
||||
const ids = [...this.faceQueue.upsert.keys()].map((key) => this.asParts(key));
|
||||
const items = await this.idsToFaces(ids);
|
||||
this.logger.debug(`Flushing ${items.length} face upserts`);
|
||||
await this.searchRepository.importFaces(items, false);
|
||||
this.faceQueue.upsert.clear();
|
||||
}
|
||||
|
||||
if (this.faceQueue.delete.size > 0) {
|
||||
const ids = [...this.faceQueue.delete.keys()];
|
||||
this.logger.debug(`Flushing ${ids.length} face deletes`);
|
||||
await this.searchRepository.deleteFaces(ids);
|
||||
this.faceQueue.delete.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private assertEnabled() {
|
||||
@@ -247,6 +314,10 @@ export class SearchService {
|
||||
return this.patchAssets(entities.filter((entity) => entity.isVisible));
|
||||
}
|
||||
|
||||
private async idsToFaces(ids: AssetFaceId[]): Promise<OwnedFaceEntity[]> {
|
||||
return this.patchFaces(await this.faceRepository.getByIds(ids));
|
||||
}
|
||||
|
||||
private patchAssets(assets: AssetEntity[]): AssetEntity[] {
|
||||
return assets;
|
||||
}
|
||||
@@ -254,4 +325,23 @@ export class SearchService {
|
||||
private patchAlbums(albums: AlbumEntity[]): AlbumEntity[] {
|
||||
return albums.map((entity) => ({ ...entity, assets: [] }));
|
||||
}
|
||||
|
||||
private patchFaces(faces: AssetFaceEntity[]): OwnedFaceEntity[] {
|
||||
return faces.map((face) => ({
|
||||
id: this.asKey(face),
|
||||
ownerId: face.asset.ownerId,
|
||||
assetId: face.assetId,
|
||||
personId: face.personId,
|
||||
embedding: face.embedding,
|
||||
}));
|
||||
}
|
||||
|
||||
private asKey(face: AssetFaceId): string {
|
||||
return `${face.assetId}|${face.personId}`;
|
||||
}
|
||||
|
||||
private asParts(key: string): AssetFaceId {
|
||||
const [assetId, personId] = key.split('|');
|
||||
return { assetId, personId };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,25 @@ export interface MachineLearningInput {
|
||||
thumbnailPath: string;
|
||||
}
|
||||
|
||||
export interface BoundingBox {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
export interface DetectFaceResult {
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
boundingBox: BoundingBox;
|
||||
score: number;
|
||||
embedding: number[];
|
||||
}
|
||||
|
||||
export interface IMachineLearningRepository {
|
||||
classifyImage(input: MachineLearningInput): Promise<string[]>;
|
||||
detectObjects(input: MachineLearningInput): Promise<string[]>;
|
||||
encodeImage(input: MachineLearningInput): Promise<number[]>;
|
||||
encodeText(input: string): Promise<number[]>;
|
||||
detectFaces(input: MachineLearningInput): Promise<DetectFaceResult[]>;
|
||||
}
|
||||
|
||||
@@ -435,7 +435,7 @@ describe(UserService.name, () => {
|
||||
{ deletedAt: makeDeletedAt(5) },
|
||||
] as UserEntity[]);
|
||||
|
||||
await sut.handleQueueUserDelete();
|
||||
await sut.handleUserDeleteCheck();
|
||||
|
||||
expect(userRepositoryMock.getDeletedUsers).toHaveBeenCalled();
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
@@ -445,7 +445,7 @@ describe(UserService.name, () => {
|
||||
const user = { deletedAt: makeDeletedAt(10) };
|
||||
userRepositoryMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]);
|
||||
|
||||
await sut.handleQueueUserDelete();
|
||||
await sut.handleUserDeleteCheck();
|
||||
|
||||
expect(userRepositoryMock.getDeletedUsers).toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.USER_DELETION, data: { user } });
|
||||
|
||||
@@ -143,7 +143,7 @@ export class UserService {
|
||||
return { admin, password, provided: !!providedPassword };
|
||||
}
|
||||
|
||||
async handleQueueUserDelete() {
|
||||
async handleUserDeleteCheck() {
|
||||
const users = await this.userRepository.getDeletedUsers();
|
||||
for (const user of users) {
|
||||
if (this.isReadyForDeletion(user)) {
|
||||
|
||||
Reference in New Issue
Block a user