immich/server/src/infra/repositories/person.repository.ts
martin 7702560b12
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>
2023-12-05 09:43:15 -06:00

237 lines
7.5 KiB
TypeScript

import {
AssetFaceId,
IPersonRepository,
PersonNameSearchOptions,
PersonSearchOptions,
PersonStatistics,
UpdateFacesData,
} from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities';
import { DummyValue, GenerateSql } from '../infra.util';
export class PersonRepository implements IPersonRepository {
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
) {}
/**
* Before reassigning faces, delete potential key violations
*/
async prepareReassignFaces({ oldPersonId, newPersonId }: UpdateFacesData): Promise<string[]> {
const results = await this.assetFaceRepository
.createQueryBuilder('face')
.select('face."assetId"')
.where(`face."personId" IN (:...ids)`, { ids: [oldPersonId, newPersonId] })
.groupBy('face."assetId"')
.having('COUNT(face."personId") > 1')
.getRawMany();
const assetIds = results.map(({ assetId }) => assetId);
await this.assetFaceRepository.delete({ personId: oldPersonId, assetId: In(assetIds) });
return assetIds;
}
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
async reassignFaces({ oldPersonId, newPersonId }: UpdateFacesData): Promise<number> {
const result = await this.assetFaceRepository
.createQueryBuilder()
.update()
.set({ personId: newPersonId })
.where({ personId: oldPersonId })
.execute();
return result.affected ?? 0;
}
delete(entity: PersonEntity): Promise<PersonEntity | null> {
return this.personRepository.remove(entity);
}
async deleteAll(): Promise<number> {
const people = await this.personRepository.find();
await this.personRepository.remove(people);
return people.length;
}
@GenerateSql()
getAllFaces(): Promise<AssetFaceEntity[]> {
return this.assetFaceRepository.find({ relations: { asset: true }, withDeleted: true });
}
@GenerateSql()
getAll(): Promise<PersonEntity[]> {
return this.personRepository.find();
}
@GenerateSql()
getAllWithoutThumbnail(): Promise<PersonEntity[]> {
return this.personRepository.findBy({ thumbnailPath: '' });
}
@GenerateSql({ params: [DummyValue.UUID] })
getAllForUser(userId: string, options?: PersonSearchOptions): Promise<PersonEntity[]> {
const queryBuilder = this.personRepository
.createQueryBuilder('person')
.leftJoin('person.faces', 'face')
.where('person.ownerId = :userId', { userId })
.innerJoin('face.asset', 'asset')
.orderBy('person.isHidden', 'ASC')
.addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC')
.addOrderBy('COUNT(face.assetId)', 'DESC')
.addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST')
.having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 })
.groupBy('person.id')
.limit(500);
if (!options?.withHidden) {
queryBuilder.andWhere('person.isHidden = false');
}
return queryBuilder.getMany();
}
@GenerateSql()
getAllWithoutFaces(): Promise<PersonEntity[]> {
return this.personRepository
.createQueryBuilder('person')
.leftJoin('person.faces', 'face')
.having('COUNT(face.assetId) = 0')
.groupBy('person.id')
.withDeleted()
.getMany();
}
@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 } });
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise<PersonEntity[]> {
const queryBuilder = this.personRepository
.createQueryBuilder('person')
.leftJoin('person.faces', 'face')
.where(
'person.ownerId = :userId AND (LOWER(person.name) LIKE :nameStart OR LOWER(person.name) LIKE :nameAnywhere)',
{ userId, nameStart: `${personName.toLowerCase()}%`, nameAnywhere: `% ${personName.toLowerCase()}%` },
)
.groupBy('person.id')
.orderBy('COUNT(face.assetId)', 'DESC')
.limit(20);
if (!withHidden) {
queryBuilder.andWhere('person.isHidden = false');
}
return queryBuilder.getMany();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getStatistics(personId: string): Promise<PersonStatistics> {
return {
assets: await this.assetFaceRepository
.createQueryBuilder('face')
.leftJoin('face.asset', 'asset')
.where('face.personId = :personId', { personId })
.andWhere('asset.isArchived = false')
.andWhere('asset.deletedAt IS NULL')
.andWhere('asset.livePhotoVideoId IS NULL')
.distinct(true)
.getCount(),
};
}
@GenerateSql({ params: [DummyValue.UUID] })
getAssets(personId: string): Promise<AssetEntity[]> {
return this.assetRepository.find({
where: {
faces: {
personId,
},
isVisible: true,
isArchived: false,
},
relations: {
faces: {
person: true,
},
exifInfo: true,
},
order: {
fileCreatedAt: 'desc',
},
// TODO: remove after either (1) pagination or (2) time bucket is implemented for this query
take: 1000,
});
}
create(entity: Partial<PersonEntity>): Promise<PersonEntity> {
return this.personRepository.save(entity);
}
createFace(entity: Partial<AssetFaceEntity>): Promise<AssetFaceEntity> {
return this.assetFaceRepository.save(entity);
}
async update(entity: Partial<PersonEntity>): Promise<PersonEntity> {
const { id } = await this.personRepository.save(entity);
return this.personRepository.findOneByOrFail({ id });
}
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
async getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> {
return this.assetFaceRepository.find({ where: ids, relations: { asset: true }, withDeleted: true });
}
@GenerateSql({ params: [DummyValue.UUID] })
async getRandomFace(personId: string): Promise<AssetFaceEntity | null> {
return this.assetFaceRepository.findOneBy({ personId });
}
}