feat(web): re-assign person faces (2) (#4949)

* feat: unassign person faces

* multiple improvements

* chore: regenerate api

* feat: improve face interactions in photos

* fix: tests

* fix: tests

* optimize

* fix: wrong assignment on complex-multiple re-assignments

* fix: thumbnails with large photos

* fix: complex reassign

* fix: don't send people with faces

* fix: person thumbnail generation

* chore: regenerate api

* add tess

* feat: face box even when zoomed

* fix: change feature photo

* feat: make the blue icon hoverable

* chore: regenerate api

* feat: use websocket

* fix: loading spinner when clicking on the done button

* fix: use the svelte way

* fix: tests

* simplify

* fix: unused vars

* fix: remove unused code

* fix: add migration

* chore: regenerate api

* ci: add unit tests

* chore: regenerate api

* feat: if a new person is created for a face and the server takes more than 15 seconds to generate the person thumbnail, don't wait for it

* reorganize

* chore: regenerate api

* feat: global edit

* pr feedback

* pr feedback

* simplify

* revert test

* fix: face generation

* fix: tests

* fix: face generation

* fix merge

* feat: search names in unmerge face selector modal

* fix: merge face selector

* simplify feature photo generation

* fix: change endpoint

* pr feedback

* chore: fix merge

* chore: fix merge

* fix: tests

* fix: edit & hide buttons

* fix: tests

* feat: show if person is hidden

* feat: rename face to person

* feat: split in new panel

* copy-paste-error

* pr feedback

* fix: feature photo

* do not leak faces

* fix: unmerge modal

* fix: merge modal event

* feat(server): remove duplicates

* fix: title for image thumbnails

* fix: disable side panel when there's no face until next PR

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
martin
2023-12-05 16:43:15 +01:00
committed by GitHub
parent 982183600d
commit 7702560b12
74 changed files with 4882 additions and 283 deletions

View File

@@ -41,6 +41,8 @@ export enum Permission {
PERSON_READ = 'person.read',
PERSON_WRITE = 'person.write',
PERSON_MERGE = 'person.merge',
PERSON_CREATE = 'person.create',
PERSON_REASSIGN = 'person.reassign',
PARTNER_UPDATE = 'partner.update',
}
@@ -247,6 +249,12 @@ export class AccessCore {
case Permission.PERSON_MERGE:
return await this.repository.person.checkOwnerAccess(authUser.id, ids);
case Permission.PERSON_CREATE:
return this.repository.person.hasFaceOwnerAccess(authUser.id, ids);
case Permission.PERSON_REASSIGN:
return this.repository.person.hasFaceOwnerAccess(authUser.id, ids);
case Permission.PARTNER_UPDATE:
return await this.repository.partner.checkUpdateAccess(authUser.id, ids);
}

View File

@@ -1,6 +1,6 @@
import { AssetEntity, AssetType } from '@app/infra/entities';
import { AssetEntity, AssetFaceEntity, AssetType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { PersonResponseDto, mapFace } from '../../person/person.dto';
import { PersonWithFacesResponseDto } from '../../person/person.dto';
import { TagResponseDto, mapTag } from '../../tag';
import { UserResponseDto, mapUser } from '../../user/response-dto/user-response.dto';
import { ExifResponseDto, mapExif } from './exif-response.dto';
@@ -39,7 +39,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
exifInfo?: ExifResponseDto;
smartInfo?: SmartInfoResponseDto;
tags?: TagResponseDto[];
people?: PersonResponseDto[];
people?: PersonWithFacesResponseDto[];
/**base64 encoded sha1 hash */
checksum!: string;
stackParentId?: string | null;
@@ -53,6 +53,24 @@ export type AssetMapOptions = {
withStack?: boolean;
};
const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] => {
const result: PersonWithFacesResponseDto[] = [];
if (faces) {
faces.forEach((face) => {
if (face.person) {
const existingPersonEntry = result.find((item) => item.id === face.person!.id);
if (existingPersonEntry) {
existingPersonEntry.faces.push(face);
} else {
result.push({ ...face.person!, faces: [face] });
}
}
});
}
return result;
};
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
const { stripMetadata = false, withStack = false } = options;
@@ -96,16 +114,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map(mapTag),
people: entity.faces
?.map(mapFace)
.filter((person): person is PersonResponseDto => person !== null)
.reduce((people, person) => {
const existingPerson = people.find((p) => p.id === person.id);
if (!existingPerson) {
people.push(person);
}
return people;
}, [] as PersonResponseDto[]),
people: peopleWithFaces(entity.faces),
checksum: entity.checksum.toString('base64'),
stackParentId: entity.stackParentId,
stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined,

View File

@@ -201,7 +201,7 @@ export class JobService {
const { id } = item.data;
const person = await this.personRepository.getById(id);
if (person) {
this.communicationRepository.send(CommunicationEvent.PERSON_THUMBNAIL, person.ownerId, id);
this.communicationRepository.send(CommunicationEvent.PERSON_THUMBNAIL, person.ownerId, person.id);
}
break;

View File

@@ -2,6 +2,7 @@ import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsArray, IsBoolean, IsDate, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import { AuthUserDto } from '../auth';
import { Optional, ValidateUUID, toBoolean } from '../domain.util';
export class PersonUpdateDto {
@@ -73,6 +74,51 @@ export class PersonResponseDto {
isHidden!: boolean;
}
export class PersonWithFacesResponseDto extends PersonResponseDto {
faces!: AssetFaceWithoutPersonResponseDto[];
}
export class AssetFaceWithoutPersonResponseDto {
@ValidateUUID()
id!: string;
@ApiProperty({ type: 'integer' })
imageHeight!: number;
@ApiProperty({ type: 'integer' })
imageWidth!: number;
@ApiProperty({ type: 'integer' })
boundingBoxX1!: number;
@ApiProperty({ type: 'integer' })
boundingBoxX2!: number;
@ApiProperty({ type: 'integer' })
boundingBoxY1!: number;
@ApiProperty({ type: 'integer' })
boundingBoxY2!: number;
}
export class AssetFaceResponseDto extends AssetFaceWithoutPersonResponseDto {
person!: PersonResponseDto | null;
}
export class AssetFaceUpdateDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetFaceUpdateItem)
data!: AssetFaceUpdateItem[];
}
export class FaceDto {
@ValidateUUID()
id!: string;
}
export class AssetFaceUpdateItem {
@ValidateUUID()
personId!: string;
@ValidateUUID()
assetId!: string;
}
export class PersonStatisticsResponseDto {
@ApiProperty({ type: 'integer' })
assets!: number;
@@ -98,10 +144,15 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
};
}
export function mapFace(face: AssetFaceEntity): PersonResponseDto | null {
if (face.person) {
return mapPerson(face.person);
}
return null;
export function mapFaces(face: AssetFaceEntity, authUser: AuthUserDto): AssetFaceResponseDto {
return {
id: face.id,
imageHeight: face.imageHeight,
imageWidth: face.imageWidth,
boundingBoxX1: face.boundingBoxX1,
boundingBoxX2: face.boundingBoxX2,
boundingBoxY1: face.boundingBoxY1,
boundingBoxY2: face.boundingBoxY2,
person: face.person?.ownerId === authUser.id ? mapPerson(face.person) : null,
};
}

View File

@@ -31,7 +31,7 @@ import {
ISystemConfigRepository,
WithoutProperty,
} from '../repositories';
import { PersonResponseDto } from './person.dto';
import { PersonResponseDto, mapFaces } from './person.dto';
import { PersonService } from './person.service';
const responseDto: PersonResponseDto = {
@@ -339,7 +339,7 @@ describe(PersonService.name, () => {
).resolves.toEqual(responseDto);
expect(personMock.getById).toHaveBeenCalledWith('person-1');
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.assetId });
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id });
expect(personMock.getFacesByIds).toHaveBeenCalledWith([
{
assetId: faceStub.face1.assetId,
@@ -375,6 +375,139 @@ describe(PersonService.name, () => {
});
});
describe('reassignFaces', () => {
it('should throw an error if user has no access to the person', async () => {
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set());
await expect(
sut.reassignFaces(authStub.admin, personStub.noName.id, {
data: [{ personId: 'asset-face-1', assetId: '' }],
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(jobMock.queue).not.toHaveBeenCalledWith();
});
it('should reassign a face', async () => {
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id]));
personMock.getById.mockResolvedValue(personStub.noName);
accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
personMock.reassignFace.mockResolvedValue(1);
personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1);
await expect(
sut.reassignFaces(authStub.admin, personStub.noName.id, {
data: [{ personId: personStub.withName.id, assetId: assetStub.image.id }],
}),
).resolves.toEqual([personStub.noName]);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.GENERATE_PERSON_THUMBNAIL,
data: { id: personStub.newThumbnail.id },
});
});
});
describe('handlePersonMigration', () => {
it('should not move person files', async () => {
personMock.getById.mockResolvedValue(null);
await expect(sut.handlePersonMigration(personStub.noName)).resolves.toStrictEqual(false);
});
});
describe('getFacesById', () => {
it('should get the bounding boxes for an asset', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId]));
personMock.getFaces.mockResolvedValue([faceStub.primaryFace1]);
await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([
mapFaces(faceStub.primaryFace1, authStub.admin),
]);
});
it('should reject if the user has not access to the asset', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set());
personMock.getFaces.mockResolvedValue([faceStub.primaryFace1]);
await expect(sut.getFacesById(authStub.admin, { id: faceStub.primaryFace1.assetId })).rejects.toBeInstanceOf(
BadRequestException,
);
});
});
describe('createNewFeaturePhoto', () => {
it('should change person feature photo', async () => {
personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1);
await sut.createNewFeaturePhoto([personStub.newThumbnail.id]);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.GENERATE_PERSON_THUMBNAIL,
data: { id: personStub.newThumbnail.id },
});
});
});
describe('reassignFacesById', () => {
it('should create a new person', async () => {
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
personMock.getFaceById.mockResolvedValue(faceStub.face1);
personMock.reassignFace.mockResolvedValue(1);
personMock.getById.mockResolvedValue(personStub.noName);
personMock.getRandomFace.mockResolvedValue(null);
await expect(
sut.reassignFacesById(authStub.admin, personStub.noName.id, {
id: faceStub.face1.id,
}),
).resolves.toEqual({
birthDate: personStub.noName.birthDate,
isHidden: personStub.noName.isHidden,
id: personStub.noName.id,
name: personStub.noName.name,
thumbnailPath: personStub.noName.thumbnailPath,
});
expect(jobMock.queue).not.toHaveBeenCalledWith();
});
it('should fail if user has not the correct permissions on the asset', async () => {
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set());
personMock.getFaceById.mockResolvedValue(faceStub.face1);
personMock.reassignFace.mockResolvedValue(1);
personMock.getById.mockResolvedValue(personStub.noName);
personMock.getRandomFace.mockResolvedValue(null);
await expect(
sut.reassignFacesById(authStub.admin, personStub.noName.id, {
id: faceStub.face1.id,
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(jobMock.queue).not.toHaveBeenCalledWith();
});
});
describe('createPerson', () => {
it('should create a new person', async () => {
personMock.create.mockResolvedValue(personStub.primaryPerson);
personMock.getFaceById.mockResolvedValue(faceStub.face1);
accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
await expect(sut.createPerson(authStub.admin)).resolves.toBe(personStub.primaryPerson);
});
});
describe('handlePersonDelete', () => {
it('should stop if a person has not be found', async () => {
personMock.getById.mockResolvedValue(null);
await expect(sut.handlePersonDelete({ id: 'person-1' })).resolves.toBe(false);
expect(personMock.update).not.toHaveBeenCalled();
expect(storageMock.unlink).not.toHaveBeenCalled();
});
it('should delete a person', async () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson);
await expect(sut.handlePersonDelete({ id: 'person-1' })).resolves.toBe(true);
expect(personMock.delete).toHaveBeenCalledWith(personStub.primaryPerson);
expect(storageMock.unlink).toHaveBeenCalledWith(personStub.primaryPerson.thumbnailPath);
});
});
describe('handlePersonCleanup', () => {
it('should delete people without faces', async () => {
personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]);
@@ -515,6 +648,7 @@ describe(PersonService.name, () => {
searchMock.searchFaces.mockResolvedValue(faceSearch.oneRemoteMatch);
personMock.create.mockResolvedValue(personStub.noName);
assetMock.getByIds.mockResolvedValue([assetStub.image]);
personMock.createFace.mockResolvedValue(faceStub.primaryFace1);
await sut.handleRecognizeFaces({ id: assetStub.image.id });
@@ -557,16 +691,16 @@ describe(PersonService.name, () => {
expect(mediaMock.crop).not.toHaveBeenCalled();
});
it('should skip an person with a face asset id not found', async () => {
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
it('should skip a person with a face asset id not found', async () => {
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.id });
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mediaMock.crop).not.toHaveBeenCalled();
});
it('should skip a person with a face asset id without a thumbnail', async () => {
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1);
assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mediaMock.crop).not.toHaveBeenCalled();
@@ -574,7 +708,7 @@ describe(PersonService.name, () => {
it('should generate a thumbnail', async () => {
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
personMock.getFacesByIds.mockResolvedValue([faceStub.middle]);
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle);
assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
@@ -601,7 +735,7 @@ describe(PersonService.name, () => {
it('should generate a thumbnail without going negative', async () => {
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId });
personMock.getFacesByIds.mockResolvedValue([faceStub.start]);
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.start);
assetMock.getByIds.mockResolvedValue([assetStub.image]);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
@@ -622,7 +756,7 @@ describe(PersonService.name, () => {
it('should generate a thumbnail without overflowing', async () => {
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId });
personMock.getFacesByIds.mockResolvedValue([faceStub.end]);
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end);
assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
@@ -646,15 +780,12 @@ describe(PersonService.name, () => {
it('should require person.write and person.merge permission', async () => {
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
personMock.getById.mockResolvedValueOnce(personStub.mergePerson);
personMock.prepareReassignFaces.mockResolvedValue([]);
personMock.delete.mockResolvedValue(personStub.mergePerson);
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(personMock.prepareReassignFaces).not.toHaveBeenCalled();
expect(personMock.reassignFaces).not.toHaveBeenCalled();
expect(personMock.delete).not.toHaveBeenCalled();
@@ -664,7 +795,6 @@ describe(PersonService.name, () => {
it('should merge two people', async () => {
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
personMock.getById.mockResolvedValueOnce(personStub.mergePerson);
personMock.prepareReassignFaces.mockResolvedValue([]);
personMock.delete.mockResolvedValue(personStub.mergePerson);
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
@@ -673,11 +803,6 @@ describe(PersonService.name, () => {
{ id: 'person-2', success: true },
]);
expect(personMock.prepareReassignFaces).toHaveBeenCalledWith({
newPersonId: personStub.primaryPerson.id,
oldPersonId: personStub.mergePerson.id,
});
expect(personMock.reassignFaces).toHaveBeenCalledWith({
newPersonId: personStub.primaryPerson.id,
oldPersonId: personStub.mergePerson.id,
@@ -690,29 +815,6 @@ describe(PersonService.name, () => {
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it('should delete conflicting faces before merging', async () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson);
personMock.getById.mockResolvedValue(personStub.mergePerson);
personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
{ id: 'person-2', success: true },
]);
expect(personMock.prepareReassignFaces).toHaveBeenCalledWith({
newPersonId: personStub.primaryPerson.id,
oldPersonId: personStub.mergePerson.id,
});
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEARCH_REMOVE_FACE,
data: { assetId: assetStub.image.id, personId: personStub.mergePerson.id },
});
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it('should throw an error when the primary person is not found', async () => {
personMock.getById.mockResolvedValue(null);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
@@ -735,7 +837,6 @@ describe(PersonService.name, () => {
{ id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
]);
expect(personMock.prepareReassignFaces).not.toHaveBeenCalled();
expect(personMock.reassignFaces).not.toHaveBeenCalled();
expect(personMock.delete).not.toHaveBeenCalled();
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
@@ -744,7 +845,6 @@ describe(PersonService.name, () => {
it('should handle an error reassigning faces', async () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson);
personMock.getById.mockResolvedValue(personStub.mergePerson);
personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
personMock.reassignFaces.mockRejectedValue(new Error('update failed'));
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));

View File

@@ -28,6 +28,9 @@ import {
import { StorageCore } from '../storage';
import { SystemConfigCore } from '../system-config';
import {
AssetFaceResponseDto,
AssetFaceUpdateDto,
FaceDto,
MergePersonDto,
PeopleResponseDto,
PeopleUpdateDto,
@@ -35,6 +38,7 @@ import {
PersonSearchDto,
PersonStatisticsResponseDto,
PersonUpdateDto,
mapFaces,
mapPerson,
} from './person.dto';
@@ -80,6 +84,86 @@ export class PersonService {
};
}
createPerson(authUser: AuthUserDto): Promise<PersonResponseDto> {
return this.repository.create({ ownerId: authUser.id });
}
async reassignFaces(authUser: AuthUserDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
await this.access.requirePermission(authUser, Permission.PERSON_WRITE, personId);
const person = await this.findOrFail(personId);
const result: PersonResponseDto[] = [];
const changeFeaturePhoto: string[] = [];
for (const data of dto.data) {
const faces = await this.repository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]);
for (const face of faces) {
await this.access.requirePermission(authUser, Permission.PERSON_CREATE, face.id);
if (person.faceAssetId === null) {
changeFeaturePhoto.push(person.id);
}
if (face.person && face.person.faceAssetId === face.id) {
changeFeaturePhoto.push(face.person.id);
}
await this.repository.reassignFace(face.id, personId);
}
result.push(person);
}
if (changeFeaturePhoto.length > 0) {
// Remove duplicates
await this.createNewFeaturePhoto(Array.from(new Set(changeFeaturePhoto)));
}
return result;
}
async reassignFacesById(authUser: AuthUserDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
await this.access.requirePermission(authUser, Permission.PERSON_WRITE, personId);
await this.access.requirePermission(authUser, Permission.PERSON_CREATE, dto.id);
const face = await this.repository.getFaceById(dto.id);
const person = await this.findOrFail(personId);
await this.repository.reassignFace(face.id, personId);
if (person.faceAssetId === null) {
await this.createNewFeaturePhoto([person.id]);
}
if (face.person && face.person.faceAssetId === face.id) {
await this.createNewFeaturePhoto([face.person.id]);
}
return await this.findOrFail(personId).then(mapPerson);
}
async getFacesById(authUser: AuthUserDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
await this.access.requirePermission(authUser, Permission.ASSET_READ, dto.id);
const faces = await this.repository.getFaces(dto.id);
return faces.map((asset) => mapFaces(asset, authUser));
}
async createNewFeaturePhoto(changeFeaturePhoto: string[]) {
this.logger.debug(
`Changing feature photos for ${changeFeaturePhoto.length} ${changeFeaturePhoto.length > 1 ? 'people' : 'person'}`,
);
for (const personId of changeFeaturePhoto) {
const assetFace = await this.repository.getRandomFace(personId);
if (assetFace !== null) {
await this.repository.update({
id: personId,
faceAssetId: assetFace.id,
});
await this.jobRepository.queue({
name: JobName.GENERATE_PERSON_THUMBNAIL,
data: {
id: personId,
},
});
}
}
}
async getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> {
await this.access.requirePermission(authUser, Permission.PERSON_READ, id);
return this.findOrFail(id).then(mapPerson);
@@ -128,7 +212,7 @@ export class PersonService {
throw new BadRequestException('Invalid assetId for feature face');
}
person = await this.repository.update({ id, faceAssetId: assetId });
person = await this.repository.update({ id, faceAssetId: face.id });
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
}
@@ -255,9 +339,9 @@ export class PersonService {
personId = newPerson.id;
}
const faceId: AssetFaceId = { assetId: asset.id, personId };
await this.repository.createFace({
...faceId,
const face = await this.repository.createFace({
assetId: asset.id,
personId,
embedding,
imageHeight: rest.imageHeight,
imageWidth: rest.imageWidth,
@@ -266,10 +350,11 @@ export class PersonService {
boundingBoxY1: rest.boundingBox.y1,
boundingBoxY2: rest.boundingBox.y2,
});
const faceId: AssetFaceId = { assetId: asset.id, personId };
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId });
if (newPerson) {
await this.repository.update({ id: personId, faceAssetId: asset.id });
await this.repository.update({ id: personId, faceAssetId: face.id });
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } });
}
}
@@ -304,14 +389,13 @@ export class PersonService {
return false;
}
const [face] = await this.repository.getFacesByIds([{ personId: person.id, assetId: person.faceAssetId }]);
if (!face) {
const face = await this.repository.getFaceByIdWithAssets(person.faceAssetId);
if (face === null) {
return false;
}
const {
assetId,
personId,
boundingBoxX1: x1,
boundingBoxX2: x2,
boundingBoxY1: y1,
@@ -324,8 +408,7 @@ export class PersonService {
if (!asset?.resizePath) {
return false;
}
this.logger.verbose(`Cropping face for person: ${personId}`);
this.logger.verbose(`Cropping face for person: ${person.id}`);
const thumbnailPath = StorageCore.getPersonThumbnailPath(person);
this.storageCore.ensureFolders(thumbnailPath);
@@ -395,10 +478,6 @@ export class PersonService {
const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id };
this.logger.log(`Merging ${mergeName} into ${primaryName}`);
const assetIds = await this.repository.prepareReassignFaces(mergeData);
for (const assetId of assetIds) {
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId: mergeId } });
}
await this.repository.reassignFaces(mergeData);
await this.jobRepository.queue({ name: JobName.PERSON_DELETE, data: { id: mergePerson.id } });

View File

@@ -34,6 +34,7 @@ export interface IAccessRepository {
};
person: {
hasFaceOwnerAccess(userId: string, assetFaceId: Set<string>): Promise<Set<string>>;
checkOwnerAccess(userId: string, personIds: Set<string>): Promise<Set<string>>;
};

View File

@@ -34,7 +34,7 @@ export interface IPersonRepository {
getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise<PersonEntity[]>;
getAssets(personId: string): Promise<AssetEntity[]>;
prepareReassignFaces(data: UpdateFacesData): Promise<string[]>;
reassignFaces(data: UpdateFacesData): Promise<number>;
create(entity: Partial<PersonEntity>): Promise<PersonEntity>;
@@ -48,4 +48,8 @@ export interface IPersonRepository {
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
getRandomFace(personId: string): Promise<AssetFaceEntity | null>;
createFace(entity: Partial<AssetFaceEntity>): Promise<AssetFaceEntity>;
getFaces(assetId: string): Promise<AssetFaceEntity[]>;
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
getFaceById(id: string): Promise<AssetFaceEntity>;
getFaceByIdWithAssets(id: string): Promise<AssetFaceEntity | null>;
}

View File

@@ -19,6 +19,7 @@ import {
AssetsController,
AuditController,
AuthController,
FaceController,
JobController,
LibraryController,
OAuthController,
@@ -50,6 +51,7 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors';
APIKeyController,
AuditController,
AuthController,
FaceController,
JobController,
LibraryController,
OAuthController,

View File

@@ -0,0 +1,28 @@
import { AssetFaceResponseDto, AuthUserDto, FaceDto, PersonResponseDto, PersonService } from '@app/domain';
import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthUser, Authenticated } from '../app.guard';
import { UseValidation } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Face')
@Controller('face')
@Authenticated()
@UseValidation()
export class FaceController {
constructor(private service: PersonService) {}
@Get()
getFaces(@AuthUser() authUser: AuthUserDto, @Query() dto: FaceDto): Promise<AssetFaceResponseDto[]> {
return this.service.getFacesById(authUser, dto);
}
@Put(':id')
reassignFacesById(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: FaceDto,
): Promise<PersonResponseDto> {
return this.service.reassignFacesById(authUser, id, dto);
}
}

View File

@@ -5,6 +5,7 @@ export * from './app.controller';
export * from './asset.controller';
export * from './audit.controller';
export * from './auth.controller';
export * from './face.controller';
export * from './job.controller';
export * from './library.controller';
export * from './oauth.controller';

View File

@@ -1,4 +1,5 @@
import {
AssetFaceUpdateDto,
AssetResponseDto,
AuthUserDto,
BulkIdResponseDto,
@@ -34,6 +35,20 @@ export class PersonController {
return this.service.getAll(authUser, withHidden);
}
@Post()
createPerson(@AuthUser() authUser: AuthUserDto): Promise<PersonResponseDto> {
return this.service.createPerson(authUser);
}
@Put(':id/reassign')
reassignFaces(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AssetFaceUpdateDto,
): Promise<PersonResponseDto[]> {
return this.service.reassignFaces(authUser, id, dto);
}
@Put()
updatePeople(@AuthUser() authUser: AuthUserDto, @Body() dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
return this.service.updatePeople(authUser, dto);

View File

@@ -8,7 +8,6 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { AssetFaceEntity } from './asset-face.entity';
import { AssetEntity } from './asset.entity';
import { UserEntity } from './user.entity';
@Entity('person')
@@ -40,8 +39,8 @@ export class PersonEntity {
@Column({ nullable: true })
faceAssetId!: string | null;
@ManyToOne(() => AssetEntity, { onDelete: 'SET NULL', nullable: true })
faceAsset!: AssetEntity | null;
@ManyToOne(() => AssetFaceEntity, { onDelete: 'SET NULL', nullable: true })
faceAsset!: AssetFaceEntity | null;
@OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.person)
faces!: AssetFaceEntity[];

View File

@@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class EditFaceAssetForeignKey1699727044012 implements MigrationInterface {
name = 'EditFaceAssetForeignKey1699727044012'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" DROP CONSTRAINT "FK_2bbabe31656b6778c6b87b61023"`);
await queryRunner.query(`UPDATE person SET "faceAssetId" = asset_faces."id" FROM asset_faces WHERE person."faceAssetId" = asset_faces."assetId" AND person."id" = asset_faces."personId"`)
await queryRunner.query(`ALTER TABLE "person" ADD CONSTRAINT "FK_2bbabe31656b6778c6b87b61023" FOREIGN KEY ("faceAssetId") REFERENCES "asset_faces"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" DROP CONSTRAINT "FK_2bbabe31656b6778c6b87b61023"`);
await queryRunner.query(`UPDATE person SET "faceAssetId" = assets."id" FROM assets, asset_faces WHERE person."faceAssetId" = asset_faces."id" AND asset_faces."assetId" = assets."id"`);
await queryRunner.query(`ALTER TABLE "person" ADD CONSTRAINT "FK_2bbabe31656b6778c6b87b61023" FOREIGN KEY ("faceAssetId") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
}
}

View File

@@ -5,6 +5,7 @@ import {
ActivityEntity,
AlbumEntity,
AssetEntity,
AssetFaceEntity,
LibraryEntity,
PartnerEntity,
PersonEntity,
@@ -20,6 +21,7 @@ export class AccessRepository implements IAccessRepository {
@InjectRepository(LibraryEntity) private libraryRepository: Repository<LibraryEntity>,
@InjectRepository(PartnerEntity) private partnerRepository: Repository<PartnerEntity>,
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(SharedLinkEntity) private sharedLinkRepository: Repository<SharedLinkEntity>,
@InjectRepository(UserTokenEntity) private tokenRepository: Repository<UserTokenEntity>,
) {}
@@ -318,6 +320,22 @@ export class AccessRepository implements IAccessRepository {
})
.then((persons) => new Set(persons.map((person) => person.id)));
},
hasFaceOwnerAccess: async (userId: string, assetFaceIds: Set<string>): Promise<Set<string>> => {
if (assetFaceIds.size === 0) {
return new Set();
}
return this.assetFaceRepository
.find({
select: { id: true },
where: {
id: In([...assetFaceIds]),
asset: {
ownerId: userId,
},
},
})
.then((faces) => new Set(faces.map((face) => face.id)));
},
};
partner = {

View File

@@ -107,6 +107,48 @@ export class PersonRepository implements IPersonRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
getFaces(assetId: string): Promise<AssetFaceEntity[]> {
return this.assetFaceRepository.find({
where: { assetId },
relations: {
person: true,
},
});
}
@GenerateSql({ params: [DummyValue.UUID] })
getFaceById(id: string): Promise<AssetFaceEntity> {
return this.assetFaceRepository.findOneOrFail({
where: { id },
relations: {
person: true,
},
});
}
@GenerateSql({ params: [DummyValue.UUID] })
getFaceByIdWithAssets(id: string): Promise<AssetFaceEntity | null> {
return this.assetFaceRepository.findOne({
where: { id },
relations: {
person: true,
asset: true,
},
});
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
const result = await this.assetFaceRepository
.createQueryBuilder()
.update()
.set({ personId: newPersonId })
.where({ id: assetFaceId })
.execute();
return result.affected ?? 0;
}
getById(personId: string): Promise<PersonEntity | null> {
return this.personRepository.findOne({ where: { id: personId } });
}

View File

@@ -133,24 +133,145 @@ GROUP BY
HAVING
COUNT("face"."assetId") = 0
-- PersonRepository.getById
-- PersonRepository.getFaces
SELECT
"PersonEntity"."id" AS "PersonEntity_id",
"PersonEntity"."createdAt" AS "PersonEntity_createdAt",
"PersonEntity"."updatedAt" AS "PersonEntity_updatedAt",
"PersonEntity"."ownerId" AS "PersonEntity_ownerId",
"PersonEntity"."name" AS "PersonEntity_name",
"PersonEntity"."birthDate" AS "PersonEntity_birthDate",
"PersonEntity"."thumbnailPath" AS "PersonEntity_thumbnailPath",
"PersonEntity"."faceAssetId" AS "PersonEntity_faceAssetId",
"PersonEntity"."isHidden" AS "PersonEntity_isHidden"
"AssetFaceEntity"."id" AS "AssetFaceEntity_id",
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
"AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding",
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
"AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId",
"AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name",
"AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate",
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden"
FROM
"person" "PersonEntity"
"asset_faces" "AssetFaceEntity"
LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
WHERE
("PersonEntity"."id" = $1)
("AssetFaceEntity"."assetId" = $1)
-- PersonRepository.getFaceById
SELECT DISTINCT
"distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id"
FROM
(
SELECT
"AssetFaceEntity"."id" AS "AssetFaceEntity_id",
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
"AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding",
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
"AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId",
"AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name",
"AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate",
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden"
FROM
"asset_faces" "AssetFaceEntity"
LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
WHERE
("AssetFaceEntity"."id" = $1)
) "distinctAlias"
ORDER BY
"AssetFaceEntity_id" ASC
LIMIT
1
-- PersonRepository.getFaceByIdWithAssets
SELECT DISTINCT
"distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id"
FROM
(
SELECT
"AssetFaceEntity"."id" AS "AssetFaceEntity_id",
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
"AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding",
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
"AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId",
"AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name",
"AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate",
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden",
"AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id",
"AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId",
"AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId",
"AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId",
"AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId",
"AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type",
"AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath",
"AssetFaceEntity__AssetFaceEntity_asset"."resizePath" AS "AssetFaceEntity__AssetFaceEntity_asset_resizePath",
"AssetFaceEntity__AssetFaceEntity_asset"."webpPath" AS "AssetFaceEntity__AssetFaceEntity_asset_webpPath",
"AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash",
"AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath",
"AssetFaceEntity__AssetFaceEntity_asset"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_asset_createdAt",
"AssetFaceEntity__AssetFaceEntity_asset"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_updatedAt",
"AssetFaceEntity__AssetFaceEntity_asset"."deletedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_deletedAt",
"AssetFaceEntity__AssetFaceEntity_asset"."fileCreatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileCreatedAt",
"AssetFaceEntity__AssetFaceEntity_asset"."localDateTime" AS "AssetFaceEntity__AssetFaceEntity_asset_localDateTime",
"AssetFaceEntity__AssetFaceEntity_asset"."fileModifiedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileModifiedAt",
"AssetFaceEntity__AssetFaceEntity_asset"."isFavorite" AS "AssetFaceEntity__AssetFaceEntity_asset_isFavorite",
"AssetFaceEntity__AssetFaceEntity_asset"."isArchived" AS "AssetFaceEntity__AssetFaceEntity_asset_isArchived",
"AssetFaceEntity__AssetFaceEntity_asset"."isExternal" AS "AssetFaceEntity__AssetFaceEntity_asset_isExternal",
"AssetFaceEntity__AssetFaceEntity_asset"."isReadOnly" AS "AssetFaceEntity__AssetFaceEntity_asset_isReadOnly",
"AssetFaceEntity__AssetFaceEntity_asset"."isOffline" AS "AssetFaceEntity__AssetFaceEntity_asset_isOffline",
"AssetFaceEntity__AssetFaceEntity_asset"."checksum" AS "AssetFaceEntity__AssetFaceEntity_asset_checksum",
"AssetFaceEntity__AssetFaceEntity_asset"."duration" AS "AssetFaceEntity__AssetFaceEntity_asset_duration",
"AssetFaceEntity__AssetFaceEntity_asset"."isVisible" AS "AssetFaceEntity__AssetFaceEntity_asset_isVisible",
"AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId",
"AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName",
"AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath",
"AssetFaceEntity__AssetFaceEntity_asset"."stackParentId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackParentId"
FROM
"asset_faces" "AssetFaceEntity"
LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
LEFT JOIN "assets" "AssetFaceEntity__AssetFaceEntity_asset" ON "AssetFaceEntity__AssetFaceEntity_asset"."id" = "AssetFaceEntity"."assetId"
AND (
"AssetFaceEntity__AssetFaceEntity_asset"."deletedAt" IS NULL
)
WHERE
("AssetFaceEntity"."id" = $1)
) "distinctAlias"
ORDER BY
"AssetFaceEntity_id" ASC
LIMIT
1
-- PersonRepository.reassignFace
UPDATE "asset_faces"
SET
"personId" = $1
WHERE
"id" = $2
-- PersonRepository.getByName
SELECT
"person"."id" AS "person_id",