1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-04-21 15:36:26 +02:00

refactor: migrate person repository to kysely ()

* refactor: migrate person repository to kysely

* `asVector` begone

* linting

* fix metadata faces

* update test

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
This commit is contained in:
Daniel Dietzler 2025-01-21 19:12:28 +01:00 committed by GitHub
parent 0c152366ec
commit 332a865ce6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 715 additions and 747 deletions

View file

@ -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 () => {

View file

@ -10,7 +10,7 @@ from tokenizers import Encoding, Tokenizer
from app.config import log
from app.models.base import InferenceModel
from app.models.transforms import clean_text
from app.models.transforms import clean_text, serialize_np_array
from app.schemas import ModelSession, ModelTask, ModelType
@ -18,9 +18,9 @@ class BaseCLIPTextualEncoder(InferenceModel):
depends = []
identity = (ModelType.TEXTUAL, ModelTask.SEARCH)
def _predict(self, inputs: str, **kwargs: Any) -> NDArray[np.float32]:
def _predict(self, inputs: str, **kwargs: Any) -> str:
res: NDArray[np.float32] = self.session.run(None, self.tokenize(inputs))[0][0]
return res
return serialize_np_array(res)
def _load(self) -> ModelSession:
session = super()._load()

View file

@ -10,7 +10,15 @@ from PIL import Image
from app.config import log
from app.models.base import InferenceModel
from app.models.transforms import crop_pil, decode_pil, get_pil_resampling, normalize, resize_pil, to_numpy
from app.models.transforms import (
crop_pil,
decode_pil,
get_pil_resampling,
normalize,
resize_pil,
serialize_np_array,
to_numpy,
)
from app.schemas import ModelSession, ModelTask, ModelType
@ -18,10 +26,10 @@ class BaseCLIPVisualEncoder(InferenceModel):
depends = []
identity = (ModelType.VISUAL, ModelTask.SEARCH)
def _predict(self, inputs: Image.Image | bytes, **kwargs: Any) -> NDArray[np.float32]:
def _predict(self, inputs: Image.Image | bytes, **kwargs: Any) -> str:
image = decode_pil(inputs)
res: NDArray[np.float32] = self.session.run(None, self.transform(image))[0][0]
return res
return serialize_np_array(res)
@abstractmethod
def transform(self, image: Image.Image) -> dict[str, NDArray[np.float32]]:

View file

@ -12,7 +12,7 @@ from PIL import Image
from app.config import log, settings
from app.models.base import InferenceModel
from app.models.transforms import decode_cv2
from app.models.transforms import decode_cv2, serialize_np_array
from app.schemas import FaceDetectionOutput, FacialRecognitionOutput, ModelFormat, ModelSession, ModelTask, ModelType
@ -61,7 +61,7 @@ class FaceRecognizer(InferenceModel):
return [
{
"boundingBox": {"x1": x1, "y1": y1, "x2": x2, "y2": y2},
"embedding": embedding,
"embedding": serialize_np_array(embedding),
"score": score,
}
for (x1, y1, x2, y2), embedding, score in zip(faces["boxes"], embeddings, faces["scores"])

View file

@ -4,6 +4,7 @@ from typing import IO
import cv2
import numpy as np
import orjson
from numpy.typing import NDArray
from PIL import Image
@ -69,3 +70,9 @@ def clean_text(text: str, canonicalize: bool = False) -> str:
if canonicalize:
text = text.translate(_PUNCTUATION_TRANS).lower()
return text
# this allows the client to use the array as a string without deserializing only to serialize back to a string
# TODO: use this in a less invasive way
def serialize_np_array(arr: NDArray[np.float32]) -> str:
return orjson.dumps(arr, option=orjson.OPT_SERIALIZE_NUMPY).decode()

View file

@ -79,7 +79,7 @@ class FaceDetectionOutput(TypedDict):
class DetectedFace(TypedDict):
boundingBox: BoundingBox
embedding: npt.NDArray[np.float32]
embedding: str
score: float

View file

@ -10,6 +10,7 @@ from unittest import mock
import cv2
import numpy as np
import onnxruntime as ort
import orjson
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
@ -346,11 +347,11 @@ class TestCLIP:
mocked.run.return_value = [[self.embedding]]
clip_encoder = OpenClipVisualEncoder("ViT-B-32__openai", cache_dir="test_cache")
embedding = clip_encoder.predict(pil_image)
assert isinstance(embedding, np.ndarray)
assert embedding.shape[0] == clip_model_cfg["embed_dim"]
assert embedding.dtype == np.float32
embedding_str = clip_encoder.predict(pil_image)
assert isinstance(embedding_str, str)
embedding = orjson.loads(embedding_str)
assert isinstance(embedding, list)
assert len(embedding) == clip_model_cfg["embed_dim"]
mocked.run.assert_called_once()
def test_basic_text(
@ -368,11 +369,11 @@ class TestCLIP:
mocker.patch("app.models.clip.textual.Tokenizer.from_file", autospec=True)
clip_encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache")
embedding = clip_encoder.predict("test search query")
assert isinstance(embedding, np.ndarray)
assert embedding.shape[0] == clip_model_cfg["embed_dim"]
assert embedding.dtype == np.float32
embedding_str = clip_encoder.predict("test search query")
assert isinstance(embedding_str, str)
embedding = orjson.loads(embedding_str)
assert isinstance(embedding, list)
assert len(embedding) == clip_model_cfg["embed_dim"]
mocked.run.assert_called_once()
def test_openclip_tokenizer(
@ -508,8 +509,11 @@ class TestFaceRecognition:
assert isinstance(face.get("boundingBox"), dict)
assert set(face["boundingBox"]) == {"x1", "y1", "x2", "y2"}
assert all(isinstance(val, np.float32) for val in face["boundingBox"].values())
assert isinstance(face.get("embedding"), np.ndarray)
assert face["embedding"].shape[0] == 512
embedding_str = face.get("embedding")
assert isinstance(embedding_str, str)
embedding = orjson.loads(embedding_str)
assert isinstance(embedding, list)
assert len(embedding) == 512
assert isinstance(face.get("score", None), np.float32)
rec_model.get_feat.assert_called_once()
@ -880,8 +884,10 @@ class TestPredictionEndpoints:
actual = response.json()
assert response.status_code == 200
assert isinstance(actual, dict)
assert isinstance(actual.get("clip", None), list)
assert np.allclose(expected, actual["clip"])
embedding = actual.get("clip", None)
assert isinstance(embedding, str)
parsed_embedding = orjson.loads(embedding)
assert np.allclose(expected, parsed_embedding)
def test_clip_text_endpoint(self, responses: dict[str, Any], deployed_app: TestClient) -> None:
expected = responses["clip"]["text"]
@ -901,8 +907,10 @@ class TestPredictionEndpoints:
actual = response.json()
assert response.status_code == 200
assert isinstance(actual, dict)
assert isinstance(actual.get("clip", None), list)
assert np.allclose(expected, actual["clip"])
embedding = actual.get("clip", None)
assert isinstance(embedding, str)
parsed_embedding = orjson.loads(embedding)
assert np.allclose(expected, parsed_embedding)
def test_face_endpoint(self, pil_image: Image.Image, responses: dict[str, Any], deployed_app: TestClient) -> None:
byte_image = BytesIO()
@ -933,5 +941,8 @@ class TestPredictionEndpoints:
for expected_face, actual_face in zip(responses["facial-recognition"], actual["facial-recognition"]):
assert expected_face["boundingBox"] == actual_face["boundingBox"]
assert np.allclose(expected_face["embedding"], actual_face["embedding"])
embedding = actual_face.get("embedding", None)
assert isinstance(embedding, str)
parsed_embedding = orjson.loads(embedding)
assert np.allclose(expected_face["embedding"], parsed_embedding)
assert np.allclose(expected_face["score"], actual_face["score"])

View file

@ -100,6 +100,7 @@ export const DummyValue = {
DATE: new Date(),
TIME_BUCKET: '2024-01-01T00:00:00.000Z',
BOOLEAN: true,
VECTOR: '[1, 2, 3]',
};
export const GENERATE_SQL_KEY = 'generate-sql-key';

View file

@ -11,10 +11,6 @@ export class FaceSearchEntity {
faceId!: string;
@Index('face_index', { synchronize: false })
@Column({
type: 'float4',
array: true,
transformer: { from: JSON.parse, to: (v) => `[${v}]` },
})
embedding!: number[];
@Column({ type: 'float4', array: true })
embedding!: string;
}

View file

@ -11,6 +11,6 @@ export class SmartSearchEntity {
assetId!: string;
@Index('clip_index', { synchronize: false })
@Column({ type: 'float4', array: true, transformer: { from: JSON.parse, to: (v) => v } })
embedding!: number[];
@Column({ type: 'float4', array: true })
embedding!: string;
}

View file

@ -28,10 +28,10 @@ export type FaceDetectionOptions = ModelOptions & { minScore: number };
type VisualResponse = { imageHeight: number; imageWidth: number };
export type ClipVisualRequest = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: ModelOptions } };
export type ClipVisualResponse = { [ModelTask.SEARCH]: number[] } & VisualResponse;
export type ClipVisualResponse = { [ModelTask.SEARCH]: string } & VisualResponse;
export type ClipTextualRequest = { [ModelTask.SEARCH]: { [ModelType.TEXTUAL]: ModelOptions } };
export type ClipTextualResponse = { [ModelTask.SEARCH]: number[] };
export type ClipTextualResponse = { [ModelTask.SEARCH]: string };
export type FacialRecognitionRequest = {
[ModelTask.FACIAL_RECOGNITION]: {
@ -42,7 +42,7 @@ export type FacialRecognitionRequest = {
export interface Face {
boundingBox: BoundingBox;
embedding: number[];
embedding: string;
score: number;
}
@ -51,7 +51,7 @@ export type DetectedFaces = { faces: Face[] } & VisualResponse;
export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | FacialRecognitionRequest;
export interface IMachineLearningRepository {
encodeImage(urls: string[], imagePath: string, config: ModelOptions): Promise<number[]>;
encodeText(urls: string[], text: string, config: ModelOptions): Promise<number[]>;
encodeImage(urls: string[], imagePath: string, config: ModelOptions): Promise<string>;
encodeText(urls: string[], text: string, config: ModelOptions): Promise<string>;
detectFaces(urls: string[], imagePath: string, config: FaceDetectionOptions): Promise<DetectedFaces>;
}

View file

@ -1,9 +1,10 @@
import { Insertable, Updateable } from 'kysely';
import { AssetFaces, FaceSearch, Person } from 'src/db';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
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 +49,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<PersonEntity>): Paginated<PersonEntity>;
getAll(options?: Partial<PersonEntity>): AsyncIterableIterator<PersonEntity>;
getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated<PersonEntity>;
getAllWithoutFaces(): Promise<PersonEntity[]>;
getById(personId: string): Promise<PersonEntity | null>;
getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise<PersonEntity[]>;
getDistinctNames(userId: string, options: PersonNameSearchOptions): Promise<PersonNameResponse[]>;
create(person: Partial<PersonEntity>): Promise<PersonEntity>;
createAll(people: Partial<PersonEntity>[]): Promise<string[]>;
create(person: Insertable<Person>): Promise<PersonEntity>;
createAll(people: Insertable<Person>[]): Promise<string[]>;
delete(entities: PersonEntity[]): Promise<void>;
deleteFaces(options: DeleteFacesOptions): Promise<void>;
refreshFaces(
facesToAdd: Partial<AssetFaceEntity>[],
facesToAdd: Insertable<AssetFaces>[],
faceIdsToRemove: string[],
embeddingsToAdd?: FaceSearchEntity[],
embeddingsToAdd?: Insertable<FaceSearch>[],
): Promise<void>;
getAllFaces(pagination: PaginationOptions, options?: FindManyOptions<AssetFaceEntity>): Paginated<AssetFaceEntity>;
getAllFaces(options?: Partial<AssetFaceEntity>): AsyncIterableIterator<AssetFaceEntity>;
getFaceById(id: string): Promise<AssetFaceEntity>;
getFaceByIdWithAssets(
id: string,
relations?: FindOptionsRelations<AssetFaceEntity>,
select?: FindOptionsSelect<AssetFaceEntity>,
select?: SelectFaceOptions,
): Promise<AssetFaceEntity | null>;
getFaces(assetId: string): Promise<AssetFaceEntity[]>;
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
@ -80,7 +83,7 @@ export interface IPersonRepository {
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
reassignFaces(data: UpdateFacesData): Promise<number>;
unassignFaces(options: UnassignFacesOptions): Promise<void>;
update(person: Partial<PersonEntity>): Promise<PersonEntity>;
updateAll(people: Partial<PersonEntity>[]): Promise<void>;
update(person: Updateable<Person> & { id: string }): Promise<PersonEntity>;
updateAll(people: Insertable<Person>[]): Promise<void>;
getLatestFaceDate(): Promise<string | undefined>;
}

View file

@ -104,7 +104,7 @@ export interface SearchExifOptions {
}
export interface SearchEmbeddingOptions {
embedding: number[];
embedding: string;
userIds: string[];
}
@ -152,7 +152,7 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
export interface AssetDuplicateSearch {
assetId: string;
embedding: number[];
embedding: string;
maxDistance: number;
type: AssetType;
userIds: string[];
@ -192,7 +192,7 @@ export interface ISearchRepository {
searchDuplicates(options: AssetDuplicateSearch): Promise<AssetDuplicateResult[]>;
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
searchRandom(size: number, options: AssetSearchOptions): Promise<AssetEntity[]>;
upsert(assetId: string, embedding: number[]): Promise<void>;
upsert(assetId: string, embedding: string): Promise<void>;
searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]>;
getAssetsByCity(userIds: string[]): Promise<AssetEntity[]>;
deleteAllSearchEmbeddings(): Promise<void>;

View file

@ -1,342 +1,252 @@
-- 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
"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
"person".*
from
"person"
where
"person"."id" = "asset_faces"."personId"
) as obj
) as "person",
(
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
)
LIMIT
1000
-- 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" != ''
-- 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
-- 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
-- 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
"person".*
from
"person"
where
(
(
(
("AssetFaceEntity"."assetId" = $1)
AND ("AssetFaceEntity"."personId" = $2)
)
"person"."ownerId" = $1
and (
lower("person"."name") like $2
or lower("person"."name") like $3
)
)
limit
$4
-- PersonRepository.getDistinctNames
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 ("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" = $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.refreshFaces
with
"added_embeddings" as (
insert into
"face_search" ("faceId", "embedding")
values
($1, $2)
)
select
from
(
select
1
) as "dummy"
-- PersonRepository.getFacesByIds
select
"asset_faces".*,
(
select
to_json(obj)
from
(
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"

View file

@ -76,7 +76,7 @@ where
and "assets"."isArchived" = $5
and "assets"."deletedAt" is null
order by
smart_search.embedding <= > $6::vector
smart_search.embedding <= > $6
limit
$7
offset
@ -88,7 +88,7 @@ with
select
"assets"."id" as "assetId",
"assets"."duplicateId",
smart_search.embedding <= > $1::vector as "distance"
smart_search.embedding <= > $1 as "distance"
from
"assets"
inner join "smart_search" on "assets"."id" = "smart_search"."assetId"
@ -99,7 +99,7 @@ with
and "assets"."type" = $4
and "assets"."id" != $5::uuid
order by
smart_search.embedding <= > $6::vector
smart_search.embedding <= > $6
limit
$7
)
@ -116,7 +116,7 @@ with
select
"asset_faces"."id",
"asset_faces"."personId",
face_search.embedding <= > $1::vector as "distance"
face_search.embedding <= > $1 as "distance"
from
"asset_faces"
inner join "assets" on "assets"."id" = "asset_faces"."assetId"
@ -125,7 +125,7 @@ with
"assets"."ownerId" = any ($2::uuid [])
and "assets"."deletedAt" is null
order by
face_search.embedding <= > $3::vector
face_search.embedding <= > $3
limit
$4
)

View file

@ -1,13 +1,13 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { ExpressionBuilder, Insertable, Kysely, SelectExpression, sql } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFaces, DB, FaceSearch, Person } 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 +17,418 @@ 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 { mapUpsertColumns } from 'src/utils/database';
import { Paginated, PaginationOptions } from 'src/utils/pagination';
import { FindOptionsRelations } from 'typeorm';
const withPerson = (eb: ExpressionBuilder<DB, 'asset_faces'>) => {
return jsonObjectFrom(
eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_faces.personId'),
).as('person');
};
const withAsset = (eb: ExpressionBuilder<DB, 'asset_faces'>) => {
return jsonObjectFrom(
eb.selectFrom('assets').selectAll('assets').whereRef('assets.id', '=', 'asset_faces.assetId'),
).as('asset');
};
const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_faces'>) => {
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<AssetEntity>,
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(FaceSearchEntity) private faceSearchRepository: Repository<FaceSearchEntity>,
@InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository<AssetJobStatusEntity>,
) {}
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> {
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<void> {
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<void> {
await this.personRepository.remove(entities);
if (entities.length === 0) {
return;
}
await this.db
.deleteFrom('person')
.where(
'person.id',
'in',
entities.map(({ id }) => id),
)
.execute();
}
@GenerateSql({ params: [{ sourceType: SourceType.EXIF }] })
async deleteFaces({ sourceType }: DeleteFacesOptions): Promise<void> {
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<AssetFaceEntity> = {},
): Paginated<AssetFaceEntity> {
return paginate(this.assetFaceRepository, pagination, options);
getAllFaces(options: Partial<AssetFaceEntity> = {}): AsyncIterableIterator<AssetFaceEntity> {
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<AssetFaceEntity>;
}
getAll(pagination: PaginationOptions, options: FindManyOptions<PersonEntity> = {}): Paginated<PersonEntity> {
return paginate(this.personRepository, pagination, options);
getAll(options: Partial<PersonEntity> = {}): AsyncIterableIterator<PersonEntity> {
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<PersonEntity>;
}
@GenerateSql({ params: [{ take: 10, skip: 10 }, DummyValue.UUID] })
async getAllForUser(
pagination: PaginationOptions,
userId: string,
options?: PersonSearchOptions,
): Paginated<PersonEntity> {
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());
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 };
}
if (!options?.withHidden) {
queryBuilder.andWhere('person.isHidden = false');
}
return paginatedBuilder(queryBuilder, {
mode: PaginationMode.LIMIT_OFFSET,
...pagination,
});
return { items, hasNextPage: false };
}
@GenerateSql()
getAllWithoutFaces(): Promise<PersonEntity[]> {
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<PersonEntity[]>;
}
@GenerateSql({ params: [DummyValue.UUID] })
getFaces(assetId: string): Promise<AssetFaceEntity[]> {
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<AssetFaceEntity[]>;
}
@GenerateSql({ params: [DummyValue.UUID] })
getFaceById(id: string): Promise<AssetFaceEntity> {
// 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<AssetFaceEntity>;
}
@GenerateSql({ params: [DummyValue.UUID] })
getFaceByIdWithAssets(
id: string,
relations: FindOptionsRelations<AssetFaceEntity>,
select: FindOptionsSelect<AssetFaceEntity>,
relations?: FindOptionsRelations<AssetFaceEntity>,
select?: SelectFaceOptions,
): Promise<AssetFaceEntity | null> {
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<DB, 'asset_faces'>[],
),
)
.$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<AssetFaceEntity | null>;
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
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<PersonEntity | null> {
return this.personRepository.findOne({ where: { id: personId } });
return (this.db //
.selectFrom('person')
.selectAll('person')
.where('person.id', '=', personId)
.executeTakeFirst() ?? null) as Promise<PersonEntity | null>;
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise<PersonEntity[]> {
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<PersonEntity[]>;
}
@GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] })
getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise<PersonNameResponse[]> {
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<PersonStatistics> {
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<PeopleStatistics> {
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<PersonEntity>): Promise<PersonEntity> {
return this.save(person);
create(person: Insertable<Person>): Promise<PersonEntity> {
return this.db.insertInto('person').values(person).returningAll().executeTakeFirst() as Promise<PersonEntity>;
}
async createAll(people: Partial<PersonEntity>[]): Promise<string[]> {
const results = await this.personRepository.save(people);
return results.map((person) => person.id);
async createAll(people: Insertable<Person>[]): Promise<string[]> {
const results = await this.db.insertInto('person').values(people).returningAll().execute();
return results.map(({ id }) => id);
}
@GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] })
async refreshFaces(
facesToAdd: Partial<AssetFaceEntity>[],
facesToAdd: (Insertable<AssetFaces> & { assetId: string })[],
faceIdsToRemove: string[],
embeddingsToAdd?: FaceSearchEntity[],
embeddingsToAdd?: Insertable<FaceSearch>[],
): Promise<void> {
const query = this.faceSearchRepository.createQueryBuilder().select('1').fromDummy();
let query = this.db;
if (facesToAdd.length > 0) {
const insertCte = this.assetFaceRepository.createQueryBuilder().insert().values(facesToAdd);
query.addCommonTableExpression(insertCte, 'added');
(query as any) = query.with('added', (db) => db.insertInto('asset_faces').values(facesToAdd));
}
if (faceIdsToRemove.length > 0) {
const deleteCte = this.assetFaceRepository
.createQueryBuilder()
.delete()
.where('id = any(:faceIdsToRemove)', { faceIdsToRemove });
query.addCommonTableExpression(deleteCte, 'deleted');
(query as any) = query.with('removed', (db) =>
db.deleteFrom('asset_faces').where('asset_faces.id', '=', (eb) => eb.fn.any(eb.val(faceIdsToRemove))),
);
}
if (embeddingsToAdd?.length) {
const embeddingCte = this.faceSearchRepository.createQueryBuilder().insert().values(embeddingsToAdd).orIgnore();
query.addCommonTableExpression(embeddingCte, 'embeddings');
query.getQuery(); // typeorm mixes up parameters without this
(query as any) = query.with('added_embeddings', (db) => db.insertInto('face_search').values(embeddingsToAdd));
}
await query.execute();
await query.selectFrom(sql`(select 1)`.as('dummy')).execute();
}
async update(person: Partial<PersonEntity>): Promise<PersonEntity> {
return this.save(person);
async update(person: Partial<PersonEntity> & { id: string }): Promise<PersonEntity> {
return this.db
.updateTable('person')
.set(person)
.where('person.id', '=', person.id)
.returningAll()
.executeTakeFirstOrThrow() as Promise<PersonEntity>;
}
async updateAll(people: Partial<PersonEntity>[]): Promise<void> {
await this.personRepository.save(people);
async updateAll(people: Insertable<Person>[]): Promise<void> {
if (people.length === 0) {
return;
}
await this.db
.insertInto('person')
.values(people)
.onConflict((oc) => oc.column('id').doUpdateSet(() => mapUpsertColumns('person', people[0], ['id'])))
.execute();
}
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
@ChunkedArray()
async getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> {
return this.assetFaceRepository.find({ where: ids, relations: { asset: true }, withDeleted: true });
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> {
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<AssetFaceEntity[]>;
}
@GenerateSql({ params: [DummyValue.UUID] })
async getRandomFace(personId: string): Promise<AssetFaceEntity | null> {
return this.assetFaceRepository.findOneBy({ personId });
getRandomFace(personId: string): Promise<AssetFaceEntity | null> {
return (this.db
.selectFrom('asset_faces')
.selectAll('asset_faces')
.where('asset_faces.personId', '=', personId)
.executeTakeFirst() ?? null) as Promise<AssetFaceEntity | null>;
}
@GenerateSql()
async getLatestFaceDate(): Promise<string | undefined> {
const result: { latestDate?: string } | undefined = await this.jobStatusRepository
.createQueryBuilder('jobStatus')
.select('MAX(jobStatus.facesRecognizedAt)::text', 'latestDate')
.getRawOne();
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;
return result?.latestDate;
}
private async save(person: Partial<PersonEntity>): Promise<PersonEntity> {
const { id } = await this.personRepository.save(person);
return this.personRepository.findOneByOrFail({ id });
}
private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise<void> {
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);
}
}
}

View file

@ -20,7 +20,7 @@ import {
SearchPaginationOptions,
SmartSearchOptions,
} from 'src/interfaces/search.interface';
import { anyUuid, asUuid, asVector } from 'src/utils/database';
import { anyUuid, asUuid } from 'src/utils/database';
import { Paginated } from 'src/utils/pagination';
import { isValidInteger } from 'src/validation';
@ -82,7 +82,7 @@ export class SearchRepository implements ISearchRepository {
{ page: 1, size: 200 },
{
takenAfter: DummyValue.DATE,
embedding: Array.from({ length: 512 }, Math.random),
embedding: DummyValue.VECTOR,
lensModel: DummyValue.STRING,
withStacked: true,
isFavorite: true,
@ -97,7 +97,7 @@ export class SearchRepository implements ISearchRepository {
const items = (await searchAssetBuilder(this.db, options)
.innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
.orderBy(sql`smart_search.embedding <=> ${asVector(options.embedding)}`)
.orderBy(sql`smart_search.embedding <=> ${options.embedding}`)
.limit(pagination.size + 1)
.offset((pagination.page - 1) * pagination.size)
.execute()) as any as AssetEntity[];
@ -111,7 +111,7 @@ export class SearchRepository implements ISearchRepository {
params: [
{
assetId: DummyValue.UUID,
embedding: Array.from({ length: 512 }, Math.random),
embedding: DummyValue.VECTOR,
maxDistance: 0.6,
type: AssetType.IMAGE,
userIds: [DummyValue.UUID],
@ -119,7 +119,6 @@ export class SearchRepository implements ISearchRepository {
],
})
searchDuplicates({ assetId, embedding, maxDistance, type, userIds }: AssetDuplicateSearch) {
const vector = asVector(embedding);
return this.db
.with('cte', (qb) =>
qb
@ -127,7 +126,7 @@ export class SearchRepository implements ISearchRepository {
.select([
'assets.id as assetId',
'assets.duplicateId',
sql<number>`smart_search.embedding <=> ${vector}`.as('distance'),
sql<number>`smart_search.embedding <=> ${embedding}`.as('distance'),
])
.innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
.where('assets.ownerId', '=', anyUuid(userIds))
@ -135,7 +134,7 @@ export class SearchRepository implements ISearchRepository {
.where('assets.isVisible', '=', true)
.where('assets.type', '=', type)
.where('assets.id', '!=', asUuid(assetId))
.orderBy(sql`smart_search.embedding <=> ${vector}`)
.orderBy(sql`smart_search.embedding <=> ${embedding}`)
.limit(64),
)
.selectFrom('cte')
@ -148,7 +147,7 @@ export class SearchRepository implements ISearchRepository {
params: [
{
userIds: [DummyValue.UUID],
embedding: Array.from({ length: 512 }, Math.random),
embedding: DummyValue.VECTOR,
numResults: 10,
maxDistance: 0.6,
},
@ -159,7 +158,6 @@ export class SearchRepository implements ISearchRepository {
throw new Error(`Invalid value for 'numResults': ${numResults}`);
}
const vector = asVector(embedding);
return this.db
.with('cte', (qb) =>
qb
@ -167,14 +165,14 @@ export class SearchRepository implements ISearchRepository {
.select([
'asset_faces.id',
'asset_faces.personId',
sql<number>`face_search.embedding <=> ${vector}`.as('distance'),
sql<number>`face_search.embedding <=> ${embedding}`.as('distance'),
])
.innerJoin('assets', 'assets.id', 'asset_faces.assetId')
.innerJoin('face_search', 'face_search.faceId', 'asset_faces.id')
.where('assets.ownerId', '=', anyUuid(userIds))
.where('assets.deletedAt', 'is', null)
.$if(!!hasPerson, (qb) => qb.where('asset_faces.personId', 'is not', null))
.orderBy(sql`face_search.embedding <=> ${vector}`)
.orderBy(sql`face_search.embedding <=> ${embedding}`)
.limit(numResults),
)
.selectFrom('cte')
@ -258,12 +256,11 @@ export class SearchRepository implements ISearchRepository {
.execute() as any as Promise<AssetEntity[]>;
}
async upsert(assetId: string, embedding: number[]): Promise<void> {
const vector = asVector(embedding);
async upsert(assetId: string, embedding: string): Promise<void> {
await this.db
.insertInto('smart_search')
.values({ assetId: asUuid(assetId), embedding: vector } as any)
.onConflict((oc) => oc.column('assetId').doUpdateSet({ embedding: vector } as any))
.values({ assetId: asUuid(assetId), embedding } as any)
.onConflict((oc) => oc.column('assetId').doUpdateSet({ embedding } as any))
.execute();
}

View file

@ -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);

View file

@ -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 });

View file

@ -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;
}

View file

@ -1086,7 +1086,9 @@ describe(MetadataService.name, () => {
],
[],
);
expect(personMock.updateAll).toHaveBeenCalledWith([{ id: 'random-uuid', faceAssetId: 'random-uuid' }]);
expect(personMock.updateAll).toHaveBeenCalledWith([
{ id: 'random-uuid', ownerId: 'admin-id', faceAssetId: 'random-uuid' },
]);
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.GENERATE_PERSON_THUMBNAIL,

View file

@ -509,11 +509,11 @@ export class MetadataService extends BaseService {
return;
}
const facesToAdd: Partial<AssetFaceEntity>[] = [];
const facesToAdd: (Partial<AssetFaceEntity> & { 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<PersonEntity>[] = [];
const missingWithFaceAsset: Partial<PersonEntity>[] = [];
const missing: (Partial<PersonEntity> & { ownerId: string })[] = [];
const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = [];
for (const region of tags.RegionInfo.RegionList) {
if (!region.Name) {
continue;
@ -540,7 +540,7 @@ export class MetadataService extends BaseService {
facesToAdd.push(face);
if (!existingNameMap.has(loweredName)) {
missing.push({ id: personId, ownerId: asset.ownerId, name: region.Name });
missingWithFaceAsset.push({ id: personId, faceAssetId: face.id });
missingWithFaceAsset.push({ id: personId, ownerId: asset.ownerId, faceAssetId: face.id });
}
}
@ -557,7 +557,7 @@ export class MetadataService extends BaseService {
}
if (facesToAdd.length > 0) {
this.logger.debug(`Creating ${facesToAdd} faces from metadata for asset ${asset.id}`);
this.logger.debug(`Creating ${facesToAdd.length} faces from metadata for asset ${asset.id}`);
}
if (facesToRemove.length > 0 || facesToAdd.length > 0) {

View file

@ -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 = {
@ -46,7 +45,7 @@ const face = {
imageHeight: 500,
imageWidth: 400,
};
const faceSearch = { faceId, embedding: [1, 2, 3, 4] };
const faceSearch = { faceId, embedding: '[1, 2, 3, 4]' };
const detectFaceMock: DetectedFaces = {
faces: [
{
@ -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 });

View file

@ -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<AssetFaceEntity> & { id: string })[] = [];
const facesToAdd: (Partial<AssetFaceEntity> & { id: string; assetId: string })[] = [];
const embeddings: FaceSearchEntity[] = [];
const mlFaceIds = new Set<string>();
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`);

View file

@ -284,7 +284,7 @@ describe(SmartInfoService.name, () => {
});
it('should save the returned objects', async () => {
machineLearningMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
machineLearningMock.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]');
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS);
@ -293,7 +293,7 @@ describe(SmartInfoService.name, () => {
'/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
);
expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, [0.01, 0.02, 0.03]);
expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]');
});
it('should skip invisible assets', async () => {
@ -315,7 +315,7 @@ describe(SmartInfoService.name, () => {
});
it('should wait for database', async () => {
machineLearningMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
machineLearningMock.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]');
databaseMock.isBusy.mockReturnValue(true);
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS);
@ -326,7 +326,7 @@ describe(SmartInfoService.name, () => {
'/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
);
expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, [0.01, 0.02, 0.03]);
expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]');
});
});

View file

@ -42,7 +42,7 @@ export const asUuid = (id: string | Expression<string>) => sql<string>`${id}::uu
export const anyUuid = (ids: string[]) => sql<string>`any(${`{${ids}}`}::uuid[])`;
export const asVector = (embedding: number[]) => sql<number[]>`${`[${embedding}]`}::vector`;
export const asVector = (embedding: number[]) => sql<string>`${`[${embedding}]`}::vector`;
/**
* Mainly for type debugging to make VS Code display a more useful tooltip.

View file

@ -824,7 +824,7 @@ export const assetStub = {
duplicateId: null,
smartSearch: {
assetId: 'asset-id',
embedding: Array.from({ length: 512 }, Math.random),
embedding: '[1, 2, 3, 4]',
},
isOffline: false,
}),
@ -866,7 +866,7 @@ export const assetStub = {
duplicateId: 'duplicate-id',
smartSearch: {
assetId: 'asset-id',
embedding: Array.from({ length: 512 }, Math.random),
embedding: '[1, 2, 3, 4]',
},
isOffline: false,
}),

View file

@ -19,7 +19,7 @@ export const faceStub = {
imageHeight: 1024,
imageWidth: 1024,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId1', embedding: [1, 2, 3, 4] },
faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' },
}),
primaryFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
id: 'assetFaceId2',
@ -34,7 +34,7 @@ export const faceStub = {
imageHeight: 1024,
imageWidth: 1024,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId2', embedding: [1, 2, 3, 4] },
faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' },
}),
mergeFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
id: 'assetFaceId3',
@ -49,7 +49,7 @@ export const faceStub = {
imageHeight: 1024,
imageWidth: 1024,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId3', embedding: [1, 2, 3, 4] },
faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' },
}),
start: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
id: 'assetFaceId5',
@ -64,7 +64,7 @@ export const faceStub = {
imageHeight: 2880,
imageWidth: 2160,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId5', embedding: [1, 2, 3, 4] },
faceSearch: { faceId: 'assetFaceId5', embedding: '[1, 2, 3, 4]' },
}),
middle: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
id: 'assetFaceId6',
@ -79,7 +79,7 @@ export const faceStub = {
imageHeight: 500,
imageWidth: 400,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId6', embedding: [1, 2, 3, 4] },
faceSearch: { faceId: 'assetFaceId6', embedding: '[1, 2, 3, 4]' },
}),
end: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
id: 'assetFaceId7',
@ -94,7 +94,7 @@ export const faceStub = {
imageHeight: 500,
imageWidth: 500,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId7', embedding: [1, 2, 3, 4] },
faceSearch: { faceId: 'assetFaceId7', embedding: '[1, 2, 3, 4]' },
}),
noPerson1: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId8',
@ -109,7 +109,7 @@ export const faceStub = {
imageHeight: 1024,
imageWidth: 1024,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId8', embedding: [1, 2, 3, 4] },
faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' },
}),
noPerson2: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId9',
@ -124,7 +124,7 @@ export const faceStub = {
imageHeight: 1024,
imageWidth: 1024,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId9', embedding: [1, 2, 3, 4] },
faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' },
}),
fromExif1: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId9',

View file

@ -254,3 +254,10 @@ export const mockSpawn = vitest.fn((exitCode: number, stdout: string, stderr: st
}),
} as unknown as ChildProcessWithoutNullStreams;
});
export async function* makeStream<T>(items: T[] = []): AsyncIterableIterator<T> {
for (const item of items) {
await Promise.resolve();
yield item;
}
}