refactor: migrate person repository to kysely (#15242)

* 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({ expect(body).toMatchObject({
id: expect.any(String), id: expect.any(String),
name: 'New Person', 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}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ birthDate: '1990-01-01' }); .send({ birthDate: '1990-01-01' });
expect(status).toBe(200); 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 () => { 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.config import log
from app.models.base import InferenceModel 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 from app.schemas import ModelSession, ModelTask, ModelType
@ -18,9 +18,9 @@ class BaseCLIPTextualEncoder(InferenceModel):
depends = [] depends = []
identity = (ModelType.TEXTUAL, ModelTask.SEARCH) 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] res: NDArray[np.float32] = self.session.run(None, self.tokenize(inputs))[0][0]
return res return serialize_np_array(res)
def _load(self) -> ModelSession: def _load(self) -> ModelSession:
session = super()._load() session = super()._load()

View File

@ -10,7 +10,15 @@ from PIL import Image
from app.config import log from app.config import log
from app.models.base import InferenceModel 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 from app.schemas import ModelSession, ModelTask, ModelType
@ -18,10 +26,10 @@ class BaseCLIPVisualEncoder(InferenceModel):
depends = [] depends = []
identity = (ModelType.VISUAL, ModelTask.SEARCH) 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) image = decode_pil(inputs)
res: NDArray[np.float32] = self.session.run(None, self.transform(image))[0][0] res: NDArray[np.float32] = self.session.run(None, self.transform(image))[0][0]
return res return serialize_np_array(res)
@abstractmethod @abstractmethod
def transform(self, image: Image.Image) -> dict[str, NDArray[np.float32]]: 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.config import log, settings
from app.models.base import InferenceModel 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 from app.schemas import FaceDetectionOutput, FacialRecognitionOutput, ModelFormat, ModelSession, ModelTask, ModelType
@ -61,7 +61,7 @@ class FaceRecognizer(InferenceModel):
return [ return [
{ {
"boundingBox": {"x1": x1, "y1": y1, "x2": x2, "y2": y2}, "boundingBox": {"x1": x1, "y1": y1, "x2": x2, "y2": y2},
"embedding": embedding, "embedding": serialize_np_array(embedding),
"score": score, "score": score,
} }
for (x1, y1, x2, y2), embedding, score in zip(faces["boxes"], embeddings, faces["scores"]) 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 cv2
import numpy as np import numpy as np
import orjson
from numpy.typing import NDArray from numpy.typing import NDArray
from PIL import Image from PIL import Image
@ -69,3 +70,9 @@ def clean_text(text: str, canonicalize: bool = False) -> str:
if canonicalize: if canonicalize:
text = text.translate(_PUNCTUATION_TRANS).lower() text = text.translate(_PUNCTUATION_TRANS).lower()
return text 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): class DetectedFace(TypedDict):
boundingBox: BoundingBox boundingBox: BoundingBox
embedding: npt.NDArray[np.float32] embedding: str
score: float score: float

View File

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

View File

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

View File

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

View File

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

View File

@ -28,10 +28,10 @@ export type FaceDetectionOptions = ModelOptions & { minScore: number };
type VisualResponse = { imageHeight: number; imageWidth: number }; type VisualResponse = { imageHeight: number; imageWidth: number };
export type ClipVisualRequest = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: ModelOptions } }; 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 ClipTextualRequest = { [ModelTask.SEARCH]: { [ModelType.TEXTUAL]: ModelOptions } };
export type ClipTextualResponse = { [ModelTask.SEARCH]: number[] }; export type ClipTextualResponse = { [ModelTask.SEARCH]: string };
export type FacialRecognitionRequest = { export type FacialRecognitionRequest = {
[ModelTask.FACIAL_RECOGNITION]: { [ModelTask.FACIAL_RECOGNITION]: {
@ -42,7 +42,7 @@ export type FacialRecognitionRequest = {
export interface Face { export interface Face {
boundingBox: BoundingBox; boundingBox: BoundingBox;
embedding: number[]; embedding: string;
score: number; score: number;
} }
@ -51,7 +51,7 @@ export type DetectedFaces = { faces: Face[] } & VisualResponse;
export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | FacialRecognitionRequest; export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | FacialRecognitionRequest;
export interface IMachineLearningRepository { export interface IMachineLearningRepository {
encodeImage(urls: string[], imagePath: string, config: ModelOptions): Promise<number[]>; encodeImage(urls: string[], imagePath: string, config: ModelOptions): Promise<string>;
encodeText(urls: string[], text: string, config: ModelOptions): Promise<number[]>; encodeText(urls: string[], text: string, config: ModelOptions): Promise<string>;
detectFaces(urls: string[], imagePath: string, config: FaceDetectionOptions): Promise<DetectedFaces>; 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 { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { FaceSearchEntity } from 'src/entities/face-search.entity';
import { PersonEntity } from 'src/entities/person.entity'; import { PersonEntity } from 'src/entities/person.entity';
import { SourceType } from 'src/enum'; import { SourceType } from 'src/enum';
import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { Paginated, PaginationOptions } from 'src/utils/pagination';
import { FindManyOptions, FindOptionsRelations, FindOptionsSelect } from 'typeorm'; import { FindOptionsRelations } from 'typeorm';
export const IPersonRepository = 'IPersonRepository'; export const IPersonRepository = 'IPersonRepository';
@ -48,29 +49,31 @@ export interface DeleteFacesOptions {
export type UnassignFacesOptions = DeleteFacesOptions; export type UnassignFacesOptions = DeleteFacesOptions;
export type SelectFaceOptions = Partial<{ [K in keyof AssetFaceEntity]: boolean }>;
export interface IPersonRepository { 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>; getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated<PersonEntity>;
getAllWithoutFaces(): Promise<PersonEntity[]>; getAllWithoutFaces(): Promise<PersonEntity[]>;
getById(personId: string): Promise<PersonEntity | null>; getById(personId: string): Promise<PersonEntity | null>;
getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise<PersonEntity[]>; getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise<PersonEntity[]>;
getDistinctNames(userId: string, options: PersonNameSearchOptions): Promise<PersonNameResponse[]>; getDistinctNames(userId: string, options: PersonNameSearchOptions): Promise<PersonNameResponse[]>;
create(person: Partial<PersonEntity>): Promise<PersonEntity>; create(person: Insertable<Person>): Promise<PersonEntity>;
createAll(people: Partial<PersonEntity>[]): Promise<string[]>; createAll(people: Insertable<Person>[]): Promise<string[]>;
delete(entities: PersonEntity[]): Promise<void>; delete(entities: PersonEntity[]): Promise<void>;
deleteFaces(options: DeleteFacesOptions): Promise<void>; deleteFaces(options: DeleteFacesOptions): Promise<void>;
refreshFaces( refreshFaces(
facesToAdd: Partial<AssetFaceEntity>[], facesToAdd: Insertable<AssetFaces>[],
faceIdsToRemove: string[], faceIdsToRemove: string[],
embeddingsToAdd?: FaceSearchEntity[], embeddingsToAdd?: Insertable<FaceSearch>[],
): Promise<void>; ): Promise<void>;
getAllFaces(pagination: PaginationOptions, options?: FindManyOptions<AssetFaceEntity>): Paginated<AssetFaceEntity>; getAllFaces(options?: Partial<AssetFaceEntity>): AsyncIterableIterator<AssetFaceEntity>;
getFaceById(id: string): Promise<AssetFaceEntity>; getFaceById(id: string): Promise<AssetFaceEntity>;
getFaceByIdWithAssets( getFaceByIdWithAssets(
id: string, id: string,
relations?: FindOptionsRelations<AssetFaceEntity>, relations?: FindOptionsRelations<AssetFaceEntity>,
select?: FindOptionsSelect<AssetFaceEntity>, select?: SelectFaceOptions,
): Promise<AssetFaceEntity | null>; ): Promise<AssetFaceEntity | null>;
getFaces(assetId: string): Promise<AssetFaceEntity[]>; getFaces(assetId: string): Promise<AssetFaceEntity[]>;
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>; getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
@ -80,7 +83,7 @@ export interface IPersonRepository {
getNumberOfPeople(userId: string): Promise<PeopleStatistics>; getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
reassignFaces(data: UpdateFacesData): Promise<number>; reassignFaces(data: UpdateFacesData): Promise<number>;
unassignFaces(options: UnassignFacesOptions): Promise<void>; unassignFaces(options: UnassignFacesOptions): Promise<void>;
update(person: Partial<PersonEntity>): Promise<PersonEntity>; update(person: Updateable<Person> & { id: string }): Promise<PersonEntity>;
updateAll(people: Partial<PersonEntity>[]): Promise<void>; updateAll(people: Insertable<Person>[]): Promise<void>;
getLatestFaceDate(): Promise<string | undefined>; getLatestFaceDate(): Promise<string | undefined>;
} }

View File

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

View File

@ -1,342 +1,252 @@
-- NOTE: This file is auto generated by ./sql-generator -- NOTE: This file is auto generated by ./sql-generator
-- PersonRepository.reassignFaces -- PersonRepository.reassignFaces
UPDATE "asset_faces" update "asset_faces"
SET set
"personId" = $1 "personId" = $1
WHERE where
"personId" = $2 "asset_faces"."personId" = $2
-- PersonRepository.getAllForUser -- PersonRepository.unassignFaces
SELECT update "asset_faces"
"person"."id" AS "person_id", set
"person"."createdAt" AS "person_createdAt", "personId" = $1
"person"."updatedAt" AS "person_updatedAt", where
"person"."ownerId" AS "person_ownerId", "asset_faces"."sourceType" = $2
"person"."name" AS "person_name", VACUUM
"person"."birthDate" AS "person_birthDate", ANALYZE asset_faces,
"person"."thumbnailPath" AS "person_thumbnailPath", face_search,
"person"."faceAssetId" AS "person_faceAssetId", person
"person"."isHidden" AS "person_isHidden" REINDEX TABLE asset_faces
FROM REINDEX TABLE person
"person" "person"
INNER JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" -- PersonRepository.delete
INNER JOIN "assets" "asset" ON "asset"."id" = "face"."assetId" delete from "person"
AND ("asset"."deletedAt" IS NULL) where
WHERE "person"."id" in ($1)
"person"."ownerId" = $1
AND "asset"."isArchived" = false -- PersonRepository.deleteFaces
AND "person"."isHidden" = false delete from "asset_faces"
GROUP BY where
"person"."id" "asset_faces"."sourceType" = $1
HAVING VACUUM
"person"."name" != '' ANALYZE asset_faces,
OR COUNT("face"."assetId") >= $2 face_search,
ORDER BY person
"person"."isHidden" ASC, REINDEX TABLE asset_faces
NULLIF("person"."name", '') IS NULL ASC, REINDEX TABLE person
COUNT("face"."assetId") DESC,
NULLIF("person"."name", '') ASC NULLS LAST,
"person"."createdAt" ASC
LIMIT
11
OFFSET
10
-- PersonRepository.getAllWithoutFaces -- PersonRepository.getAllWithoutFaces
SELECT select
"person"."id" AS "person_id", "person".*
"person"."createdAt" AS "person_createdAt", from
"person"."updatedAt" AS "person_updatedAt", "person"
"person"."ownerId" AS "person_ownerId", left join "asset_faces" on "asset_faces"."personId" = "person"."id"
"person"."name" AS "person_name", group by
"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
"person"."id" "person"."id"
HAVING having
COUNT("face"."assetId") = 0 count("asset_faces"."assetId") = $1
-- PersonRepository.getFaces -- PersonRepository.getFaces
SELECT select
"AssetFaceEntity"."id" AS "AssetFaceEntity_id", "asset_faces".*,
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", (
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", select
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", to_json(obj)
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", from
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", (
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", select
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", "person".*
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", from
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", "person"
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", where
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", "person"."id" = "asset_faces"."personId"
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", ) as obj
"AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId", ) as "person"
"AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name", from
"AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate", "asset_faces"
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath", where
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId", "asset_faces"."assetId" = $1
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden" order by
FROM "asset_faces"."boundingBoxX1" asc
"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
-- PersonRepository.getFaceById -- PersonRepository.getFaceById
SELECT DISTINCT select
"distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id" "asset_faces".*,
FROM
( (
SELECT select
"AssetFaceEntity"."id" AS "AssetFaceEntity_id", to_json(obj)
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", from
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", (
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", select
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", "person".*
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", from
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "person"
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", where
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", "person"."id" = "asset_faces"."personId"
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", ) as obj
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", ) as "person"
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", from
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", "asset_faces"
"AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId", where
"AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name", "asset_faces"."id" = $1
"AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate",
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden"
FROM
"asset_faces" "AssetFaceEntity"
LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
WHERE
(("AssetFaceEntity"."id" = $1))
) "distinctAlias"
ORDER BY
"AssetFaceEntity_id" ASC
LIMIT
1
-- PersonRepository.getFaceByIdWithAssets -- PersonRepository.getFaceByIdWithAssets
SELECT DISTINCT select
"distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id" "asset_faces".*,
FROM
( (
SELECT select
"AssetFaceEntity"."id" AS "AssetFaceEntity_id", to_json(obj)
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", from
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", (
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", select
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", "person".*
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", from
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "person"
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", where
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", "person"."id" = "asset_faces"."personId"
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", ) as obj
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", ) as "person",
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", (
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", select
"AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId", to_json(obj)
"AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name", from
"AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate", (
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath", select
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId", "assets".*
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden", from
"AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id", "assets"
"AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId", where
"AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId", "assets"."id" = "asset_faces"."assetId"
"AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId", ) as obj
"AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId", ) as "asset"
"AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type", from
"AssetFaceEntity__AssetFaceEntity_asset"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status", "asset_faces"
"AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath", where
"AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash", "asset_faces"."id" = $1
"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
-- PersonRepository.reassignFace -- PersonRepository.reassignFace
UPDATE "asset_faces" update "asset_faces"
SET set
"personId" = $1 "personId" = $1
WHERE where
"id" = $2 "asset_faces"."id" = $2
-- PersonRepository.getByName -- PersonRepository.getByName
SELECT select
"person"."id" AS "person_id", "person".*
"person"."createdAt" AS "person_createdAt", from
"person"."updatedAt" AS "person_updatedAt", "person"
"person"."ownerId" AS "person_ownerId", where
"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
( (
( "person"."ownerId" = $1
( and (
("AssetFaceEntity"."assetId" = $1) lower("person"."name") like $2
AND ("AssetFaceEntity"."personId" = $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 -- PersonRepository.getRandomFace
SELECT select
"AssetFaceEntity"."id" AS "AssetFaceEntity_id", "asset_faces".*
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", from
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", "asset_faces"
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", where
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", "asset_faces"."personId" = $1
"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
-- PersonRepository.getLatestFaceDate -- PersonRepository.getLatestFaceDate
SELECT select
MAX("jobStatus"."facesRecognizedAt")::text AS "latestDate" max("asset_job_status"."facesRecognizedAt")::text as "latestDate"
FROM from
"asset_job_status" "jobStatus" "asset_job_status"

View File

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

View File

@ -1,13 +1,13 @@
import { Injectable } from '@nestjs/common'; 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 _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFaces, DB, FaceSearch, Person } from 'src/db';
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity'; 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 { PersonEntity } from 'src/entities/person.entity';
import { PaginationMode, SourceType } from 'src/enum'; import { SourceType } from 'src/enum';
import { import {
AssetFaceId, AssetFaceId,
DeleteFacesOptions, DeleteFacesOptions,
@ -17,332 +17,418 @@ import {
PersonNameSearchOptions, PersonNameSearchOptions,
PersonSearchOptions, PersonSearchOptions,
PersonStatistics, PersonStatistics,
SelectFaceOptions,
UnassignFacesOptions, UnassignFacesOptions,
UpdateFacesData, UpdateFacesData,
} from 'src/interfaces/person.interface'; } from 'src/interfaces/person.interface';
import { Paginated, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; import { mapUpsertColumns } from 'src/utils/database';
import { DataSource, FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm'; 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() @Injectable()
export class PersonRepository implements IPersonRepository { export class PersonRepository implements IPersonRepository {
constructor( constructor(@InjectKysely() private db: Kysely<DB>) {}
@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>,
) {}
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] }) @GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> { async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> {
const result = await this.assetFaceRepository const result = await this.db
.createQueryBuilder() .updateTable('asset_faces')
.update()
.set({ personId: newPersonId }) .set({ personId: newPersonId })
.where(_.omitBy({ personId: oldPersonId, id: faceIds ? In(faceIds) : undefined }, _.isUndefined)) .$if(!!oldPersonId, (qb) => qb.where('asset_faces.personId', '=', oldPersonId!))
.execute(); .$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> { async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
await this.assetFaceRepository await this.db
.createQueryBuilder() .updateTable('asset_faces')
.update()
.set({ personId: null }) .set({ personId: null })
.where({ sourceType }) .where('asset_faces.sourceType', '=', sourceType)
.execute(); .execute();
await this.vacuum({ reindexVectors: false }); await this.vacuum({ reindexVectors: false });
} }
@GenerateSql({ params: [[{ id: DummyValue.UUID }]] })
async delete(entities: PersonEntity[]): Promise<void> { 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> { async deleteFaces({ sourceType }: DeleteFacesOptions): Promise<void> {
await this.assetFaceRepository await this.db.deleteFrom('asset_faces').where('asset_faces.sourceType', '=', sourceType).execute();
.createQueryBuilder('asset_faces')
.delete()
.andWhere('sourceType = :sourceType', { sourceType })
.execute();
await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING }); await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING });
} }
getAllFaces( getAllFaces(options: Partial<AssetFaceEntity> = {}): AsyncIterableIterator<AssetFaceEntity> {
pagination: PaginationOptions, return this.db
options: FindManyOptions<AssetFaceEntity> = {}, .selectFrom('asset_faces')
): Paginated<AssetFaceEntity> { .selectAll('asset_faces')
return paginate(this.assetFaceRepository, pagination, options); .$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> { getAll(options: Partial<PersonEntity> = {}): AsyncIterableIterator<PersonEntity> {
return paginate(this.personRepository, pagination, options); 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( async getAllForUser(
pagination: PaginationOptions, pagination: PaginationOptions,
userId: string, userId: string,
options?: PersonSearchOptions, options?: PersonSearchOptions,
): Paginated<PersonEntity> { ): Paginated<PersonEntity> {
const queryBuilder = this.personRepository const items = (await this.db
.createQueryBuilder('person') .selectFrom('person')
.innerJoin('person.faces', 'face') .selectAll('person')
.where('person.ownerId = :userId', { userId }) .innerJoin('asset_faces', 'asset_faces.personId', 'person.id')
.innerJoin('face.asset', 'asset') .innerJoin('assets', (join) =>
.andWhere('asset.isArchived = false') join
.orderBy('person.isHidden', 'ASC') .onRef('asset_faces.assetId', '=', 'assets.id')
.addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC') .on('assets.isArchived', '=', false)
.addOrderBy('COUNT(face.assetId)', 'DESC') .on('assets.deletedAt', 'is', null),
.addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST') )
.addOrderBy('person.createdAt') .where('person.ownerId', '=', userId)
.having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 }) .orderBy('person.isHidden', 'asc')
.groupBy('person.id'); .orderBy(sql`NULLIF(person.name, '') is null`, 'asc')
if (options?.closestFaceAssetId) { .orderBy((eb) => eb.fn.count('asset_faces.assetId'), 'desc')
const innerQueryBuilder = this.faceSearchRepository .orderBy(sql`NULLIF(person.name, '')`, sql`asc nulls last`)
.createQueryBuilder('face_search') .orderBy('person.createdAt')
.select('embedding', 'embedding') .having((eb) =>
.where('"face_search"."faceId" = "person"."faceAssetId"'); eb.or([
const faceSelectQueryBuilder = this.faceSearchRepository eb('person.name', '!=', ''),
.createQueryBuilder('face_search') eb((innerEb) => innerEb.fn.count('asset_faces.assetId'), '>=', options?.minimumFaceCount || 1),
.select('embedding', 'embedding') ]),
.where('"face_search"."faceId" = :faceId', { faceId: options.closestFaceAssetId }); )
queryBuilder .groupBy('person.id')
.orderBy('(' + innerQueryBuilder.getQuery() + ') <=> (' + faceSelectQueryBuilder.getQuery() + ')') .$if(!!options?.closestFaceAssetId, (qb) =>
.setParameters(faceSelectQueryBuilder.getParameters()); 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 { items, hasNextPage: false };
}
return paginatedBuilder(queryBuilder, {
mode: PaginationMode.LIMIT_OFFSET,
...pagination,
});
} }
@GenerateSql() @GenerateSql()
getAllWithoutFaces(): Promise<PersonEntity[]> { getAllWithoutFaces(): Promise<PersonEntity[]> {
return this.personRepository return this.db
.createQueryBuilder('person') .selectFrom('person')
.leftJoin('person.faces', 'face') .selectAll('person')
.having('COUNT(face.assetId) = 0') .leftJoin('asset_faces', 'asset_faces.personId', 'person.id')
.having((eb) => eb.fn.count('asset_faces.assetId'), '=', 0)
.groupBy('person.id') .groupBy('person.id')
.withDeleted() .execute() as Promise<PersonEntity[]>;
.getMany();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getFaces(assetId: string): Promise<AssetFaceEntity[]> { getFaces(assetId: string): Promise<AssetFaceEntity[]> {
return this.assetFaceRepository.find({ return this.db
where: { assetId }, .selectFrom('asset_faces')
relations: { .selectAll('asset_faces')
person: true, .select(withPerson)
}, .where('asset_faces.assetId', '=', assetId)
order: { .orderBy('asset_faces.boundingBoxX1', 'asc')
boundingBoxX1: 'ASC', .execute() as Promise<AssetFaceEntity[]>;
},
});
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getFaceById(id: string): Promise<AssetFaceEntity> { getFaceById(id: string): Promise<AssetFaceEntity> {
// TODO return null instead of find or fail // TODO return null instead of find or fail
return this.assetFaceRepository.findOneOrFail({ return this.db
where: { id }, .selectFrom('asset_faces')
relations: { .selectAll('asset_faces')
person: true, .select(withPerson)
}, .where('asset_faces.id', '=', id)
}); .executeTakeFirstOrThrow() as Promise<AssetFaceEntity>;
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getFaceByIdWithAssets( getFaceByIdWithAssets(
id: string, id: string,
relations: FindOptionsRelations<AssetFaceEntity>, relations?: FindOptionsRelations<AssetFaceEntity>,
select: FindOptionsSelect<AssetFaceEntity>, select?: SelectFaceOptions,
): Promise<AssetFaceEntity | null> { ): Promise<AssetFaceEntity | null> {
return this.assetFaceRepository.findOne( return (this.db
_.omitBy( .selectFrom('asset_faces')
{ .$if(!!select, (qb) =>
where: { id }, qb.select(
relations: { Object.keys(
...relations, _.omitBy({ ...select!, faceSearch: undefined, asset: undefined }, _.isUndefined),
person: true, ) as SelectExpression<DB, 'asset_faces'>[],
asset: true, ),
}, )
select, .$if(!select, (qb) => qb.selectAll('asset_faces'))
}, .select(withPerson)
_.isUndefined, .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] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> { async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
const result = await this.assetFaceRepository const result = await this.db
.createQueryBuilder() .updateTable('asset_faces')
.update()
.set({ personId: newPersonId }) .set({ personId: newPersonId })
.where({ id: assetFaceId }) .where('asset_faces.id', '=', assetFaceId)
.execute(); .executeTakeFirst();
return result.affected ?? 0; return Number(result.numChangedRows) ?? 0;
} }
getById(personId: string): Promise<PersonEntity | null> { 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 }] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise<PersonEntity[]> { getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise<PersonEntity[]> {
const queryBuilder = this.personRepository return this.db
.createQueryBuilder('person') .selectFrom('person')
.where( .selectAll('person')
'person.ownerId = :userId AND (LOWER(person.name) LIKE :nameStart OR LOWER(person.name) LIKE :nameAnywhere)', .where((eb) =>
{ userId, nameStart: `${personName.toLowerCase()}%`, nameAnywhere: `% ${personName.toLowerCase()}%` }, 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); .limit(1000)
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
if (!withHidden) { .execute() as Promise<PersonEntity[]>;
queryBuilder.andWhere('person.isHidden = false');
}
return queryBuilder.getMany();
} }
@GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] }) @GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] })
getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise<PersonNameResponse[]> { getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise<PersonNameResponse[]> {
const queryBuilder = this.personRepository return this.db
.createQueryBuilder('person') .selectFrom('person')
.select(['person.id', 'person.name']) .select(['person.id', 'person.name'])
.distinctOn(['lower(person.name)']) .distinctOn((eb) => eb.fn('lower', ['person.name']))
.where(`person.ownerId = :userId AND person.name != ''`, { userId }); .where((eb) => eb.and([eb('person.ownerId', '=', userId), eb('person.name', '!=', '')]))
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
if (!withHidden) { .execute();
queryBuilder.andWhere('person.isHidden = false');
}
return queryBuilder.getMany();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getStatistics(personId: string): Promise<PersonStatistics> { async getStatistics(personId: string): Promise<PersonStatistics> {
const items = await this.assetFaceRepository const result = await this.db
.createQueryBuilder('face') .selectFrom('asset_faces')
.leftJoin('face.asset', 'asset') .leftJoin('assets', (join) =>
.where('face.personId = :personId', { personId }) join
.andWhere('asset.isArchived = false') .onRef('assets.id', '=', 'asset_faces.assetId')
.andWhere('asset.deletedAt IS NULL') .on('asset_faces.personId', '=', personId)
.andWhere('asset.livePhotoVideoId IS NULL') .on('assets.isArchived', '=', false)
.select('COUNT(DISTINCT(asset.id))', 'count') .on('assets.deletedAt', 'is', null)
.getRawOne(); .on('assets.livePhotoVideoId', 'is', null),
)
.select((eb) => eb.fn.count(eb.fn('distinct', ['assets.id'])).as('count'))
.executeTakeFirst();
return { return {
assets: items.count ?? 0, assets: result ? Number(result.count) : 0,
}; };
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getNumberOfPeople(userId: string): Promise<PeopleStatistics> { async getNumberOfPeople(userId: string): Promise<PeopleStatistics> {
const items = await this.personRepository const items = await this.db
.createQueryBuilder('person') .selectFrom('person')
.innerJoin('person.faces', 'face') .innerJoin('asset_faces', 'asset_faces.personId', 'person.id')
.where('person.ownerId = :userId', { userId }) .where('person.ownerId', '=', userId)
.innerJoin('face.asset', 'asset') .innerJoin('assets', (join) =>
.andWhere('asset.isArchived = false') join
.select('COUNT(DISTINCT(person.id))', 'total') .onRef('assets.id', '=', 'asset_faces.assetId')
.addSelect('COUNT(DISTINCT(person.id)) FILTER (WHERE person.isHidden = true)', 'hidden') .on('assets.deletedAt', 'is', null)
.getRawOne(); .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) { if (items == undefined) {
return { total: 0, hidden: 0 }; return { total: 0, hidden: 0 };
} }
const result: PeopleStatistics = { return {
total: items.total ?? 0, total: Number(items.total),
hidden: items.hidden ?? 0, hidden: Number(items.hidden),
}; };
return result;
} }
create(person: Partial<PersonEntity>): Promise<PersonEntity> { create(person: Insertable<Person>): Promise<PersonEntity> {
return this.save(person); return this.db.insertInto('person').values(person).returningAll().executeTakeFirst() as Promise<PersonEntity>;
} }
async createAll(people: Partial<PersonEntity>[]): Promise<string[]> { async createAll(people: Insertable<Person>[]): Promise<string[]> {
const results = await this.personRepository.save(people); const results = await this.db.insertInto('person').values(people).returningAll().execute();
return results.map((person) => person.id); return results.map(({ id }) => id);
} }
@GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] })
async refreshFaces( async refreshFaces(
facesToAdd: Partial<AssetFaceEntity>[], facesToAdd: (Insertable<AssetFaces> & { assetId: string })[],
faceIdsToRemove: string[], faceIdsToRemove: string[],
embeddingsToAdd?: FaceSearchEntity[], embeddingsToAdd?: Insertable<FaceSearch>[],
): Promise<void> { ): Promise<void> {
const query = this.faceSearchRepository.createQueryBuilder().select('1').fromDummy(); let query = this.db;
if (facesToAdd.length > 0) { if (facesToAdd.length > 0) {
const insertCte = this.assetFaceRepository.createQueryBuilder().insert().values(facesToAdd); (query as any) = query.with('added', (db) => db.insertInto('asset_faces').values(facesToAdd));
query.addCommonTableExpression(insertCte, 'added');
} }
if (faceIdsToRemove.length > 0) { if (faceIdsToRemove.length > 0) {
const deleteCte = this.assetFaceRepository (query as any) = query.with('removed', (db) =>
.createQueryBuilder() db.deleteFrom('asset_faces').where('asset_faces.id', '=', (eb) => eb.fn.any(eb.val(faceIdsToRemove))),
.delete() );
.where('id = any(:faceIdsToRemove)', { faceIdsToRemove });
query.addCommonTableExpression(deleteCte, 'deleted');
} }
if (embeddingsToAdd?.length) { if (embeddingsToAdd?.length) {
const embeddingCte = this.faceSearchRepository.createQueryBuilder().insert().values(embeddingsToAdd).orIgnore(); (query as any) = query.with('added_embeddings', (db) => db.insertInto('face_search').values(embeddingsToAdd));
query.addCommonTableExpression(embeddingCte, 'embeddings');
query.getQuery(); // typeorm mixes up parameters without this
} }
await query.execute(); await query.selectFrom(sql`(select 1)`.as('dummy')).execute();
} }
async update(person: Partial<PersonEntity>): Promise<PersonEntity> { async update(person: Partial<PersonEntity> & { id: string }): Promise<PersonEntity> {
return this.save(person); return this.db
.updateTable('person')
.set(person)
.where('person.id', '=', person.id)
.returningAll()
.executeTakeFirstOrThrow() as Promise<PersonEntity>;
} }
async updateAll(people: Partial<PersonEntity>[]): Promise<void> { async updateAll(people: Insertable<Person>[]): Promise<void> {
await this.personRepository.save(people); 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 }]] }) @GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
@ChunkedArray() @ChunkedArray()
async getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> { getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> {
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<AssetFaceEntity[]>;
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getRandomFace(personId: string): Promise<AssetFaceEntity | null> { getRandomFace(personId: string): Promise<AssetFaceEntity | null> {
return this.assetFaceRepository.findOneBy({ personId }); return (this.db
.selectFrom('asset_faces')
.selectAll('asset_faces')
.where('asset_faces.personId', '=', personId)
.executeTakeFirst() ?? null) as Promise<AssetFaceEntity | null>;
} }
@GenerateSql() @GenerateSql()
async getLatestFaceDate(): Promise<string | undefined> { async getLatestFaceDate(): Promise<string | undefined> {
const result: { latestDate?: string } | undefined = await this.jobStatusRepository const result = (await this.db
.createQueryBuilder('jobStatus') .selectFrom('asset_job_status')
.select('MAX(jobStatus.facesRecognizedAt)::text', 'latestDate') .select((eb) => sql`${eb.fn.max('asset_job_status.facesRecognizedAt')}::text`.as('latestDate'))
.getRawOne(); .executeTakeFirst()) as { latestDate: string } | undefined;
return result?.latestDate; 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> { private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise<void> {
await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search, person'); await sql`VACUUM ANALYZE asset_faces, face_search, person`.execute(this.db);
await this.assetFaceRepository.query('REINDEX TABLE asset_faces'); await sql`REINDEX TABLE asset_faces`.execute(this.db);
await this.assetFaceRepository.query('REINDEX TABLE person'); await sql`REINDEX TABLE person`.execute(this.db);
if (reindexVectors) { 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, SearchPaginationOptions,
SmartSearchOptions, SmartSearchOptions,
} from 'src/interfaces/search.interface'; } 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 { Paginated } from 'src/utils/pagination';
import { isValidInteger } from 'src/validation'; import { isValidInteger } from 'src/validation';
@ -82,7 +82,7 @@ export class SearchRepository implements ISearchRepository {
{ page: 1, size: 200 }, { page: 1, size: 200 },
{ {
takenAfter: DummyValue.DATE, takenAfter: DummyValue.DATE,
embedding: Array.from({ length: 512 }, Math.random), embedding: DummyValue.VECTOR,
lensModel: DummyValue.STRING, lensModel: DummyValue.STRING,
withStacked: true, withStacked: true,
isFavorite: true, isFavorite: true,
@ -97,7 +97,7 @@ export class SearchRepository implements ISearchRepository {
const items = (await searchAssetBuilder(this.db, options) const items = (await searchAssetBuilder(this.db, options)
.innerJoin('smart_search', 'assets.id', 'smart_search.assetId') .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) .limit(pagination.size + 1)
.offset((pagination.page - 1) * pagination.size) .offset((pagination.page - 1) * pagination.size)
.execute()) as any as AssetEntity[]; .execute()) as any as AssetEntity[];
@ -111,7 +111,7 @@ export class SearchRepository implements ISearchRepository {
params: [ params: [
{ {
assetId: DummyValue.UUID, assetId: DummyValue.UUID,
embedding: Array.from({ length: 512 }, Math.random), embedding: DummyValue.VECTOR,
maxDistance: 0.6, maxDistance: 0.6,
type: AssetType.IMAGE, type: AssetType.IMAGE,
userIds: [DummyValue.UUID], userIds: [DummyValue.UUID],
@ -119,7 +119,6 @@ export class SearchRepository implements ISearchRepository {
], ],
}) })
searchDuplicates({ assetId, embedding, maxDistance, type, userIds }: AssetDuplicateSearch) { searchDuplicates({ assetId, embedding, maxDistance, type, userIds }: AssetDuplicateSearch) {
const vector = asVector(embedding);
return this.db return this.db
.with('cte', (qb) => .with('cte', (qb) =>
qb qb
@ -127,7 +126,7 @@ export class SearchRepository implements ISearchRepository {
.select([ .select([
'assets.id as assetId', 'assets.id as assetId',
'assets.duplicateId', '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') .innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
.where('assets.ownerId', '=', anyUuid(userIds)) .where('assets.ownerId', '=', anyUuid(userIds))
@ -135,7 +134,7 @@ export class SearchRepository implements ISearchRepository {
.where('assets.isVisible', '=', true) .where('assets.isVisible', '=', true)
.where('assets.type', '=', type) .where('assets.type', '=', type)
.where('assets.id', '!=', asUuid(assetId)) .where('assets.id', '!=', asUuid(assetId))
.orderBy(sql`smart_search.embedding <=> ${vector}`) .orderBy(sql`smart_search.embedding <=> ${embedding}`)
.limit(64), .limit(64),
) )
.selectFrom('cte') .selectFrom('cte')
@ -148,7 +147,7 @@ export class SearchRepository implements ISearchRepository {
params: [ params: [
{ {
userIds: [DummyValue.UUID], userIds: [DummyValue.UUID],
embedding: Array.from({ length: 512 }, Math.random), embedding: DummyValue.VECTOR,
numResults: 10, numResults: 10,
maxDistance: 0.6, maxDistance: 0.6,
}, },
@ -159,7 +158,6 @@ export class SearchRepository implements ISearchRepository {
throw new Error(`Invalid value for 'numResults': ${numResults}`); throw new Error(`Invalid value for 'numResults': ${numResults}`);
} }
const vector = asVector(embedding);
return this.db return this.db
.with('cte', (qb) => .with('cte', (qb) =>
qb qb
@ -167,14 +165,14 @@ export class SearchRepository implements ISearchRepository {
.select([ .select([
'asset_faces.id', 'asset_faces.id',
'asset_faces.personId', '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('assets', 'assets.id', 'asset_faces.assetId')
.innerJoin('face_search', 'face_search.faceId', 'asset_faces.id') .innerJoin('face_search', 'face_search.faceId', 'asset_faces.id')
.where('assets.ownerId', '=', anyUuid(userIds)) .where('assets.ownerId', '=', anyUuid(userIds))
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
.$if(!!hasPerson, (qb) => qb.where('asset_faces.personId', 'is not', 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), .limit(numResults),
) )
.selectFrom('cte') .selectFrom('cte')
@ -258,12 +256,11 @@ export class SearchRepository implements ISearchRepository {
.execute() as any as Promise<AssetEntity[]>; .execute() as any as Promise<AssetEntity[]>;
} }
async upsert(assetId: string, embedding: number[]): Promise<void> { async upsert(assetId: string, embedding: string): Promise<void> {
const vector = asVector(embedding);
await this.db await this.db
.insertInto('smart_search') .insertInto('smart_search')
.values({ assetId: asUuid(assetId), embedding: vector } as any) .values({ assetId: asUuid(assetId), embedding } as any)
.onConflict((oc) => oc.column('assetId').doUpdateSet({ embedding: vector } as any)) .onConflict((oc) => oc.column('assetId').doUpdateSet({ embedding } as any))
.execute(); .execute();
} }

View File

@ -201,21 +201,22 @@ export class AuditService extends BaseService {
} }
} }
const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => let peopleCount = 0;
this.personRepository.getAll(pagination), for await (const { id, thumbnailPath } of this.personRepository.getAll()) {
); track(thumbnailPath);
for await (const people of personPagination) { const entity = { entityId: id, entityType: PathEntityType.PERSON };
for (const { id, thumbnailPath } of people) { if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) {
track(thumbnailPath); orphans.push({ ...entity, pathType: PersonPathType.FACE, pathValue: 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[] = []; const extras: string[] = [];
for (const file of allFiles) { for (const file of allFiles) {
extras.push(file); extras.push(file);

View File

@ -25,7 +25,7 @@ import { assetStub } from 'test/fixtures/asset.stub';
import { faceStub } from 'test/fixtures/face.stub'; import { faceStub } from 'test/fixtures/face.stub';
import { probeStub } from 'test/fixtures/media.stub'; import { probeStub } from 'test/fixtures/media.stub';
import { personStub } from 'test/fixtures/person.stub'; import { personStub } from 'test/fixtures/person.stub';
import { newTestService } from 'test/utils'; import { makeStream, newTestService } from 'test/utils';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(MediaService.name, () => { describe(MediaService.name, () => {
@ -55,10 +55,8 @@ describe(MediaService.name, () => {
items: [assetStub.image], items: [assetStub.image],
hasNextPage: false, hasNextPage: false,
}); });
personMock.getAll.mockResolvedValue({
items: [personStub.newThumbnail], personMock.getAll.mockReturnValue(makeStream([personStub.newThumbnail]));
hasNextPage: false,
});
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
await sut.handleQueueGenerateThumbnails({ force: true }); 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([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.GENERATE_PERSON_THUMBNAIL, name: JobName.GENERATE_PERSON_THUMBNAIL,
@ -86,10 +84,7 @@ describe(MediaService.name, () => {
items: [assetStub.trashed], items: [assetStub.trashed],
hasNextPage: false, hasNextPage: false,
}); });
personMock.getAll.mockResolvedValue({ personMock.getAll.mockReturnValue(makeStream());
items: [],
hasNextPage: false,
});
await sut.handleQueueGenerateThumbnails({ force: true }); await sut.handleQueueGenerateThumbnails({ force: true });
@ -111,10 +106,7 @@ describe(MediaService.name, () => {
items: [assetStub.archived], items: [assetStub.archived],
hasNextPage: false, hasNextPage: false,
}); });
personMock.getAll.mockResolvedValue({ personMock.getAll.mockReturnValue(makeStream());
items: [],
hasNextPage: false,
});
await sut.handleQueueGenerateThumbnails({ force: true }); await sut.handleQueueGenerateThumbnails({ force: true });
@ -136,10 +128,7 @@ describe(MediaService.name, () => {
items: [assetStub.image], items: [assetStub.image],
hasNextPage: false, hasNextPage: false,
}); });
personMock.getAll.mockResolvedValue({ personMock.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail]));
items: [personStub.noThumbnail, personStub.noThumbnail],
hasNextPage: false,
});
personMock.getRandomFace.mockResolvedValueOnce(faceStub.face1); personMock.getRandomFace.mockResolvedValueOnce(faceStub.face1);
await sut.handleQueueGenerateThumbnails({ force: false }); await sut.handleQueueGenerateThumbnails({ force: false });
@ -147,7 +136,7 @@ describe(MediaService.name, () => {
expect(assetMock.getAll).not.toHaveBeenCalled(); expect(assetMock.getAll).not.toHaveBeenCalled();
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); 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.getRandomFace).toHaveBeenCalled();
expect(personMock.update).toHaveBeenCalledTimes(1); expect(personMock.update).toHaveBeenCalledTimes(1);
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
@ -165,11 +154,7 @@ describe(MediaService.name, () => {
items: [assetStub.noResizePath], items: [assetStub.noResizePath],
hasNextPage: false, hasNextPage: false,
}); });
personMock.getAll.mockResolvedValue({ personMock.getAll.mockReturnValue(makeStream());
items: [],
hasNextPage: false,
});
await sut.handleQueueGenerateThumbnails({ force: false }); await sut.handleQueueGenerateThumbnails({ force: false });
expect(assetMock.getAll).not.toHaveBeenCalled(); 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 () => { it('should queue all assets with missing webp path', async () => {
@ -189,11 +174,7 @@ describe(MediaService.name, () => {
items: [assetStub.noWebpPath], items: [assetStub.noWebpPath],
hasNextPage: false, hasNextPage: false,
}); });
personMock.getAll.mockResolvedValue({ personMock.getAll.mockReturnValue(makeStream());
items: [],
hasNextPage: false,
});
await sut.handleQueueGenerateThumbnails({ force: false }); await sut.handleQueueGenerateThumbnails({ force: false });
expect(assetMock.getAll).not.toHaveBeenCalled(); 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 () => { it('should queue all assets with missing thumbhash', async () => {
@ -213,11 +194,7 @@ describe(MediaService.name, () => {
items: [assetStub.noThumbhash], items: [assetStub.noThumbhash],
hasNextPage: false, hasNextPage: false,
}); });
personMock.getAll.mockResolvedValue({ personMock.getAll.mockReturnValue(makeStream());
items: [],
hasNextPage: false,
});
await sut.handleQueueGenerateThumbnails({ force: false }); await sut.handleQueueGenerateThumbnails({ force: false });
expect(assetMock.getAll).not.toHaveBeenCalled(); 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 () => { it('should remove empty directories and queue jobs', async () => {
assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] });
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); 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); await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.SUCCESS);
@ -730,10 +707,7 @@ describe(MediaService.name, () => {
items: [assetStub.video], items: [assetStub.video],
hasNextPage: false, hasNextPage: false,
}); });
personMock.getAll.mockResolvedValue({ personMock.getAll.mockReturnValue(makeStream());
items: [],
hasNextPage: false,
});
await sut.handleQueueVideoConversion({ force: true }); await sut.handleQueueVideoConversion({ force: true });

View File

@ -72,23 +72,20 @@ export class MediaService extends BaseService {
} }
const jobs: JobItem[] = []; 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) { const people = this.personRepository.getAll(force ? undefined : { thumbnailPath: '' });
for (const person of people) {
if (!person.faceAssetId) {
const face = await this.personRepository.getRandomFace(person.id);
if (!face) {
continue;
}
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); await this.jobRepository.queueAll(jobs);
@ -114,16 +111,19 @@ export class MediaService extends BaseService {
); );
} }
const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => let jobs: { name: JobName.MIGRATE_PERSON; data: { id: string } }[] = [];
this.personRepository.getAll(pagination),
);
for await (const people of personPagination) { for await (const person of this.personRepository.getAll()) {
await this.jobRepository.queueAll( jobs.push({ name: JobName.MIGRATE_PERSON, data: { id: person.id } });
people.map((person) => ({ 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; 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([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.GENERATE_PERSON_THUMBNAIL, name: JobName.GENERATE_PERSON_THUMBNAIL,

View File

@ -509,11 +509,11 @@ export class MetadataService extends BaseService {
return; return;
} }
const facesToAdd: Partial<AssetFaceEntity>[] = []; const facesToAdd: (Partial<AssetFaceEntity> & { assetId: string })[] = [];
const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true }); const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true });
const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id])); const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id]));
const missing: Partial<PersonEntity>[] = []; const missing: (Partial<PersonEntity> & { ownerId: string })[] = [];
const missingWithFaceAsset: Partial<PersonEntity>[] = []; const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = [];
for (const region of tags.RegionInfo.RegionList) { for (const region of tags.RegionInfo.RegionList) {
if (!region.Name) { if (!region.Name) {
continue; continue;
@ -540,7 +540,7 @@ export class MetadataService extends BaseService {
facesToAdd.push(face); facesToAdd.push(face);
if (!existingNameMap.has(loweredName)) { if (!existingNameMap.has(loweredName)) {
missing.push({ id: personId, ownerId: asset.ownerId, name: region.Name }); 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) { 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) { 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 { personStub } from 'test/fixtures/person.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils'; import { makeStream, newTestService } from 'test/utils';
import { IsNull } from 'typeorm';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
const responseDto: PersonResponseDto = { const responseDto: PersonResponseDto = {
@ -46,7 +45,7 @@ const face = {
imageHeight: 500, imageHeight: 500,
imageWidth: 400, imageWidth: 400,
}; };
const faceSearch = { faceId, embedding: [1, 2, 3, 4] }; const faceSearch = { faceId, embedding: '[1, 2, 3, 4]' };
const detectFaceMock: DetectedFaces = { const detectFaceMock: DetectedFaces = {
faces: [ faces: [
{ {
@ -495,14 +494,8 @@ describe(PersonService.name, () => {
}); });
it('should delete existing people and faces if forced', async () => { it('should delete existing people and faces if forced', async () => {
personMock.getAll.mockResolvedValue({ personMock.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson]));
items: [faceStub.face1.person, personStub.randomPerson], personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
hasNextPage: false,
});
personMock.getAllFaces.mockResolvedValue({
items: [faceStub.face1],
hasNextPage: false,
});
assetMock.getAll.mockResolvedValue({ assetMock.getAll.mockResolvedValue({
items: [assetStub.image], items: [assetStub.image],
hasNextPage: false, hasNextPage: false,
@ -544,18 +537,12 @@ describe(PersonService.name, () => {
it('should queue missing assets', async () => { it('should queue missing assets', async () => {
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
personMock.getAllFaces.mockResolvedValue({ personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
items: [faceStub.face1],
hasNextPage: false,
});
personMock.getAllWithoutFaces.mockResolvedValue([]); personMock.getAllWithoutFaces.mockResolvedValue([]);
await sut.handleQueueRecognizeFaces({}); await sut.handleQueueRecognizeFaces({});
expect(personMock.getAllFaces).toHaveBeenCalledWith( expect(personMock.getAllFaces).toHaveBeenCalledWith({ personId: null, sourceType: SourceType.MACHINE_LEARNING });
{ skip: 0, take: 1000 },
{ where: { personId: IsNull(), sourceType: SourceType.MACHINE_LEARNING } },
);
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.FACIAL_RECOGNITION, name: JobName.FACIAL_RECOGNITION,
@ -569,19 +556,13 @@ describe(PersonService.name, () => {
it('should queue all assets', async () => { it('should queue all assets', async () => {
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
personMock.getAll.mockResolvedValue({ personMock.getAll.mockReturnValue(makeStream());
items: [], personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
hasNextPage: false,
});
personMock.getAllFaces.mockResolvedValue({
items: [faceStub.face1],
hasNextPage: false,
});
personMock.getAllWithoutFaces.mockResolvedValue([]); personMock.getAllWithoutFaces.mockResolvedValue([]);
await sut.handleQueueRecognizeFaces({ force: true }); await sut.handleQueueRecognizeFaces({ force: true });
expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, {}); expect(personMock.getAllFaces).toHaveBeenCalledWith(undefined);
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.FACIAL_RECOGNITION, 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 () => { it('should run nightly if new face has been added since last run', async () => {
personMock.getLatestFaceDate.mockResolvedValue(new Date().toISOString()); personMock.getLatestFaceDate.mockResolvedValue(new Date().toISOString());
personMock.getAllFaces.mockResolvedValue({ personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
items: [faceStub.face1],
hasNextPage: false,
});
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
personMock.getAll.mockResolvedValue({ personMock.getAll.mockReturnValue(makeStream());
items: [], personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
hasNextPage: false,
});
personMock.getAllFaces.mockResolvedValue({
items: [faceStub.face1],
hasNextPage: false,
});
personMock.getAllWithoutFaces.mockResolvedValue([]); personMock.getAllWithoutFaces.mockResolvedValue([]);
await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); await sut.handleQueueRecognizeFaces({ force: true, nightly: true });
expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE);
expect(personMock.getLatestFaceDate).toHaveBeenCalledOnce(); expect(personMock.getLatestFaceDate).toHaveBeenCalledOnce();
expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, {}); expect(personMock.getAllFaces).toHaveBeenCalledWith(undefined);
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.FACIAL_RECOGNITION, name: JobName.FACIAL_RECOGNITION,
@ -631,10 +603,7 @@ describe(PersonService.name, () => {
systemMock.get.mockResolvedValue({ lastRun: lastRun.toISOString() }); systemMock.get.mockResolvedValue({ lastRun: lastRun.toISOString() });
personMock.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString()); personMock.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString());
personMock.getAllFaces.mockResolvedValue({ personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
items: [faceStub.face1],
hasNextPage: false,
});
personMock.getAllWithoutFaces.mockResolvedValue([]); personMock.getAllWithoutFaces.mockResolvedValue([]);
await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); await sut.handleQueueRecognizeFaces({ force: true, nightly: true });
@ -648,15 +617,8 @@ describe(PersonService.name, () => {
it('should delete existing people if forced', async () => { it('should delete existing people if forced', async () => {
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
personMock.getAll.mockResolvedValue({ personMock.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson]));
items: [faceStub.face1.person, personStub.randomPerson], personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
hasNextPage: false,
});
personMock.getAllFaces.mockResolvedValue({
items: [faceStub.face1],
hasNextPage: false,
});
personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]);
await sut.handleQueueRecognizeFaces({ force: true }); 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 { mimeTypes } from 'src/utils/mime-types';
import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc'; import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
import { IsNull } from 'typeorm';
@Injectable() @Injectable()
export class PersonService extends BaseService { export class PersonService extends BaseService {
@ -306,7 +305,7 @@ export class PersonService extends BaseService {
); );
this.logger.debug(`${faces.length} faces detected in ${previewFile.path}`); 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 embeddings: FaceSearchEntity[] = [];
const mlFaceIds = new Set<string>(); const mlFaceIds = new Set<string>();
for (const face of asset.faces) { for (const face of asset.faces) {
@ -414,18 +413,22 @@ export class PersonService extends BaseService {
} }
const lastRun = new Date().toISOString(); const lastRun = new Date().toISOString();
const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => const facePagination = this.personRepository.getAllFaces(
this.personRepository.getAllFaces(pagination, { force ? undefined : { personId: null, sourceType: SourceType.MACHINE_LEARNING },
where: force ? undefined : { personId: IsNull(), sourceType: SourceType.MACHINE_LEARNING },
}),
); );
for await (const page of facePagination) { let jobs: { name: JobName.FACIAL_RECOGNITION; data: { id: string; deferred: false } }[] = [];
await this.jobRepository.queueAll( for await (const face of facePagination) {
page.map((face) => ({ name: JobName.FACIAL_RECOGNITION, data: { id: face.id, deferred: false } })), 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 }); await this.systemMetadataRepository.set(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun });
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
@ -441,7 +444,7 @@ export class PersonService extends BaseService {
const face = await this.personRepository.getFaceByIdWithAssets( const face = await this.personRepository.getFaceByIdWithAssets(
id, id,
{ person: true, asset: true, faceSearch: true }, { 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) { if (!face || !face.asset) {
this.logger.warn(`Face ${id} not found`); this.logger.warn(`Face ${id} not found`);

View File

@ -284,7 +284,7 @@ describe(SmartInfoService.name, () => {
}); });
it('should save the returned objects', async () => { 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); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS);
@ -293,7 +293,7 @@ describe(SmartInfoService.name, () => {
'/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }), 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 () => { it('should skip invisible assets', async () => {
@ -315,7 +315,7 @@ describe(SmartInfoService.name, () => {
}); });
it('should wait for database', async () => { 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); databaseMock.isBusy.mockReturnValue(true);
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS);
@ -326,7 +326,7 @@ describe(SmartInfoService.name, () => {
'/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }), 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 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. * Mainly for type debugging to make VS Code display a more useful tooltip.

View File

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

View File

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

View File

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