mirror of
https://github.com/immich-app/immich.git
synced 2025-12-20 09:15:35 +03:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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']));
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
@@ -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>>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
28
server/src/immich/controllers/face.controller.ts
Normal file
28
server/src/immich/controllers/face.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user