From 62952aeffc29cdc307936c402df6378c0dceb5c8 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler Date: Sat, 11 Jan 2025 13:57:54 +0100 Subject: [PATCH] refactor: migrate person repository to kysely --- e2e/src/api/specs/person.e2e-spec.ts | 4 +- server/src/interfaces/person.interface.ts | 18 +- server/src/queries/person.repository.sql | 507 ++++++++----------- server/src/repositories/person.repository.ts | 496 +++++++++++------- server/src/services/audit.service.ts | 23 +- server/src/services/media.service.spec.ts | 58 +-- server/src/services/media.service.ts | 38 +- server/src/services/metadata.service.ts | 4 +- server/src/services/person.service.spec.ts | 68 +-- server/src/services/person.service.ts | 25 +- server/src/utils/database.ts | 2 +- server/test/utils.ts | 7 + 12 files changed, 598 insertions(+), 652 deletions(-) diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts index d6ccf8265f900..bb838bbae395e 100644 --- a/e2e/src/api/specs/person.e2e-spec.ts +++ b/e2e/src/api/specs/person.e2e-spec.ts @@ -200,7 +200,7 @@ describe('/people', () => { expect(body).toMatchObject({ id: expect.any(String), name: 'New Person', - birthDate: '1990-01-01', + birthDate: '1990-01-01T00:00:00.000Z', }); }); }); @@ -244,7 +244,7 @@ describe('/people', () => { .set('Authorization', `Bearer ${admin.accessToken}`) .send({ birthDate: '1990-01-01' }); expect(status).toBe(200); - expect(body).toMatchObject({ birthDate: '1990-01-01' }); + expect(body).toMatchObject({ birthDate: '1990-01-01T00:00:00.000Z' }); }); it('should clear a date of birth', async () => { diff --git a/server/src/interfaces/person.interface.ts b/server/src/interfaces/person.interface.ts index dc89f5c1b0648..a8bc85d0c71b1 100644 --- a/server/src/interfaces/person.interface.ts +++ b/server/src/interfaces/person.interface.ts @@ -3,7 +3,7 @@ import { FaceSearchEntity } from 'src/entities/face-search.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { SourceType } from 'src/enum'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; -import { FindManyOptions, FindOptionsRelations, FindOptionsSelect } from 'typeorm'; +import { FindOptionsRelations } from 'typeorm'; export const IPersonRepository = 'IPersonRepository'; @@ -48,29 +48,31 @@ export interface DeleteFacesOptions { export type UnassignFacesOptions = DeleteFacesOptions; +export type SelectFaceOptions = Partial<{ [K in keyof AssetFaceEntity]: boolean }>; + export interface IPersonRepository { - getAll(pagination: PaginationOptions, options?: FindManyOptions): Paginated; + getAll(options?: Partial): AsyncIterableIterator; getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated; getAllWithoutFaces(): Promise; getById(personId: string): Promise; getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise; getDistinctNames(userId: string, options: PersonNameSearchOptions): Promise; - create(person: Partial): Promise; - createAll(people: Partial[]): Promise; + create(person: Partial & { ownerId: string }): Promise; + createAll(people: (Partial & { ownerId: string })[]): Promise; delete(entities: PersonEntity[]): Promise; deleteFaces(options: DeleteFacesOptions): Promise; refreshFaces( - facesToAdd: Partial[], + facesToAdd: (Partial & { assetId: string })[], faceIdsToRemove: string[], embeddingsToAdd?: FaceSearchEntity[], ): Promise; - getAllFaces(pagination: PaginationOptions, options?: FindManyOptions): Paginated; + getAllFaces(options?: Partial): AsyncIterableIterator; getFaceById(id: string): Promise; getFaceByIdWithAssets( id: string, relations?: FindOptionsRelations, - select?: FindOptionsSelect, + select?: SelectFaceOptions, ): Promise; getFaces(assetId: string): Promise; getFacesByIds(ids: AssetFaceId[]): Promise; @@ -80,7 +82,7 @@ export interface IPersonRepository { getNumberOfPeople(userId: string): Promise; reassignFaces(data: UpdateFacesData): Promise; unassignFaces(options: UnassignFacesOptions): Promise; - update(person: Partial): Promise; + update(person: Partial & { id: string }): Promise; updateAll(people: Partial[]): Promise; getLatestFaceDate(): Promise; } diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index a7e683fca1e72..02dfa460a6843 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -1,342 +1,237 @@ -- NOTE: This file is auto generated by ./sql-generator -- PersonRepository.reassignFaces -UPDATE "asset_faces" -SET +update "asset_faces" +set "personId" = $1 -WHERE - "personId" = $2 +where + "asset_faces"."personId" = $2 --- PersonRepository.getAllForUser -SELECT - "person"."id" AS "person_id", - "person"."createdAt" AS "person_createdAt", - "person"."updatedAt" AS "person_updatedAt", - "person"."ownerId" AS "person_ownerId", - "person"."name" AS "person_name", - "person"."birthDate" AS "person_birthDate", - "person"."thumbnailPath" AS "person_thumbnailPath", - "person"."faceAssetId" AS "person_faceAssetId", - "person"."isHidden" AS "person_isHidden" -FROM - "person" "person" - INNER JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" - INNER JOIN "assets" "asset" ON "asset"."id" = "face"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "person"."ownerId" = $1 - AND "asset"."isArchived" = false - AND "person"."isHidden" = false -GROUP BY - "person"."id" -HAVING - "person"."name" != '' - OR COUNT("face"."assetId") >= $2 -ORDER BY - "person"."isHidden" ASC, - NULLIF("person"."name", '') IS NULL ASC, - COUNT("face"."assetId") DESC, - NULLIF("person"."name", '') ASC NULLS LAST, - "person"."createdAt" ASC -LIMIT - 11 -OFFSET - 10 +-- PersonRepository.unassignFaces +update "asset_faces" +set + "personId" = $1 +where + "asset_faces"."sourceType" = $2 +VACUUM +ANALYZE asset_faces, +face_search, +person +REINDEX TABLE asset_faces +REINDEX TABLE person + +-- PersonRepository.delete +delete from "person" +where + "person"."id" in ($1) + +-- PersonRepository.deleteFaces +delete from "asset_faces" +where + "asset_faces"."sourceType" = $1 +VACUUM +ANALYZE asset_faces, +face_search, +person +REINDEX TABLE asset_faces +REINDEX TABLE person -- PersonRepository.getAllWithoutFaces -SELECT - "person"."id" AS "person_id", - "person"."createdAt" AS "person_createdAt", - "person"."updatedAt" AS "person_updatedAt", - "person"."ownerId" AS "person_ownerId", - "person"."name" AS "person_name", - "person"."birthDate" AS "person_birthDate", - "person"."thumbnailPath" AS "person_thumbnailPath", - "person"."faceAssetId" AS "person_faceAssetId", - "person"."isHidden" AS "person_isHidden" -FROM - "person" "person" - LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" -GROUP BY +select + "person".* +from + "person" + left join "asset_faces" on "asset_faces"."personId" = "person"."id" +group by "person"."id" -HAVING - COUNT("face"."assetId") = 0 +having + count("asset_faces"."assetId") = $1 -- PersonRepository.getFaces -SELECT - "AssetFaceEntity"."id" AS "AssetFaceEntity_id", - "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", - "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", - "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"."sourceType" AS "AssetFaceEntity_sourceType", - "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"."assetId" = $1)) -ORDER BY - "AssetFaceEntity"."boundingBoxX1" ASC +select + "asset_faces".*, + ( + select + to_json(obj) + from + ( + select + "person".* + from + "person" + where + "person"."id" = "asset_faces"."personId" + ) as obj + ) as "person" +from + "asset_faces" +where + "asset_faces"."assetId" = $1 +order by + "asset_faces"."boundingBoxX1" asc -- PersonRepository.getFaceById -SELECT DISTINCT - "distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id" -FROM +select + "asset_faces".*, ( - SELECT - "AssetFaceEntity"."id" AS "AssetFaceEntity_id", - "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", - "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", - "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"."sourceType" AS "AssetFaceEntity_sourceType", - "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 + select + to_json(obj) + from + ( + select + "person".* + from + "person" + where + "person"."id" = "asset_faces"."personId" + ) as obj + ) as "person" +from + "asset_faces" +where + "asset_faces"."id" = $1 -- PersonRepository.getFaceByIdWithAssets -SELECT DISTINCT - "distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id" -FROM +select + "asset_faces".*, + ( + select + to_json(obj) + from + ( + select + "person".* + from + "person" + where + "person"."id" = "asset_faces"."personId" + ) as obj + ) as "person", ( - SELECT - "AssetFaceEntity"."id" AS "AssetFaceEntity_id", - "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", - "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", - "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"."sourceType" AS "AssetFaceEntity_sourceType", - "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"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status", - "AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath", - "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"."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"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId", - "AssetFaceEntity__AssetFaceEntity_asset"."duplicateId" AS "AssetFaceEntity__AssetFaceEntity_asset_duplicateId" - 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 + select + to_json(obj) + from + ( + select + "assets".* + from + "assets" + where + "assets"."id" = "asset_faces"."assetId" + ) as obj + ) as "asset" +from + "asset_faces" +where + "asset_faces"."id" = $1 -- PersonRepository.reassignFace -UPDATE "asset_faces" -SET +update "asset_faces" +set "personId" = $1 -WHERE - "id" = $2 +where + "asset_faces"."id" = $2 -- PersonRepository.getByName -SELECT - "person"."id" AS "person_id", - "person"."createdAt" AS "person_createdAt", - "person"."updatedAt" AS "person_updatedAt", - "person"."ownerId" AS "person_ownerId", - "person"."name" AS "person_name", - "person"."birthDate" AS "person_birthDate", - "person"."thumbnailPath" AS "person_thumbnailPath", - "person"."faceAssetId" AS "person_faceAssetId", - "person"."isHidden" AS "person_isHidden" -FROM - "person" "person" -WHERE - "person"."ownerId" = $1 - AND ( - LOWER("person"."name") LIKE $2 - OR LOWER("person"."name") LIKE $3 +select + "person".* +from + "person" +where + ( + "person"."ownerId" = $1 + and ( + lower("person"."name") like $2 + or lower("person"."name") like $3 + ) ) -LIMIT - 1000 +limit + $4 -- PersonRepository.getDistinctNames -SELECT DISTINCT - ON (lower("person"."name")) "person"."id" AS "person_id", - "person"."name" AS "person_name" -FROM - "person" "person" -WHERE - "person"."ownerId" = $1 - AND "person"."name" != '' +select distinct + on (lower("person"."name")) "person"."id", + "person"."name" +from + "person" +where + ( + "person"."ownerId" = $1 + and "person"."name" != $2 + ) -- PersonRepository.getStatistics -SELECT - COUNT(DISTINCT ("asset"."id")) AS "count" -FROM - "asset_faces" "face" - LEFT JOIN "assets" "asset" ON "asset"."id" = "face"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "face"."personId" = $1 - AND "asset"."isArchived" = false - AND "asset"."deletedAt" IS NULL - AND "asset"."livePhotoVideoId" IS NULL +select + count(distinct ("assets"."id")) as "count" +from + "asset_faces" + left join "assets" on "assets"."id" = "asset_faces"."assetId" + and "asset_faces"."personId" = $1 + and "assets"."isArchived" = $2 + and "assets"."deletedAt" is null + and "assets"."livePhotoVideoId" is null -- PersonRepository.getNumberOfPeople -SELECT - COUNT(DISTINCT ("person"."id")) AS "total", - COUNT(DISTINCT ("person"."id")) FILTER ( - WHERE - "person"."isHidden" = true - ) AS "hidden" -FROM - "person" "person" - INNER JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" - INNER JOIN "assets" "asset" ON "asset"."id" = "face"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "person"."ownerId" = $1 - AND "asset"."isArchived" = false +select + count(distinct ("person"."id")) as "total", + count(distinct ("person"."id")) filter ( + where + "person"."isHidden" = $1 + ) as "hidden" +from + "person" + inner join "asset_faces" on "asset_faces"."personId" = "person"."id" + inner join "assets" on "assets"."id" = "asset_faces"."assetId" + and "assets"."deletedAt" is null + and "assets"."isArchived" = $2 +where + "person"."ownerId" = $3 -- PersonRepository.getFacesByIds -SELECT - "AssetFaceEntity"."id" AS "AssetFaceEntity_id", - "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", - "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", - "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"."sourceType" AS "AssetFaceEntity_sourceType", - "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"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status", - "AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath", - "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"."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"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId", - "AssetFaceEntity__AssetFaceEntity_asset"."duplicateId" AS "AssetFaceEntity__AssetFaceEntity_asset_duplicateId" -FROM - "asset_faces" "AssetFaceEntity" - LEFT JOIN "assets" "AssetFaceEntity__AssetFaceEntity_asset" ON "AssetFaceEntity__AssetFaceEntity_asset"."id" = "AssetFaceEntity"."assetId" -WHERE +select + "asset_faces".*, ( - ( + select + to_json(obj) + from ( - ("AssetFaceEntity"."assetId" = $1) - AND ("AssetFaceEntity"."personId" = $2) - ) - ) - ) + select + "assets".* + from + "assets" + where + "assets"."id" = "asset_faces"."assetId" + ) as obj + ) as "asset", + ( + select + to_json(obj) + from + ( + select + "person".* + from + "person" + where + "person"."id" = "asset_faces"."personId" + ) as obj + ) as "person" +from + "asset_faces" +where + "asset_faces"."assetId" in ($1) + and "asset_faces"."personId" in ($2) -- PersonRepository.getRandomFace -SELECT - "AssetFaceEntity"."id" AS "AssetFaceEntity_id", - "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", - "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", - "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"."sourceType" AS "AssetFaceEntity_sourceType" -FROM - "asset_faces" "AssetFaceEntity" -WHERE - (("AssetFaceEntity"."personId" = $1)) -LIMIT - 1 +select + "asset_faces".* +from + "asset_faces" +where + "asset_faces"."personId" = $1 -- PersonRepository.getLatestFaceDate -SELECT - MAX("jobStatus"."facesRecognizedAt")::text AS "latestDate" -FROM - "asset_job_status" "jobStatus" +select + max("asset_job_status"."facesRecognizedAt")::text as "latestDate" +from + "asset_job_status" diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 4229286706619..f690db1fd6da6 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -1,13 +1,22 @@ import { Injectable } from '@nestjs/common'; -import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { + ExpressionBuilder, + Kysely, + OnConflictDatabase, + OnConflictTables, + SelectExpression, + sql, + StringReference, +} from 'kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; import _ from 'lodash'; +import { InjectKysely } from 'nestjs-kysely'; +import { DB } from 'src/db'; 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 { SourceType } from 'src/enum'; import { AssetFaceId, DeleteFacesOptions, @@ -17,332 +26,425 @@ import { PersonNameSearchOptions, PersonSearchOptions, PersonStatistics, + SelectFaceOptions, 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'; +import { asVector } from 'src/utils/database'; +import { Paginated, PaginationOptions } from 'src/utils/pagination'; +import { FindOptionsRelations } from 'typeorm'; + +const withPerson = (eb: ExpressionBuilder) => { + return jsonObjectFrom( + eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_faces.personId'), + ).as('person'); +}; + +const withAsset = (eb: ExpressionBuilder) => { + return jsonObjectFrom( + eb.selectFrom('assets').selectAll('assets').whereRef('assets.id', '=', 'asset_faces.assetId'), + ).as('asset'); +}; + +const withFaceSearch = (eb: ExpressionBuilder) => { + return jsonObjectFrom( + eb.selectFrom('face_search').selectAll('face_search').whereRef('face_search.faceId', '=', 'asset_faces.id'), + ).as('faceSearch'); +}; @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, - ) {} + constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] }) async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise { - const result = await this.assetFaceRepository - .createQueryBuilder() - .update() + const result = await this.db + .updateTable('asset_faces') .set({ personId: newPersonId }) - .where(_.omitBy({ personId: oldPersonId, id: faceIds ? In(faceIds) : undefined }, _.isUndefined)) - .execute(); + .$if(!!oldPersonId, (qb) => qb.where('asset_faces.personId', '=', oldPersonId!)) + .$if(!!faceIds, (qb) => qb.where('asset_faces.id', 'in', faceIds!)) + .executeTakeFirst(); - return result.affected ?? 0; + return Number(result.numChangedRows) ?? 0; } + @GenerateSql({ params: [{ sourceType: SourceType.EXIF }] }) async unassignFaces({ sourceType }: UnassignFacesOptions): Promise { - await this.assetFaceRepository - .createQueryBuilder() - .update() + await this.db + .updateTable('asset_faces') .set({ personId: null }) - .where({ sourceType }) + .where('asset_faces.sourceType', '=', sourceType) .execute(); await this.vacuum({ reindexVectors: false }); } + @GenerateSql({ params: [[{ id: DummyValue.UUID }]] }) async delete(entities: PersonEntity[]): Promise { - await this.personRepository.remove(entities); + await this.db + .deleteFrom('person') + .where( + 'person.id', + 'in', + entities.map(({ id }) => id), + ) + .execute(); } + @GenerateSql({ params: [{ sourceType: SourceType.EXIF }] }) async deleteFaces({ sourceType }: DeleteFacesOptions): Promise { - await this.assetFaceRepository - .createQueryBuilder('asset_faces') - .delete() - .andWhere('sourceType = :sourceType', { sourceType }) - .execute(); + await this.db.deleteFrom('asset_faces').where('asset_faces.sourceType', '=', sourceType).execute(); await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING }); } - getAllFaces( - pagination: PaginationOptions, - options: FindManyOptions = {}, - ): Paginated { - return paginate(this.assetFaceRepository, pagination, options); + getAllFaces(options: Partial = {}): AsyncIterableIterator { + return this.db + .selectFrom('asset_faces') + .selectAll('asset_faces') + .$if(options.personId === null, (qb) => qb.where('asset_faces.personId', 'is', null)) + .$if(!!options.personId, (qb) => qb.where('asset_faces.personId', '=', options.personId!)) + .$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!)) + .$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!)) + .$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!)) + .stream() as AsyncIterableIterator; } - getAll(pagination: PaginationOptions, options: FindManyOptions = {}): Paginated { - return paginate(this.personRepository, pagination, options); + getAll(options: Partial = {}): AsyncIterableIterator { + return this.db + .selectFrom('person') + .selectAll('person') + .$if(!!options.ownerId, (qb) => qb.where('person.ownerId', '=', options.ownerId!)) + .$if(!!options.thumbnailPath, (qb) => qb.where('person.thumbnailPath', '=', options.thumbnailPath!)) + .$if(options.faceAssetId === null, (qb) => qb.where('person.faceAssetId', 'is', null)) + .$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!)) + .$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!)) + .stream() as AsyncIterableIterator; } - @GenerateSql({ params: [{ take: 10, skip: 10 }, DummyValue.UUID] }) async getAllForUser( pagination: PaginationOptions, userId: string, options?: PersonSearchOptions, ): Paginated { - const queryBuilder = this.personRepository - .createQueryBuilder('person') - .innerJoin('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') - .having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 }) - .groupBy('person.id'); - if (options?.closestFaceAssetId) { - const innerQueryBuilder = this.faceSearchRepository - .createQueryBuilder('face_search') - .select('embedding', 'embedding') - .where('"face_search"."faceId" = "person"."faceAssetId"'); - const faceSelectQueryBuilder = this.faceSearchRepository - .createQueryBuilder('face_search') - .select('embedding', 'embedding') - .where('"face_search"."faceId" = :faceId', { faceId: options.closestFaceAssetId }); - queryBuilder - .orderBy('(' + innerQueryBuilder.getQuery() + ') <=> (' + faceSelectQueryBuilder.getQuery() + ')') - .setParameters(faceSelectQueryBuilder.getParameters()); - } - if (!options?.withHidden) { - queryBuilder.andWhere('person.isHidden = false'); + const items = (await this.db + .selectFrom('person') + .selectAll('person') + .innerJoin('asset_faces', 'asset_faces.personId', 'person.id') + .innerJoin('assets', (join) => + join + .onRef('asset_faces.assetId', '=', 'assets.id') + .on('assets.isArchived', '=', false) + .on('assets.deletedAt', 'is', null), + ) + .where('person.ownerId', '=', userId) + .orderBy('person.isHidden', 'asc') + .orderBy(sql`NULLIF(person.name, '') is null`, 'asc') + .orderBy((eb) => eb.fn.count('asset_faces.assetId'), 'desc') + .orderBy(sql`NULLIF(person.name, '')`, sql`asc nulls last`) + .orderBy('person.createdAt') + .having((eb) => + eb.or([ + eb('person.name', '!=', ''), + eb((innerEb) => innerEb.fn.count('asset_faces.assetId'), '>=', options?.minimumFaceCount || 1), + ]), + ) + .groupBy('person.id') + .$if(!!options?.closestFaceAssetId, (qb) => + qb.orderBy((eb) => + eb( + (eb) => + eb + .selectFrom('face_search') + .select('face_search.embedding') + .whereRef('face_search.faceId', '=', 'person.faceAssetId'), + '<=>', + (eb) => + eb + .selectFrom('face_search') + .select('face_search.embedding') + .where('face_search.faceId', '=', options!.closestFaceAssetId!), + ), + ), + ) + .$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false)) + .offset(pagination.skip ?? 0) + .limit(pagination.take + 1) + .execute()) as PersonEntity[]; + + if (items.length > pagination.take) { + return { items: items.slice(0, -1), hasNextPage: true }; } - return paginatedBuilder(queryBuilder, { - mode: PaginationMode.LIMIT_OFFSET, - ...pagination, - }); + + return { items, hasNextPage: false }; } @GenerateSql() getAllWithoutFaces(): Promise { - return this.personRepository - .createQueryBuilder('person') - .leftJoin('person.faces', 'face') - .having('COUNT(face.assetId) = 0') + return this.db + .selectFrom('person') + .selectAll('person') + .leftJoin('asset_faces', 'asset_faces.personId', 'person.id') + .having((eb) => eb.fn.count('asset_faces.assetId'), '=', 0) .groupBy('person.id') - .withDeleted() - .getMany(); + .execute() as Promise; } @GenerateSql({ params: [DummyValue.UUID] }) getFaces(assetId: string): Promise { - return this.assetFaceRepository.find({ - where: { assetId }, - relations: { - person: true, - }, - order: { - boundingBoxX1: 'ASC', - }, - }); + return this.db + .selectFrom('asset_faces') + .selectAll('asset_faces') + .select(withPerson) + .where('asset_faces.assetId', '=', assetId) + .orderBy('asset_faces.boundingBoxX1', 'asc') + .execute() as Promise; } @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, - }, - }); + return this.db + .selectFrom('asset_faces') + .selectAll('asset_faces') + .select(withPerson) + .where('asset_faces.id', '=', id) + .executeTakeFirstOrThrow() as Promise; } @GenerateSql({ params: [DummyValue.UUID] }) getFaceByIdWithAssets( id: string, - relations: FindOptionsRelations, - select: FindOptionsSelect, + relations?: FindOptionsRelations, + select?: SelectFaceOptions, ): Promise { - return this.assetFaceRepository.findOne( - _.omitBy( - { - where: { id }, - relations: { - ...relations, - person: true, - asset: true, - }, - select, - }, - _.isUndefined, - ), - ); + return (this.db + .selectFrom('asset_faces') + .$if(!!select, (qb) => + qb.select( + Object.keys( + _.omitBy({ ...select!, faceSearch: undefined, asset: undefined }, _.isUndefined), + ) as SelectExpression[], + ), + ) + .$if(!select, (qb) => qb.selectAll('asset_faces')) + .select(withPerson) + .select(withAsset) + .$if(!!relations?.faceSearch, (qb) => qb.select(withFaceSearch)) + .where('asset_faces.id', '=', id) + .executeTakeFirst() ?? null) as Promise; } @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) async reassignFace(assetFaceId: string, newPersonId: string): Promise { - const result = await this.assetFaceRepository - .createQueryBuilder() - .update() + const result = await this.db + .updateTable('asset_faces') .set({ personId: newPersonId }) - .where({ id: assetFaceId }) - .execute(); + .where('asset_faces.id', '=', assetFaceId) + .executeTakeFirst(); - return result.affected ?? 0; + return Number(result.numChangedRows) ?? 0; } getById(personId: string): Promise { - return this.personRepository.findOne({ where: { id: personId } }); + return (this.db // + .selectFrom('person') + .selectAll('person') + .where('person.id', '=', personId) + .executeTakeFirst() ?? null) as Promise; } @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()}%` }, + return this.db + .selectFrom('person') + .selectAll('person') + .where((eb) => + eb.and([ + eb('person.ownerId', '=', userId), + eb.or([ + eb(eb.fn('lower', ['person.name']), 'like', `${personName.toLowerCase()}%`), + eb(eb.fn('lower', ['person.name']), 'like', `% ${personName.toLowerCase()}%`), + ]), + ]), ) - .limit(1000); - - if (!withHidden) { - queryBuilder.andWhere('person.isHidden = false'); - } - return queryBuilder.getMany(); + .limit(1000) + .$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false)) + .execute() as Promise; } @GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] }) getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise { - const queryBuilder = this.personRepository - .createQueryBuilder('person') + return this.db + .selectFrom('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(); + .distinctOn((eb) => eb.fn('lower', ['person.name'])) + .where((eb) => eb.and([eb('person.ownerId', '=', userId), eb('person.name', '!=', '')])) + .$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false)) + .execute(); } @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(); + const result = await this.db + .selectFrom('asset_faces') + .leftJoin('assets', (join) => + join + .onRef('assets.id', '=', 'asset_faces.assetId') + .on('asset_faces.personId', '=', personId) + .on('assets.isArchived', '=', false) + .on('assets.deletedAt', 'is', null) + .on('assets.livePhotoVideoId', 'is', null), + ) + .select((eb) => eb.fn.count(eb.fn('distinct', ['assets.id'])).as('count')) + .executeTakeFirst(); + return { - assets: items.count ?? 0, + assets: result ? Number(result.count) : 0, }; } @GenerateSql({ params: [DummyValue.UUID] }) async getNumberOfPeople(userId: string): Promise { - const items = await this.personRepository - .createQueryBuilder('person') - .innerJoin('person.faces', 'face') - .where('person.ownerId = :userId', { userId }) - .innerJoin('face.asset', 'asset') - .andWhere('asset.isArchived = false') - .select('COUNT(DISTINCT(person.id))', 'total') - .addSelect('COUNT(DISTINCT(person.id)) FILTER (WHERE person.isHidden = true)', 'hidden') - .getRawOne(); + const items = await this.db + .selectFrom('person') + .innerJoin('asset_faces', 'asset_faces.personId', 'person.id') + .where('person.ownerId', '=', userId) + .innerJoin('assets', (join) => + join + .onRef('assets.id', '=', 'asset_faces.assetId') + .on('assets.deletedAt', 'is', null) + .on('assets.isArchived', '=', false), + ) + .select((eb) => eb.fn.count(eb.fn('distinct', ['person.id'])).as('total')) + .select((eb) => + eb.fn + .count(eb.fn('distinct', ['person.id'])) + .filterWhere('person.isHidden', '=', true) + .as('hidden'), + ) + .executeTakeFirst(); if (items == undefined) { return { total: 0, hidden: 0 }; } - const result: PeopleStatistics = { - total: items.total ?? 0, - hidden: items.hidden ?? 0, + return { + total: Number(items.total), + hidden: Number(items.hidden), }; - - return result; } - create(person: Partial): Promise { - return this.save(person); + create(person: Partial & { ownerId: string }): Promise { + return this.db.insertInto('person').values(person).returningAll().executeTakeFirst() as Promise; } - async createAll(people: Partial[]): Promise { - const results = await this.personRepository.save(people); - return results.map((person) => person.id); + async createAll(people: (Partial & { ownerId: string })[]): Promise { + const results = await this.db.insertInto('person').values(people).returningAll().execute(); + return results.map(({ id }) => id); } + @GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: [1, 2, 3] }]] }) async refreshFaces( - facesToAdd: Partial[], + facesToAdd: (Partial & { assetId: string })[], 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'); + const faces = facesToAdd.map((face) => _.omit(face, 'faceSearch', 'asset')); + await this.db.insertInto('asset_faces').values(faces).execute(); } if (faceIdsToRemove.length > 0) { - const deleteCte = this.assetFaceRepository - .createQueryBuilder() - .delete() - .where('id = any(:faceIdsToRemove)', { faceIdsToRemove }); - query.addCommonTableExpression(deleteCte, 'deleted'); + await this.db + .deleteFrom('asset_faces') + .where('asset_faces.id', '=', (eb) => eb.fn.any(eb.val(faceIdsToRemove))) + .execute(); } 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 this.db + .insertInto('face_search') + .values( + embeddingsToAdd.map(({ faceId, embedding }) => ({ + faceId, + embedding: asVector(embedding), + })), + ) + .execute(); } - - await query.execute(); } - async update(person: Partial): Promise { - return this.save(person); + async update(person: Partial & { id: string }): Promise { + return this.db + .updateTable('person') + .set(person) + .where('person.id', '=', person.id) + .returningAll() + .executeTakeFirstOrThrow() as Promise; } - async updateAll(people: Partial[]): Promise { - await this.personRepository.save(people); + async updateAll(people: (Partial & { ownerId: string })[]): Promise { + await this.db + .insertInto('person') + .values(people) + .onConflict((oc) => + oc.column('id').doUpdateSet((eb) => { + const keys = Object.keys(people[0]!) as StringReference< + OnConflictDatabase, + OnConflictTables<'person'> + >[]; + return Object.fromEntries(keys.map((key) => [key, eb.ref(key)])); + }), + ) + .execute(); } @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 }); + const { assetIds, personIds }: { assetIds: string[]; personIds: string[] } = { assetIds: [], personIds: [] }; + + for (const { assetId, personId } of ids) { + assetIds.push(assetId); + personIds.push(personId); + } + + return this.db + .selectFrom('asset_faces') + .selectAll('asset_faces') + .select(withAsset) + .select(withPerson) + .where('asset_faces.assetId', 'in', assetIds) + .where('asset_faces.personId', 'in', personIds) + .execute() as Promise; } @GenerateSql({ params: [DummyValue.UUID] }) async getRandomFace(personId: string): Promise { - return this.assetFaceRepository.findOneBy({ personId }); + return (this.db + .selectFrom('asset_faces') + .selectAll('asset_faces') + .where('asset_faces.personId', '=', personId) + .executeTakeFirst() ?? null) as Promise; } @GenerateSql() async getLatestFaceDate(): Promise { - const result: { latestDate?: string } | undefined = await this.jobStatusRepository - .createQueryBuilder('jobStatus') - .select('MAX(jobStatus.facesRecognizedAt)::text', 'latestDate') - .getRawOne(); - return result?.latestDate; - } + const result = (await this.db + .selectFrom('asset_job_status') + .select((eb) => sql`${eb.fn.max('asset_job_status.facesRecognizedAt')}::text`.as('latestDate')) + .executeTakeFirst()) as { latestDate: string } | undefined; - private async save(person: Partial): Promise { - const { id } = await this.personRepository.save(person); - return this.personRepository.findOneByOrFail({ id }); + return result?.latestDate; } 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'); + await sql`VACUUM ANALYZE asset_faces, face_search, person`.execute(this.db); + await sql`REINDEX TABLE asset_faces`.execute(this.db); + await sql`REINDEX TABLE person`.execute(this.db); if (reindexVectors) { - await this.assetFaceRepository.query('REINDEX TABLE face_search'); + await sql`REINDEX TABLE face_search`.execute(this.db); } } } diff --git a/server/src/services/audit.service.ts b/server/src/services/audit.service.ts index 3fc838e5e90ef..611f8f69d3488 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -201,21 +201,22 @@ export class AuditService extends BaseService { } } - const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.personRepository.getAll(pagination), - ); - for await (const people of personPagination) { - for (const { id, thumbnailPath } of people) { - track(thumbnailPath); - const entity = { entityId: id, entityType: PathEntityType.PERSON }; - if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) { - orphans.push({ ...entity, pathType: PersonPathType.FACE, pathValue: thumbnailPath }); - } + let peopleCount = 0; + for await (const { id, thumbnailPath } of this.personRepository.getAll()) { + track(thumbnailPath); + const entity = { entityId: id, entityType: PathEntityType.PERSON }; + if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) { + orphans.push({ ...entity, pathType: PersonPathType.FACE, pathValue: thumbnailPath }); } - this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${people.length} people`); + if (peopleCount === JOBS_ASSET_PAGINATION_SIZE) { + this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${peopleCount} people`); + peopleCount = 0; + } } + this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${peopleCount} people`); + const extras: string[] = []; for (const file of allFiles) { extras.push(file); diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 36a9045677460..b762e968eaecc 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -25,7 +25,7 @@ import { assetStub } from 'test/fixtures/asset.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { personStub } from 'test/fixtures/person.stub'; -import { newTestService } from 'test/utils'; +import { makeStream, newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(MediaService.name, () => { @@ -55,10 +55,8 @@ describe(MediaService.name, () => { items: [assetStub.image], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [personStub.newThumbnail], - hasNextPage: false, - }); + + personMock.getAll.mockReturnValue(makeStream([personStub.newThumbnail])); personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -72,7 +70,7 @@ describe(MediaService.name, () => { }, ]); - expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, {}); + expect(personMock.getAll).toHaveBeenCalledWith(undefined); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, @@ -86,10 +84,7 @@ describe(MediaService.name, () => { items: [assetStub.trashed], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [], - hasNextPage: false, - }); + personMock.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -111,10 +106,7 @@ describe(MediaService.name, () => { items: [assetStub.archived], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [], - hasNextPage: false, - }); + personMock.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -136,10 +128,7 @@ describe(MediaService.name, () => { items: [assetStub.image], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [personStub.noThumbnail, personStub.noThumbnail], - hasNextPage: false, - }); + personMock.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail])); personMock.getRandomFace.mockResolvedValueOnce(faceStub.face1); await sut.handleQueueGenerateThumbnails({ force: false }); @@ -147,7 +136,7 @@ describe(MediaService.name, () => { expect(assetMock.getAll).not.toHaveBeenCalled(); expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); - expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } }); + expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); expect(personMock.getRandomFace).toHaveBeenCalled(); expect(personMock.update).toHaveBeenCalledTimes(1); expect(jobMock.queueAll).toHaveBeenCalledWith([ @@ -165,11 +154,7 @@ describe(MediaService.name, () => { items: [assetStub.noResizePath], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [], - hasNextPage: false, - }); - + personMock.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(assetMock.getAll).not.toHaveBeenCalled(); @@ -181,7 +166,7 @@ describe(MediaService.name, () => { }, ]); - expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } }); + expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should queue all assets with missing webp path', async () => { @@ -189,11 +174,7 @@ describe(MediaService.name, () => { items: [assetStub.noWebpPath], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [], - hasNextPage: false, - }); - + personMock.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(assetMock.getAll).not.toHaveBeenCalled(); @@ -205,7 +186,7 @@ describe(MediaService.name, () => { }, ]); - expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } }); + expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should queue all assets with missing thumbhash', async () => { @@ -213,11 +194,7 @@ describe(MediaService.name, () => { items: [assetStub.noThumbhash], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [], - hasNextPage: false, - }); - + personMock.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(assetMock.getAll).not.toHaveBeenCalled(); @@ -229,7 +206,7 @@ describe(MediaService.name, () => { }, ]); - expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } }); + expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); }); @@ -237,7 +214,7 @@ describe(MediaService.name, () => { it('should remove empty directories and queue jobs', async () => { assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); - personMock.getAll.mockResolvedValue({ hasNextPage: false, items: [personStub.withName] }); + personMock.getAll.mockReturnValue(makeStream([personStub.withName])); await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.SUCCESS); @@ -730,10 +707,7 @@ describe(MediaService.name, () => { items: [assetStub.video], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [], - hasNextPage: false, - }); + personMock.getAll.mockReturnValue(makeStream()); await sut.handleQueueVideoConversion({ force: true }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 7036bd32e831c..2a5ee39dde0da 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -72,23 +72,20 @@ export class MediaService extends BaseService { } const jobs: JobItem[] = []; - const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.personRepository.getAll(pagination, { where: force ? undefined : { thumbnailPath: '' } }), - ); - for await (const people of personPagination) { - for (const person of people) { - if (!person.faceAssetId) { - const face = await this.personRepository.getRandomFace(person.id); - if (!face) { - continue; - } + const people = this.personRepository.getAll(force ? undefined : { thumbnailPath: '' }); - await this.personRepository.update({ id: person.id, faceAssetId: face.id }); + for await (const person of people) { + if (!person.faceAssetId) { + const face = await this.personRepository.getRandomFace(person.id); + if (!face) { + continue; } - jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } }); + await this.personRepository.update({ id: person.id, faceAssetId: face.id }); } + + jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } }); } await this.jobRepository.queueAll(jobs); @@ -114,16 +111,19 @@ export class MediaService extends BaseService { ); } - const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.personRepository.getAll(pagination), - ); + let jobs: { name: JobName.MIGRATE_PERSON; data: { id: string } }[] = []; - for await (const people of personPagination) { - await this.jobRepository.queueAll( - people.map((person) => ({ name: JobName.MIGRATE_PERSON, data: { id: person.id } })), - ); + for await (const person of this.personRepository.getAll()) { + jobs.push({ name: JobName.MIGRATE_PERSON, data: { id: person.id } }); + + if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) { + await this.jobRepository.queueAll(jobs); + jobs = []; + } } + await this.jobRepository.queueAll(jobs); + return JobStatus.SUCCESS; } diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 15ea9902352f2..d371ff175ff92 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -509,10 +509,10 @@ export class MetadataService extends BaseService { return; } - const facesToAdd: Partial[] = []; + const facesToAdd: (Partial & { assetId: string })[] = []; const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true }); const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id])); - const missing: Partial[] = []; + const missing: (Partial & { ownerId: string })[] = []; const missingWithFaceAsset: Partial[] = []; for (const region of tags.RegionInfo.RegionList) { if (!region.Name) { diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 60cb3708817ea..0e12857c4bf84 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -20,8 +20,7 @@ import { faceStub } from 'test/fixtures/face.stub'; import { personStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { IsNull } from 'typeorm'; +import { makeStream, newTestService } from 'test/utils'; import { Mocked } from 'vitest'; const responseDto: PersonResponseDto = { @@ -495,14 +494,8 @@ describe(PersonService.name, () => { }); it('should delete existing people and faces if forced', async () => { - personMock.getAll.mockResolvedValue({ - items: [faceStub.face1.person, personStub.randomPerson], - hasNextPage: false, - }); - personMock.getAllFaces.mockResolvedValue({ - items: [faceStub.face1], - hasNextPage: false, - }); + personMock.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); + personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); assetMock.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, @@ -544,18 +537,12 @@ describe(PersonService.name, () => { it('should queue missing assets', async () => { jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAllFaces.mockResolvedValue({ - items: [faceStub.face1], - hasNextPage: false, - }); + personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); personMock.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({}); - expect(personMock.getAllFaces).toHaveBeenCalledWith( - { skip: 0, take: 1000 }, - { where: { personId: IsNull(), sourceType: SourceType.MACHINE_LEARNING } }, - ); + expect(personMock.getAllFaces).toHaveBeenCalledWith({ personId: null, sourceType: SourceType.MACHINE_LEARNING }); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, @@ -569,19 +556,13 @@ describe(PersonService.name, () => { it('should queue all assets', async () => { jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAll.mockResolvedValue({ - items: [], - hasNextPage: false, - }); - personMock.getAllFaces.mockResolvedValue({ - items: [faceStub.face1], - hasNextPage: false, - }); + personMock.getAll.mockReturnValue(makeStream()); + personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); personMock.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true }); - expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, {}); + expect(personMock.getAllFaces).toHaveBeenCalledWith(undefined); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, @@ -595,26 +576,17 @@ describe(PersonService.name, () => { it('should run nightly if new face has been added since last run', async () => { personMock.getLatestFaceDate.mockResolvedValue(new Date().toISOString()); - personMock.getAllFaces.mockResolvedValue({ - items: [faceStub.face1], - hasNextPage: false, - }); + personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAll.mockResolvedValue({ - items: [], - hasNextPage: false, - }); - personMock.getAllFaces.mockResolvedValue({ - items: [faceStub.face1], - hasNextPage: false, - }); + personMock.getAll.mockReturnValue(makeStream()); + personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); personMock.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); expect(personMock.getLatestFaceDate).toHaveBeenCalledOnce(); - expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, {}); + expect(personMock.getAllFaces).toHaveBeenCalledWith(undefined); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, @@ -631,10 +603,7 @@ describe(PersonService.name, () => { systemMock.get.mockResolvedValue({ lastRun: lastRun.toISOString() }); personMock.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString()); - personMock.getAllFaces.mockResolvedValue({ - items: [faceStub.face1], - hasNextPage: false, - }); + personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); personMock.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); @@ -648,15 +617,8 @@ describe(PersonService.name, () => { it('should delete existing people if forced', async () => { jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAll.mockResolvedValue({ - items: [faceStub.face1.person, personStub.randomPerson], - hasNextPage: false, - }); - personMock.getAllFaces.mockResolvedValue({ - items: [faceStub.face1], - hasNextPage: false, - }); - + personMock.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); + personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); await sut.handleQueueRecognizeFaces({ force: true }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index cc488a7f4e0ad..45732c4e7ce46 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -50,7 +50,6 @@ import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; -import { IsNull } from 'typeorm'; @Injectable() export class PersonService extends BaseService { @@ -306,7 +305,7 @@ export class PersonService extends BaseService { ); this.logger.debug(`${faces.length} faces detected in ${previewFile.path}`); - const facesToAdd: (Partial & { id: string })[] = []; + const facesToAdd: (Partial & { id: string; assetId: string })[] = []; const embeddings: FaceSearchEntity[] = []; const mlFaceIds = new Set(); for (const face of asset.faces) { @@ -414,18 +413,22 @@ export class PersonService extends BaseService { } const lastRun = new Date().toISOString(); - const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.personRepository.getAllFaces(pagination, { - where: force ? undefined : { personId: IsNull(), sourceType: SourceType.MACHINE_LEARNING }, - }), + const facePagination = this.personRepository.getAllFaces( + force ? undefined : { personId: null, sourceType: SourceType.MACHINE_LEARNING }, ); - for await (const page of facePagination) { - await this.jobRepository.queueAll( - page.map((face) => ({ name: JobName.FACIAL_RECOGNITION, data: { id: face.id, deferred: false } })), - ); + let jobs: { name: JobName.FACIAL_RECOGNITION; data: { id: string; deferred: false } }[] = []; + for await (const face of facePagination) { + jobs.push({ name: JobName.FACIAL_RECOGNITION, data: { id: face.id, deferred: false } }); + + if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) { + await this.jobRepository.queueAll(jobs); + jobs = []; + } } + await this.jobRepository.queueAll(jobs); + await this.systemMetadataRepository.set(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun }); return JobStatus.SUCCESS; @@ -441,7 +444,7 @@ export class PersonService extends BaseService { const face = await this.personRepository.getFaceByIdWithAssets( id, { person: true, asset: true, faceSearch: true }, - { id: true, personId: true, sourceType: true, faceSearch: { embedding: true } }, + { id: true, personId: true, sourceType: true, faceSearch: true }, ); if (!face || !face.asset) { this.logger.warn(`Face ${id} not found`); diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 4ccb68f2e02ac..7483ef6f9281b 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -42,7 +42,7 @@ export const asUuid = (id: string | Expression) => sql`${id}::uu export const anyUuid = (ids: string[]) => sql`any(${`{${ids}}`}::uuid[])`; -export const asVector = (embedding: number[]) => sql`${`[${embedding}]`}::vector`; +export const asVector = (embedding: number[]) => sql`${`[${embedding}]`}::vector`; /** * Mainly for type debugging to make VS Code display a more useful tooltip. diff --git a/server/test/utils.ts b/server/test/utils.ts index 7f5b75020c7bc..20239e611df9d 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -250,3 +250,10 @@ export const mockSpawn = vitest.fn((exitCode: number, stdout: string, stderr: st }), } as unknown as ChildProcessWithoutNullStreams; }); + +export async function* makeStream(items: T[] = []): AsyncIterableIterator { + for (const item of items) { + await Promise.resolve(); + yield item; + } +}