import { Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import _ from 'lodash'; import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { FaceSearchEntity } from 'src/entities/face-search.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { PaginationMode, SourceType } from 'src/enum'; import { AssetFaceId, DeleteFacesOptions, IPersonRepository, PeopleStatistics, PersonNameResponse, PersonNameSearchOptions, PersonSearchOptions, PersonStatistics, UnassignFacesOptions, UpdateFacesData, } from 'src/interfaces/person.interface'; import { Paginated, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; import { DataSource, FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm'; @Injectable() export class PersonRepository implements IPersonRepository { constructor( @InjectDataSource() private dataSource: DataSource, @InjectRepository(AssetEntity) private assetRepository: Repository, @InjectRepository(PersonEntity) private personRepository: Repository, @InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository, @InjectRepository(FaceSearchEntity) private faceSearchRepository: Repository, @InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository, ) {} @GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] }) async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise { const result = await this.assetFaceRepository .createQueryBuilder() .update() .set({ personId: newPersonId }) .where(_.omitBy({ personId: oldPersonId, id: faceIds ? In(faceIds) : undefined }, _.isUndefined)) .execute(); return result.affected ?? 0; } async unassignFaces({ sourceType }: UnassignFacesOptions): Promise { await this.assetFaceRepository .createQueryBuilder() .update() .set({ personId: null }) .where({ sourceType }) .execute(); await this.vacuum({ reindexVectors: false }); } async delete(entities: PersonEntity[]): Promise { await this.personRepository.remove(entities); } async deleteFaces({ sourceType }: DeleteFacesOptions): Promise { await this.assetFaceRepository .createQueryBuilder('asset_faces') .delete() .andWhere('sourceType = :sourceType', { sourceType }) .execute(); await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING }); } getAllFaces( pagination: PaginationOptions, options: FindManyOptions = {}, ): Paginated { return paginate(this.assetFaceRepository, pagination, options); } getAll(pagination: PaginationOptions, options: FindManyOptions = {}): Paginated { return paginate(this.personRepository, pagination, options); } @GenerateSql({ params: [{ take: 10, skip: 10 }, DummyValue.UUID] }) getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions): Paginated { const queryBuilder = this.personRepository .createQueryBuilder('person') .leftJoin('person.faces', 'face') .where('person.ownerId = :userId', { userId }) .innerJoin('face.asset', 'asset') .andWhere('asset.isArchived = false') .orderBy('person.isHidden', 'ASC') .addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC') .addOrderBy('COUNT(face.assetId)', 'DESC') .addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST') .addOrderBy('person.createdAt') .andWhere("person.thumbnailPath != ''") .having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 }) .groupBy('person.id'); if (!options?.withHidden) { queryBuilder.andWhere('person.isHidden = false'); } return paginatedBuilder(queryBuilder, { mode: PaginationMode.LIMIT_OFFSET, ...pagination, }); } @GenerateSql() getAllWithoutFaces(): Promise { 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 { return this.assetFaceRepository.find({ where: { assetId }, relations: { person: true, }, order: { boundingBoxX1: 'ASC', }, }); } @GenerateSql({ params: [DummyValue.UUID] }) getFaceById(id: string): Promise { // TODO return null instead of find or fail return this.assetFaceRepository.findOneOrFail({ where: { id }, relations: { person: true, }, }); } @GenerateSql({ params: [DummyValue.UUID] }) getFaceByIdWithAssets( id: string, relations: FindOptionsRelations, select: FindOptionsSelect, ): Promise { return this.assetFaceRepository.findOne( _.omitBy( { where: { id }, relations: { ...relations, person: true, asset: true, }, select, }, _.isUndefined, ), ); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) async reassignFace(assetFaceId: string, newPersonId: string): Promise { const result = await this.assetFaceRepository .createQueryBuilder() .update() .set({ personId: newPersonId }) .where({ id: assetFaceId }) .execute(); return result.affected ?? 0; } getById(personId: string): Promise { return this.personRepository.findOne({ where: { id: personId } }); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] }) getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise { const queryBuilder = this.personRepository .createQueryBuilder('person') .where( 'person.ownerId = :userId AND (LOWER(person.name) LIKE :nameStart OR LOWER(person.name) LIKE :nameAnywhere)', { userId, nameStart: `${personName.toLowerCase()}%`, nameAnywhere: `% ${personName.toLowerCase()}%` }, ) .limit(1000); if (!withHidden) { queryBuilder.andWhere('person.isHidden = false'); } return queryBuilder.getMany(); } @GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] }) getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise { const queryBuilder = this.personRepository .createQueryBuilder('person') .select(['person.id', 'person.name']) .distinctOn(['lower(person.name)']) .where(`person.ownerId = :userId AND person.name != ''`, { userId }); if (!withHidden) { queryBuilder.andWhere('person.isHidden = false'); } return queryBuilder.getMany(); } @GenerateSql({ params: [DummyValue.UUID] }) async getStatistics(personId: string): Promise { const items = 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') .select('COUNT(DISTINCT(asset.id))', 'count') .getRawOne(); return { assets: items.count ?? 0, }; } @GenerateSql({ params: [DummyValue.UUID] }) async getNumberOfPeople(userId: string): Promise { const items = await this.personRepository .createQueryBuilder('person') .leftJoin('person.faces', 'face') .where('person.ownerId = :userId', { userId }) .innerJoin('face.asset', 'asset') .andWhere('asset.isArchived = false') .andWhere("person.thumbnailPath != ''") .select('COUNT(DISTINCT(person.id))', 'total') .addSelect('COUNT(DISTINCT(person.id)) FILTER (WHERE person.isHidden = true)', 'hidden') .having('COUNT(face.assetId) != 0') .getRawOne(); if (items == undefined) { return { total: 0, hidden: 0 }; } const result: PeopleStatistics = { total: items.total ?? 0, hidden: items.hidden ?? 0, }; return result; } create(person: Partial): Promise { return this.save(person); } async createAll(people: Partial[]): Promise { const results = await this.personRepository.save(people); return results.map((person) => person.id); } async refreshFaces( facesToAdd: Partial[], faceIdsToRemove: string[], embeddingsToAdd?: FaceSearchEntity[], ): Promise { const query = this.faceSearchRepository.createQueryBuilder().select('1').fromDummy(); if (facesToAdd.length > 0) { const insertCte = this.assetFaceRepository.createQueryBuilder().insert().values(facesToAdd); query.addCommonTableExpression(insertCte, 'added'); } if (faceIdsToRemove.length > 0) { const deleteCte = this.assetFaceRepository .createQueryBuilder() .delete() .where('id = any(:faceIdsToRemove)', { faceIdsToRemove }); query.addCommonTableExpression(deleteCte, 'deleted'); } if (embeddingsToAdd?.length) { const embeddingCte = this.faceSearchRepository.createQueryBuilder().insert().values(embeddingsToAdd).orIgnore(); query.addCommonTableExpression(embeddingCte, 'embeddings'); query.getQuery(); // typeorm mixes up parameters without this } await query.execute(); } async update(person: Partial): Promise { return this.save(person); } async updateAll(people: Partial[]): Promise { await this.personRepository.save(people); } @GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] }) @ChunkedArray() async getFacesByIds(ids: AssetFaceId[]): Promise { return this.assetFaceRepository.find({ where: ids, relations: { asset: true }, withDeleted: true }); } @GenerateSql({ params: [DummyValue.UUID] }) async getRandomFace(personId: string): Promise { return this.assetFaceRepository.findOneBy({ personId }); } @GenerateSql() async getLatestFaceDate(): Promise { const result: { latestDate?: string } | undefined = await this.jobStatusRepository .createQueryBuilder('jobStatus') .select('MAX(jobStatus.facesRecognizedAt)::text', 'latestDate') .getRawOne(); return result?.latestDate; } private async save(person: Partial): Promise { const { id } = await this.personRepository.save(person); return this.personRepository.findOneByOrFail({ id }); } private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise { await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search, person'); await this.assetFaceRepository.query('REINDEX TABLE asset_faces'); await this.assetFaceRepository.query('REINDEX TABLE person'); if (reindexVectors) { await this.assetFaceRepository.query('REINDEX TABLE face_search'); } } }