mirror of
https://github.com/immich-app/immich.git
synced 2025-04-21 15:36:26 +02:00
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:
parent
0c152366ec
commit
332a865ce6
29 changed files with 715 additions and 747 deletions
e2e/src/api/specs
machine-learning/app
server
src
decorators.ts
entities
interfaces
queries
repositories
services
audit.service.tsmedia.service.spec.tsmedia.service.tsmetadata.service.spec.tsmetadata.service.tsperson.service.spec.tsperson.service.tssmart-info.service.spec.ts
utils
test
|
@ -200,7 +200,7 @@ describe('/people', () => {
|
|||
expect(body).toMatchObject({
|
||||
id: expect.any(String),
|
||||
name: 'New Person',
|
||||
birthDate: '1990-01-01',
|
||||
birthDate: '1990-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -244,7 +244,7 @@ describe('/people', () => {
|
|||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ birthDate: '1990-01-01' });
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ birthDate: '1990-01-01' });
|
||||
expect(body).toMatchObject({ birthDate: '1990-01-01T00:00:00.000Z' });
|
||||
});
|
||||
|
||||
it('should clear a date of birth', async () => {
|
||||
|
|
|
@ -10,7 +10,7 @@ from tokenizers import Encoding, Tokenizer
|
|||
|
||||
from app.config import log
|
||||
from app.models.base import InferenceModel
|
||||
from app.models.transforms import clean_text
|
||||
from app.models.transforms import clean_text, serialize_np_array
|
||||
from app.schemas import ModelSession, ModelTask, ModelType
|
||||
|
||||
|
||||
|
@ -18,9 +18,9 @@ class BaseCLIPTextualEncoder(InferenceModel):
|
|||
depends = []
|
||||
identity = (ModelType.TEXTUAL, ModelTask.SEARCH)
|
||||
|
||||
def _predict(self, inputs: str, **kwargs: Any) -> NDArray[np.float32]:
|
||||
def _predict(self, inputs: str, **kwargs: Any) -> str:
|
||||
res: NDArray[np.float32] = self.session.run(None, self.tokenize(inputs))[0][0]
|
||||
return res
|
||||
return serialize_np_array(res)
|
||||
|
||||
def _load(self) -> ModelSession:
|
||||
session = super()._load()
|
||||
|
|
|
@ -10,7 +10,15 @@ from PIL import Image
|
|||
|
||||
from app.config import log
|
||||
from app.models.base import InferenceModel
|
||||
from app.models.transforms import crop_pil, decode_pil, get_pil_resampling, normalize, resize_pil, to_numpy
|
||||
from app.models.transforms import (
|
||||
crop_pil,
|
||||
decode_pil,
|
||||
get_pil_resampling,
|
||||
normalize,
|
||||
resize_pil,
|
||||
serialize_np_array,
|
||||
to_numpy,
|
||||
)
|
||||
from app.schemas import ModelSession, ModelTask, ModelType
|
||||
|
||||
|
||||
|
@ -18,10 +26,10 @@ class BaseCLIPVisualEncoder(InferenceModel):
|
|||
depends = []
|
||||
identity = (ModelType.VISUAL, ModelTask.SEARCH)
|
||||
|
||||
def _predict(self, inputs: Image.Image | bytes, **kwargs: Any) -> NDArray[np.float32]:
|
||||
def _predict(self, inputs: Image.Image | bytes, **kwargs: Any) -> str:
|
||||
image = decode_pil(inputs)
|
||||
res: NDArray[np.float32] = self.session.run(None, self.transform(image))[0][0]
|
||||
return res
|
||||
return serialize_np_array(res)
|
||||
|
||||
@abstractmethod
|
||||
def transform(self, image: Image.Image) -> dict[str, NDArray[np.float32]]:
|
||||
|
|
|
@ -12,7 +12,7 @@ from PIL import Image
|
|||
|
||||
from app.config import log, settings
|
||||
from app.models.base import InferenceModel
|
||||
from app.models.transforms import decode_cv2
|
||||
from app.models.transforms import decode_cv2, serialize_np_array
|
||||
from app.schemas import FaceDetectionOutput, FacialRecognitionOutput, ModelFormat, ModelSession, ModelTask, ModelType
|
||||
|
||||
|
||||
|
@ -61,7 +61,7 @@ class FaceRecognizer(InferenceModel):
|
|||
return [
|
||||
{
|
||||
"boundingBox": {"x1": x1, "y1": y1, "x2": x2, "y2": y2},
|
||||
"embedding": embedding,
|
||||
"embedding": serialize_np_array(embedding),
|
||||
"score": score,
|
||||
}
|
||||
for (x1, y1, x2, y2), embedding, score in zip(faces["boxes"], embeddings, faces["scores"])
|
||||
|
|
|
@ -4,6 +4,7 @@ from typing import IO
|
|||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import orjson
|
||||
from numpy.typing import NDArray
|
||||
from PIL import Image
|
||||
|
||||
|
@ -69,3 +70,9 @@ def clean_text(text: str, canonicalize: bool = False) -> str:
|
|||
if canonicalize:
|
||||
text = text.translate(_PUNCTUATION_TRANS).lower()
|
||||
return text
|
||||
|
||||
|
||||
# this allows the client to use the array as a string without deserializing only to serialize back to a string
|
||||
# TODO: use this in a less invasive way
|
||||
def serialize_np_array(arr: NDArray[np.float32]) -> str:
|
||||
return orjson.dumps(arr, option=orjson.OPT_SERIALIZE_NUMPY).decode()
|
||||
|
|
|
@ -79,7 +79,7 @@ class FaceDetectionOutput(TypedDict):
|
|||
|
||||
class DetectedFace(TypedDict):
|
||||
boundingBox: BoundingBox
|
||||
embedding: npt.NDArray[np.float32]
|
||||
embedding: str
|
||||
score: float
|
||||
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ from unittest import mock
|
|||
import cv2
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
import orjson
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
@ -346,11 +347,11 @@ class TestCLIP:
|
|||
mocked.run.return_value = [[self.embedding]]
|
||||
|
||||
clip_encoder = OpenClipVisualEncoder("ViT-B-32__openai", cache_dir="test_cache")
|
||||
embedding = clip_encoder.predict(pil_image)
|
||||
|
||||
assert isinstance(embedding, np.ndarray)
|
||||
assert embedding.shape[0] == clip_model_cfg["embed_dim"]
|
||||
assert embedding.dtype == np.float32
|
||||
embedding_str = clip_encoder.predict(pil_image)
|
||||
assert isinstance(embedding_str, str)
|
||||
embedding = orjson.loads(embedding_str)
|
||||
assert isinstance(embedding, list)
|
||||
assert len(embedding) == clip_model_cfg["embed_dim"]
|
||||
mocked.run.assert_called_once()
|
||||
|
||||
def test_basic_text(
|
||||
|
@ -368,11 +369,11 @@ class TestCLIP:
|
|||
mocker.patch("app.models.clip.textual.Tokenizer.from_file", autospec=True)
|
||||
|
||||
clip_encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache")
|
||||
embedding = clip_encoder.predict("test search query")
|
||||
|
||||
assert isinstance(embedding, np.ndarray)
|
||||
assert embedding.shape[0] == clip_model_cfg["embed_dim"]
|
||||
assert embedding.dtype == np.float32
|
||||
embedding_str = clip_encoder.predict("test search query")
|
||||
assert isinstance(embedding_str, str)
|
||||
embedding = orjson.loads(embedding_str)
|
||||
assert isinstance(embedding, list)
|
||||
assert len(embedding) == clip_model_cfg["embed_dim"]
|
||||
mocked.run.assert_called_once()
|
||||
|
||||
def test_openclip_tokenizer(
|
||||
|
@ -508,8 +509,11 @@ class TestFaceRecognition:
|
|||
assert isinstance(face.get("boundingBox"), dict)
|
||||
assert set(face["boundingBox"]) == {"x1", "y1", "x2", "y2"}
|
||||
assert all(isinstance(val, np.float32) for val in face["boundingBox"].values())
|
||||
assert isinstance(face.get("embedding"), np.ndarray)
|
||||
assert face["embedding"].shape[0] == 512
|
||||
embedding_str = face.get("embedding")
|
||||
assert isinstance(embedding_str, str)
|
||||
embedding = orjson.loads(embedding_str)
|
||||
assert isinstance(embedding, list)
|
||||
assert len(embedding) == 512
|
||||
assert isinstance(face.get("score", None), np.float32)
|
||||
|
||||
rec_model.get_feat.assert_called_once()
|
||||
|
@ -880,8 +884,10 @@ class TestPredictionEndpoints:
|
|||
actual = response.json()
|
||||
assert response.status_code == 200
|
||||
assert isinstance(actual, dict)
|
||||
assert isinstance(actual.get("clip", None), list)
|
||||
assert np.allclose(expected, actual["clip"])
|
||||
embedding = actual.get("clip", None)
|
||||
assert isinstance(embedding, str)
|
||||
parsed_embedding = orjson.loads(embedding)
|
||||
assert np.allclose(expected, parsed_embedding)
|
||||
|
||||
def test_clip_text_endpoint(self, responses: dict[str, Any], deployed_app: TestClient) -> None:
|
||||
expected = responses["clip"]["text"]
|
||||
|
@ -901,8 +907,10 @@ class TestPredictionEndpoints:
|
|||
actual = response.json()
|
||||
assert response.status_code == 200
|
||||
assert isinstance(actual, dict)
|
||||
assert isinstance(actual.get("clip", None), list)
|
||||
assert np.allclose(expected, actual["clip"])
|
||||
embedding = actual.get("clip", None)
|
||||
assert isinstance(embedding, str)
|
||||
parsed_embedding = orjson.loads(embedding)
|
||||
assert np.allclose(expected, parsed_embedding)
|
||||
|
||||
def test_face_endpoint(self, pil_image: Image.Image, responses: dict[str, Any], deployed_app: TestClient) -> None:
|
||||
byte_image = BytesIO()
|
||||
|
@ -933,5 +941,8 @@ class TestPredictionEndpoints:
|
|||
|
||||
for expected_face, actual_face in zip(responses["facial-recognition"], actual["facial-recognition"]):
|
||||
assert expected_face["boundingBox"] == actual_face["boundingBox"]
|
||||
assert np.allclose(expected_face["embedding"], actual_face["embedding"])
|
||||
embedding = actual_face.get("embedding", None)
|
||||
assert isinstance(embedding, str)
|
||||
parsed_embedding = orjson.loads(embedding)
|
||||
assert np.allclose(expected_face["embedding"], parsed_embedding)
|
||||
assert np.allclose(expected_face["score"], actual_face["score"])
|
||||
|
|
|
@ -100,6 +100,7 @@ export const DummyValue = {
|
|||
DATE: new Date(),
|
||||
TIME_BUCKET: '2024-01-01T00:00:00.000Z',
|
||||
BOOLEAN: true,
|
||||
VECTOR: '[1, 2, 3]',
|
||||
};
|
||||
|
||||
export const GENERATE_SQL_KEY = 'generate-sql-key';
|
||||
|
|
|
@ -11,10 +11,6 @@ export class FaceSearchEntity {
|
|||
faceId!: string;
|
||||
|
||||
@Index('face_index', { synchronize: false })
|
||||
@Column({
|
||||
type: 'float4',
|
||||
array: true,
|
||||
transformer: { from: JSON.parse, to: (v) => `[${v}]` },
|
||||
})
|
||||
embedding!: number[];
|
||||
@Column({ type: 'float4', array: true })
|
||||
embedding!: string;
|
||||
}
|
||||
|
|
|
@ -11,6 +11,6 @@ export class SmartSearchEntity {
|
|||
assetId!: string;
|
||||
|
||||
@Index('clip_index', { synchronize: false })
|
||||
@Column({ type: 'float4', array: true, transformer: { from: JSON.parse, to: (v) => v } })
|
||||
embedding!: number[];
|
||||
@Column({ type: 'float4', array: true })
|
||||
embedding!: string;
|
||||
}
|
||||
|
|
|
@ -28,10 +28,10 @@ export type FaceDetectionOptions = ModelOptions & { minScore: number };
|
|||
|
||||
type VisualResponse = { imageHeight: number; imageWidth: number };
|
||||
export type ClipVisualRequest = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: ModelOptions } };
|
||||
export type ClipVisualResponse = { [ModelTask.SEARCH]: number[] } & VisualResponse;
|
||||
export type ClipVisualResponse = { [ModelTask.SEARCH]: string } & VisualResponse;
|
||||
|
||||
export type ClipTextualRequest = { [ModelTask.SEARCH]: { [ModelType.TEXTUAL]: ModelOptions } };
|
||||
export type ClipTextualResponse = { [ModelTask.SEARCH]: number[] };
|
||||
export type ClipTextualResponse = { [ModelTask.SEARCH]: string };
|
||||
|
||||
export type FacialRecognitionRequest = {
|
||||
[ModelTask.FACIAL_RECOGNITION]: {
|
||||
|
@ -42,7 +42,7 @@ export type FacialRecognitionRequest = {
|
|||
|
||||
export interface Face {
|
||||
boundingBox: BoundingBox;
|
||||
embedding: number[];
|
||||
embedding: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
|
@ -51,7 +51,7 @@ export type DetectedFaces = { faces: Face[] } & VisualResponse;
|
|||
export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | FacialRecognitionRequest;
|
||||
|
||||
export interface IMachineLearningRepository {
|
||||
encodeImage(urls: string[], imagePath: string, config: ModelOptions): Promise<number[]>;
|
||||
encodeText(urls: string[], text: string, config: ModelOptions): Promise<number[]>;
|
||||
encodeImage(urls: string[], imagePath: string, config: ModelOptions): Promise<string>;
|
||||
encodeText(urls: string[], text: string, config: ModelOptions): Promise<string>;
|
||||
detectFaces(urls: string[], imagePath: string, config: FaceDetectionOptions): Promise<DetectedFaces>;
|
||||
}
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { Insertable, Updateable } from 'kysely';
|
||||
import { AssetFaces, FaceSearch, Person } from 'src/db';
|
||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||
import { FaceSearchEntity } from 'src/entities/face-search.entity';
|
||||
import { PersonEntity } from 'src/entities/person.entity';
|
||||
import { SourceType } from 'src/enum';
|
||||
import { Paginated, PaginationOptions } from 'src/utils/pagination';
|
||||
import { FindManyOptions, FindOptionsRelations, FindOptionsSelect } from 'typeorm';
|
||||
import { FindOptionsRelations } from 'typeorm';
|
||||
|
||||
export const IPersonRepository = 'IPersonRepository';
|
||||
|
||||
|
@ -48,29 +49,31 @@ export interface DeleteFacesOptions {
|
|||
|
||||
export type UnassignFacesOptions = DeleteFacesOptions;
|
||||
|
||||
export type SelectFaceOptions = Partial<{ [K in keyof AssetFaceEntity]: boolean }>;
|
||||
|
||||
export interface IPersonRepository {
|
||||
getAll(pagination: PaginationOptions, options?: FindManyOptions<PersonEntity>): Paginated<PersonEntity>;
|
||||
getAll(options?: Partial<PersonEntity>): AsyncIterableIterator<PersonEntity>;
|
||||
getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated<PersonEntity>;
|
||||
getAllWithoutFaces(): Promise<PersonEntity[]>;
|
||||
getById(personId: string): Promise<PersonEntity | null>;
|
||||
getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise<PersonEntity[]>;
|
||||
getDistinctNames(userId: string, options: PersonNameSearchOptions): Promise<PersonNameResponse[]>;
|
||||
|
||||
create(person: Partial<PersonEntity>): Promise<PersonEntity>;
|
||||
createAll(people: Partial<PersonEntity>[]): Promise<string[]>;
|
||||
create(person: Insertable<Person>): Promise<PersonEntity>;
|
||||
createAll(people: Insertable<Person>[]): Promise<string[]>;
|
||||
delete(entities: PersonEntity[]): Promise<void>;
|
||||
deleteFaces(options: DeleteFacesOptions): Promise<void>;
|
||||
refreshFaces(
|
||||
facesToAdd: Partial<AssetFaceEntity>[],
|
||||
facesToAdd: Insertable<AssetFaces>[],
|
||||
faceIdsToRemove: string[],
|
||||
embeddingsToAdd?: FaceSearchEntity[],
|
||||
embeddingsToAdd?: Insertable<FaceSearch>[],
|
||||
): Promise<void>;
|
||||
getAllFaces(pagination: PaginationOptions, options?: FindManyOptions<AssetFaceEntity>): Paginated<AssetFaceEntity>;
|
||||
getAllFaces(options?: Partial<AssetFaceEntity>): AsyncIterableIterator<AssetFaceEntity>;
|
||||
getFaceById(id: string): Promise<AssetFaceEntity>;
|
||||
getFaceByIdWithAssets(
|
||||
id: string,
|
||||
relations?: FindOptionsRelations<AssetFaceEntity>,
|
||||
select?: FindOptionsSelect<AssetFaceEntity>,
|
||||
select?: SelectFaceOptions,
|
||||
): Promise<AssetFaceEntity | null>;
|
||||
getFaces(assetId: string): Promise<AssetFaceEntity[]>;
|
||||
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
|
||||
|
@ -80,7 +83,7 @@ export interface IPersonRepository {
|
|||
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
|
||||
reassignFaces(data: UpdateFacesData): Promise<number>;
|
||||
unassignFaces(options: UnassignFacesOptions): Promise<void>;
|
||||
update(person: Partial<PersonEntity>): Promise<PersonEntity>;
|
||||
updateAll(people: Partial<PersonEntity>[]): Promise<void>;
|
||||
update(person: Updateable<Person> & { id: string }): Promise<PersonEntity>;
|
||||
updateAll(people: Insertable<Person>[]): Promise<void>;
|
||||
getLatestFaceDate(): Promise<string | undefined>;
|
||||
}
|
||||
|
|
|
@ -104,7 +104,7 @@ export interface SearchExifOptions {
|
|||
}
|
||||
|
||||
export interface SearchEmbeddingOptions {
|
||||
embedding: number[];
|
||||
embedding: string;
|
||||
userIds: string[];
|
||||
}
|
||||
|
||||
|
@ -152,7 +152,7 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
|
|||
|
||||
export interface AssetDuplicateSearch {
|
||||
assetId: string;
|
||||
embedding: number[];
|
||||
embedding: string;
|
||||
maxDistance: number;
|
||||
type: AssetType;
|
||||
userIds: string[];
|
||||
|
@ -192,7 +192,7 @@ export interface ISearchRepository {
|
|||
searchDuplicates(options: AssetDuplicateSearch): Promise<AssetDuplicateResult[]>;
|
||||
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
|
||||
searchRandom(size: number, options: AssetSearchOptions): Promise<AssetEntity[]>;
|
||||
upsert(assetId: string, embedding: number[]): Promise<void>;
|
||||
upsert(assetId: string, embedding: string): Promise<void>;
|
||||
searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]>;
|
||||
getAssetsByCity(userIds: string[]): Promise<AssetEntity[]>;
|
||||
deleteAllSearchEmbeddings(): Promise<void>;
|
||||
|
|
|
@ -1,342 +1,252 @@
|
|||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- PersonRepository.reassignFaces
|
||||
UPDATE "asset_faces"
|
||||
SET
|
||||
update "asset_faces"
|
||||
set
|
||||
"personId" = $1
|
||||
WHERE
|
||||
"personId" = $2
|
||||
where
|
||||
"asset_faces"."personId" = $2
|
||||
|
||||
-- PersonRepository.getAllForUser
|
||||
SELECT
|
||||
"person"."id" AS "person_id",
|
||||
"person"."createdAt" AS "person_createdAt",
|
||||
"person"."updatedAt" AS "person_updatedAt",
|
||||
"person"."ownerId" AS "person_ownerId",
|
||||
"person"."name" AS "person_name",
|
||||
"person"."birthDate" AS "person_birthDate",
|
||||
"person"."thumbnailPath" AS "person_thumbnailPath",
|
||||
"person"."faceAssetId" AS "person_faceAssetId",
|
||||
"person"."isHidden" AS "person_isHidden"
|
||||
FROM
|
||||
"person" "person"
|
||||
INNER JOIN "asset_faces" "face" ON "face"."personId" = "person"."id"
|
||||
INNER JOIN "assets" "asset" ON "asset"."id" = "face"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"person"."ownerId" = $1
|
||||
AND "asset"."isArchived" = false
|
||||
AND "person"."isHidden" = false
|
||||
GROUP BY
|
||||
"person"."id"
|
||||
HAVING
|
||||
"person"."name" != ''
|
||||
OR COUNT("face"."assetId") >= $2
|
||||
ORDER BY
|
||||
"person"."isHidden" ASC,
|
||||
NULLIF("person"."name", '') IS NULL ASC,
|
||||
COUNT("face"."assetId") DESC,
|
||||
NULLIF("person"."name", '') ASC NULLS LAST,
|
||||
"person"."createdAt" ASC
|
||||
LIMIT
|
||||
11
|
||||
OFFSET
|
||||
10
|
||||
-- PersonRepository.unassignFaces
|
||||
update "asset_faces"
|
||||
set
|
||||
"personId" = $1
|
||||
where
|
||||
"asset_faces"."sourceType" = $2
|
||||
VACUUM
|
||||
ANALYZE asset_faces,
|
||||
face_search,
|
||||
person
|
||||
REINDEX TABLE asset_faces
|
||||
REINDEX TABLE person
|
||||
|
||||
-- PersonRepository.delete
|
||||
delete from "person"
|
||||
where
|
||||
"person"."id" in ($1)
|
||||
|
||||
-- PersonRepository.deleteFaces
|
||||
delete from "asset_faces"
|
||||
where
|
||||
"asset_faces"."sourceType" = $1
|
||||
VACUUM
|
||||
ANALYZE asset_faces,
|
||||
face_search,
|
||||
person
|
||||
REINDEX TABLE asset_faces
|
||||
REINDEX TABLE person
|
||||
|
||||
-- PersonRepository.getAllWithoutFaces
|
||||
SELECT
|
||||
"person"."id" AS "person_id",
|
||||
"person"."createdAt" AS "person_createdAt",
|
||||
"person"."updatedAt" AS "person_updatedAt",
|
||||
"person"."ownerId" AS "person_ownerId",
|
||||
"person"."name" AS "person_name",
|
||||
"person"."birthDate" AS "person_birthDate",
|
||||
"person"."thumbnailPath" AS "person_thumbnailPath",
|
||||
"person"."faceAssetId" AS "person_faceAssetId",
|
||||
"person"."isHidden" AS "person_isHidden"
|
||||
FROM
|
||||
"person" "person"
|
||||
LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id"
|
||||
GROUP BY
|
||||
select
|
||||
"person".*
|
||||
from
|
||||
"person"
|
||||
left join "asset_faces" on "asset_faces"."personId" = "person"."id"
|
||||
group by
|
||||
"person"."id"
|
||||
HAVING
|
||||
COUNT("face"."assetId") = 0
|
||||
having
|
||||
count("asset_faces"."assetId") = $1
|
||||
|
||||
-- PersonRepository.getFaces
|
||||
SELECT
|
||||
"AssetFaceEntity"."id" AS "AssetFaceEntity_id",
|
||||
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
|
||||
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
|
||||
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
|
||||
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
|
||||
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
|
||||
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
||||
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
||||
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
||||
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden"
|
||||
FROM
|
||||
"asset_faces" "AssetFaceEntity"
|
||||
LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
|
||||
WHERE
|
||||
(("AssetFaceEntity"."assetId" = $1))
|
||||
ORDER BY
|
||||
"AssetFaceEntity"."boundingBoxX1" ASC
|
||||
select
|
||||
"asset_faces".*,
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"person".*
|
||||
from
|
||||
"person"
|
||||
where
|
||||
"person"."id" = "asset_faces"."personId"
|
||||
) as obj
|
||||
) as "person"
|
||||
from
|
||||
"asset_faces"
|
||||
where
|
||||
"asset_faces"."assetId" = $1
|
||||
order by
|
||||
"asset_faces"."boundingBoxX1" asc
|
||||
|
||||
-- PersonRepository.getFaceById
|
||||
SELECT DISTINCT
|
||||
"distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id"
|
||||
FROM
|
||||
select
|
||||
"asset_faces".*,
|
||||
(
|
||||
SELECT
|
||||
"AssetFaceEntity"."id" AS "AssetFaceEntity_id",
|
||||
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
|
||||
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
|
||||
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
|
||||
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
|
||||
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
|
||||
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
||||
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
||||
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
||||
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden"
|
||||
FROM
|
||||
"asset_faces" "AssetFaceEntity"
|
||||
LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
|
||||
WHERE
|
||||
(("AssetFaceEntity"."id" = $1))
|
||||
) "distinctAlias"
|
||||
ORDER BY
|
||||
"AssetFaceEntity_id" ASC
|
||||
LIMIT
|
||||
1
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"person".*
|
||||
from
|
||||
"person"
|
||||
where
|
||||
"person"."id" = "asset_faces"."personId"
|
||||
) as obj
|
||||
) as "person"
|
||||
from
|
||||
"asset_faces"
|
||||
where
|
||||
"asset_faces"."id" = $1
|
||||
|
||||
-- PersonRepository.getFaceByIdWithAssets
|
||||
SELECT DISTINCT
|
||||
"distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id"
|
||||
FROM
|
||||
select
|
||||
"asset_faces".*,
|
||||
(
|
||||
SELECT
|
||||
"AssetFaceEntity"."id" AS "AssetFaceEntity_id",
|
||||
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
|
||||
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
|
||||
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
|
||||
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
|
||||
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
|
||||
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
||||
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
||||
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
||||
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
|
||||
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_asset_createdAt",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_updatedAt",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."deletedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_deletedAt",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."fileCreatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileCreatedAt",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."localDateTime" AS "AssetFaceEntity__AssetFaceEntity_asset_localDateTime",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."fileModifiedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileModifiedAt",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."isFavorite" AS "AssetFaceEntity__AssetFaceEntity_asset_isFavorite",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."isArchived" AS "AssetFaceEntity__AssetFaceEntity_asset_isArchived",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."isExternal" AS "AssetFaceEntity__AssetFaceEntity_asset_isExternal",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."isOffline" AS "AssetFaceEntity__AssetFaceEntity_asset_isOffline",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."checksum" AS "AssetFaceEntity__AssetFaceEntity_asset_checksum",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."duration" AS "AssetFaceEntity__AssetFaceEntity_asset_duration",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."isVisible" AS "AssetFaceEntity__AssetFaceEntity_asset_isVisible",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."duplicateId" AS "AssetFaceEntity__AssetFaceEntity_asset_duplicateId"
|
||||
FROM
|
||||
"asset_faces" "AssetFaceEntity"
|
||||
LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
|
||||
LEFT JOIN "assets" "AssetFaceEntity__AssetFaceEntity_asset" ON "AssetFaceEntity__AssetFaceEntity_asset"."id" = "AssetFaceEntity"."assetId"
|
||||
AND (
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."deletedAt" IS NULL
|
||||
)
|
||||
WHERE
|
||||
(("AssetFaceEntity"."id" = $1))
|
||||
) "distinctAlias"
|
||||
ORDER BY
|
||||
"AssetFaceEntity_id" ASC
|
||||
LIMIT
|
||||
1
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"person".*
|
||||
from
|
||||
"person"
|
||||
where
|
||||
"person"."id" = "asset_faces"."personId"
|
||||
) as obj
|
||||
) as "person",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"assets".*
|
||||
from
|
||||
"assets"
|
||||
where
|
||||
"assets"."id" = "asset_faces"."assetId"
|
||||
) as obj
|
||||
) as "asset"
|
||||
from
|
||||
"asset_faces"
|
||||
where
|
||||
"asset_faces"."id" = $1
|
||||
|
||||
-- PersonRepository.reassignFace
|
||||
UPDATE "asset_faces"
|
||||
SET
|
||||
update "asset_faces"
|
||||
set
|
||||
"personId" = $1
|
||||
WHERE
|
||||
"id" = $2
|
||||
where
|
||||
"asset_faces"."id" = $2
|
||||
|
||||
-- PersonRepository.getByName
|
||||
SELECT
|
||||
"person"."id" AS "person_id",
|
||||
"person"."createdAt" AS "person_createdAt",
|
||||
"person"."updatedAt" AS "person_updatedAt",
|
||||
"person"."ownerId" AS "person_ownerId",
|
||||
"person"."name" AS "person_name",
|
||||
"person"."birthDate" AS "person_birthDate",
|
||||
"person"."thumbnailPath" AS "person_thumbnailPath",
|
||||
"person"."faceAssetId" AS "person_faceAssetId",
|
||||
"person"."isHidden" AS "person_isHidden"
|
||||
FROM
|
||||
"person" "person"
|
||||
WHERE
|
||||
"person"."ownerId" = $1
|
||||
AND (
|
||||
LOWER("person"."name") LIKE $2
|
||||
OR LOWER("person"."name") LIKE $3
|
||||
)
|
||||
LIMIT
|
||||
1000
|
||||
|
||||
-- PersonRepository.getDistinctNames
|
||||
SELECT DISTINCT
|
||||
ON (lower("person"."name")) "person"."id" AS "person_id",
|
||||
"person"."name" AS "person_name"
|
||||
FROM
|
||||
"person" "person"
|
||||
WHERE
|
||||
"person"."ownerId" = $1
|
||||
AND "person"."name" != ''
|
||||
|
||||
-- PersonRepository.getStatistics
|
||||
SELECT
|
||||
COUNT(DISTINCT ("asset"."id")) AS "count"
|
||||
FROM
|
||||
"asset_faces" "face"
|
||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "face"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"face"."personId" = $1
|
||||
AND "asset"."isArchived" = false
|
||||
AND "asset"."deletedAt" IS NULL
|
||||
AND "asset"."livePhotoVideoId" IS NULL
|
||||
|
||||
-- PersonRepository.getNumberOfPeople
|
||||
SELECT
|
||||
COUNT(DISTINCT ("person"."id")) AS "total",
|
||||
COUNT(DISTINCT ("person"."id")) FILTER (
|
||||
WHERE
|
||||
"person"."isHidden" = true
|
||||
) AS "hidden"
|
||||
FROM
|
||||
"person" "person"
|
||||
INNER JOIN "asset_faces" "face" ON "face"."personId" = "person"."id"
|
||||
INNER JOIN "assets" "asset" ON "asset"."id" = "face"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"person"."ownerId" = $1
|
||||
AND "asset"."isArchived" = false
|
||||
|
||||
-- PersonRepository.getFacesByIds
|
||||
SELECT
|
||||
"AssetFaceEntity"."id" AS "AssetFaceEntity_id",
|
||||
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
|
||||
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
|
||||
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
|
||||
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
|
||||
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
|
||||
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
||||
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
||||
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
||||
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_asset_createdAt",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_updatedAt",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."deletedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_deletedAt",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."fileCreatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileCreatedAt",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."localDateTime" AS "AssetFaceEntity__AssetFaceEntity_asset_localDateTime",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."fileModifiedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileModifiedAt",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."isFavorite" AS "AssetFaceEntity__AssetFaceEntity_asset_isFavorite",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."isArchived" AS "AssetFaceEntity__AssetFaceEntity_asset_isArchived",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."isExternal" AS "AssetFaceEntity__AssetFaceEntity_asset_isExternal",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."isOffline" AS "AssetFaceEntity__AssetFaceEntity_asset_isOffline",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."checksum" AS "AssetFaceEntity__AssetFaceEntity_asset_checksum",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."duration" AS "AssetFaceEntity__AssetFaceEntity_asset_duration",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."isVisible" AS "AssetFaceEntity__AssetFaceEntity_asset_isVisible",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."duplicateId" AS "AssetFaceEntity__AssetFaceEntity_asset_duplicateId"
|
||||
FROM
|
||||
"asset_faces" "AssetFaceEntity"
|
||||
LEFT JOIN "assets" "AssetFaceEntity__AssetFaceEntity_asset" ON "AssetFaceEntity__AssetFaceEntity_asset"."id" = "AssetFaceEntity"."assetId"
|
||||
WHERE
|
||||
select
|
||||
"person".*
|
||||
from
|
||||
"person"
|
||||
where
|
||||
(
|
||||
(
|
||||
(
|
||||
("AssetFaceEntity"."assetId" = $1)
|
||||
AND ("AssetFaceEntity"."personId" = $2)
|
||||
)
|
||||
"person"."ownerId" = $1
|
||||
and (
|
||||
lower("person"."name") like $2
|
||||
or lower("person"."name") like $3
|
||||
)
|
||||
)
|
||||
limit
|
||||
$4
|
||||
|
||||
-- PersonRepository.getDistinctNames
|
||||
select distinct
|
||||
on (lower("person"."name")) "person"."id",
|
||||
"person"."name"
|
||||
from
|
||||
"person"
|
||||
where
|
||||
(
|
||||
"person"."ownerId" = $1
|
||||
and "person"."name" != $2
|
||||
)
|
||||
|
||||
-- PersonRepository.getStatistics
|
||||
select
|
||||
count(distinct ("assets"."id")) as "count"
|
||||
from
|
||||
"asset_faces"
|
||||
left join "assets" on "assets"."id" = "asset_faces"."assetId"
|
||||
and "asset_faces"."personId" = $1
|
||||
and "assets"."isArchived" = $2
|
||||
and "assets"."deletedAt" is null
|
||||
and "assets"."livePhotoVideoId" is null
|
||||
|
||||
-- PersonRepository.getNumberOfPeople
|
||||
select
|
||||
count(distinct ("person"."id")) as "total",
|
||||
count(distinct ("person"."id")) filter (
|
||||
where
|
||||
"person"."isHidden" = $1
|
||||
) as "hidden"
|
||||
from
|
||||
"person"
|
||||
inner join "asset_faces" on "asset_faces"."personId" = "person"."id"
|
||||
inner join "assets" on "assets"."id" = "asset_faces"."assetId"
|
||||
and "assets"."deletedAt" is null
|
||||
and "assets"."isArchived" = $2
|
||||
where
|
||||
"person"."ownerId" = $3
|
||||
|
||||
-- PersonRepository.refreshFaces
|
||||
with
|
||||
"added_embeddings" as (
|
||||
insert into
|
||||
"face_search" ("faceId", "embedding")
|
||||
values
|
||||
($1, $2)
|
||||
)
|
||||
select
|
||||
from
|
||||
(
|
||||
select
|
||||
1
|
||||
) as "dummy"
|
||||
|
||||
-- PersonRepository.getFacesByIds
|
||||
select
|
||||
"asset_faces".*,
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"assets".*
|
||||
from
|
||||
"assets"
|
||||
where
|
||||
"assets"."id" = "asset_faces"."assetId"
|
||||
) as obj
|
||||
) as "asset",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"person".*
|
||||
from
|
||||
"person"
|
||||
where
|
||||
"person"."id" = "asset_faces"."personId"
|
||||
) as obj
|
||||
) as "person"
|
||||
from
|
||||
"asset_faces"
|
||||
where
|
||||
"asset_faces"."assetId" in ($1)
|
||||
and "asset_faces"."personId" in ($2)
|
||||
|
||||
-- PersonRepository.getRandomFace
|
||||
SELECT
|
||||
"AssetFaceEntity"."id" AS "AssetFaceEntity_id",
|
||||
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
|
||||
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
|
||||
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
|
||||
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
|
||||
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
|
||||
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
||||
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
||||
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
||||
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType"
|
||||
FROM
|
||||
"asset_faces" "AssetFaceEntity"
|
||||
WHERE
|
||||
(("AssetFaceEntity"."personId" = $1))
|
||||
LIMIT
|
||||
1
|
||||
select
|
||||
"asset_faces".*
|
||||
from
|
||||
"asset_faces"
|
||||
where
|
||||
"asset_faces"."personId" = $1
|
||||
|
||||
-- PersonRepository.getLatestFaceDate
|
||||
SELECT
|
||||
MAX("jobStatus"."facesRecognizedAt")::text AS "latestDate"
|
||||
FROM
|
||||
"asset_job_status" "jobStatus"
|
||||
select
|
||||
max("asset_job_status"."facesRecognizedAt")::text as "latestDate"
|
||||
from
|
||||
"asset_job_status"
|
||||
|
|
|
@ -76,7 +76,7 @@ where
|
|||
and "assets"."isArchived" = $5
|
||||
and "assets"."deletedAt" is null
|
||||
order by
|
||||
smart_search.embedding <= > $6::vector
|
||||
smart_search.embedding <= > $6
|
||||
limit
|
||||
$7
|
||||
offset
|
||||
|
@ -88,7 +88,7 @@ with
|
|||
select
|
||||
"assets"."id" as "assetId",
|
||||
"assets"."duplicateId",
|
||||
smart_search.embedding <= > $1::vector as "distance"
|
||||
smart_search.embedding <= > $1 as "distance"
|
||||
from
|
||||
"assets"
|
||||
inner join "smart_search" on "assets"."id" = "smart_search"."assetId"
|
||||
|
@ -99,7 +99,7 @@ with
|
|||
and "assets"."type" = $4
|
||||
and "assets"."id" != $5::uuid
|
||||
order by
|
||||
smart_search.embedding <= > $6::vector
|
||||
smart_search.embedding <= > $6
|
||||
limit
|
||||
$7
|
||||
)
|
||||
|
@ -116,7 +116,7 @@ with
|
|||
select
|
||||
"asset_faces"."id",
|
||||
"asset_faces"."personId",
|
||||
face_search.embedding <= > $1::vector as "distance"
|
||||
face_search.embedding <= > $1 as "distance"
|
||||
from
|
||||
"asset_faces"
|
||||
inner join "assets" on "assets"."id" = "asset_faces"."assetId"
|
||||
|
@ -125,7 +125,7 @@ with
|
|||
"assets"."ownerId" = any ($2::uuid [])
|
||||
and "assets"."deletedAt" is null
|
||||
order by
|
||||
face_search.embedding <= > $3::vector
|
||||
face_search.embedding <= > $3
|
||||
limit
|
||||
$4
|
||||
)
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { ExpressionBuilder, Insertable, Kysely, SelectExpression, sql } from 'kysely';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import _ from 'lodash';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { AssetFaces, DB, FaceSearch, Person } from 'src/db';
|
||||
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { FaceSearchEntity } from 'src/entities/face-search.entity';
|
||||
import { PersonEntity } from 'src/entities/person.entity';
|
||||
import { PaginationMode, SourceType } from 'src/enum';
|
||||
import { SourceType } from 'src/enum';
|
||||
import {
|
||||
AssetFaceId,
|
||||
DeleteFacesOptions,
|
||||
|
@ -17,332 +17,418 @@ import {
|
|||
PersonNameSearchOptions,
|
||||
PersonSearchOptions,
|
||||
PersonStatistics,
|
||||
SelectFaceOptions,
|
||||
UnassignFacesOptions,
|
||||
UpdateFacesData,
|
||||
} from 'src/interfaces/person.interface';
|
||||
import { Paginated, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination';
|
||||
import { DataSource, FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm';
|
||||
import { mapUpsertColumns } from 'src/utils/database';
|
||||
import { Paginated, PaginationOptions } from 'src/utils/pagination';
|
||||
import { FindOptionsRelations } from 'typeorm';
|
||||
|
||||
const withPerson = (eb: ExpressionBuilder<DB, 'asset_faces'>) => {
|
||||
return jsonObjectFrom(
|
||||
eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_faces.personId'),
|
||||
).as('person');
|
||||
};
|
||||
|
||||
const withAsset = (eb: ExpressionBuilder<DB, 'asset_faces'>) => {
|
||||
return jsonObjectFrom(
|
||||
eb.selectFrom('assets').selectAll('assets').whereRef('assets.id', '=', 'asset_faces.assetId'),
|
||||
).as('asset');
|
||||
};
|
||||
|
||||
const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_faces'>) => {
|
||||
return jsonObjectFrom(
|
||||
eb.selectFrom('face_search').selectAll('face_search').whereRef('face_search.faceId', '=', 'asset_faces.id'),
|
||||
).as('faceSearch');
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PersonRepository implements IPersonRepository {
|
||||
constructor(
|
||||
@InjectDataSource() private dataSource: DataSource,
|
||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
|
||||
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
|
||||
@InjectRepository(FaceSearchEntity) private faceSearchRepository: Repository<FaceSearchEntity>,
|
||||
@InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository<AssetJobStatusEntity>,
|
||||
) {}
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
|
||||
async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> {
|
||||
const result = await this.assetFaceRepository
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
const result = await this.db
|
||||
.updateTable('asset_faces')
|
||||
.set({ personId: newPersonId })
|
||||
.where(_.omitBy({ personId: oldPersonId, id: faceIds ? In(faceIds) : undefined }, _.isUndefined))
|
||||
.execute();
|
||||
.$if(!!oldPersonId, (qb) => qb.where('asset_faces.personId', '=', oldPersonId!))
|
||||
.$if(!!faceIds, (qb) => qb.where('asset_faces.id', 'in', faceIds!))
|
||||
.executeTakeFirst();
|
||||
|
||||
return result.affected ?? 0;
|
||||
return Number(result.numChangedRows) ?? 0;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ sourceType: SourceType.EXIF }] })
|
||||
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
|
||||
await this.assetFaceRepository
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
await this.db
|
||||
.updateTable('asset_faces')
|
||||
.set({ personId: null })
|
||||
.where({ sourceType })
|
||||
.where('asset_faces.sourceType', '=', sourceType)
|
||||
.execute();
|
||||
|
||||
await this.vacuum({ reindexVectors: false });
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[{ id: DummyValue.UUID }]] })
|
||||
async delete(entities: PersonEntity[]): Promise<void> {
|
||||
await this.personRepository.remove(entities);
|
||||
if (entities.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db
|
||||
.deleteFrom('person')
|
||||
.where(
|
||||
'person.id',
|
||||
'in',
|
||||
entities.map(({ id }) => id),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ sourceType: SourceType.EXIF }] })
|
||||
async deleteFaces({ sourceType }: DeleteFacesOptions): Promise<void> {
|
||||
await this.assetFaceRepository
|
||||
.createQueryBuilder('asset_faces')
|
||||
.delete()
|
||||
.andWhere('sourceType = :sourceType', { sourceType })
|
||||
.execute();
|
||||
await this.db.deleteFrom('asset_faces').where('asset_faces.sourceType', '=', sourceType).execute();
|
||||
|
||||
await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING });
|
||||
}
|
||||
|
||||
getAllFaces(
|
||||
pagination: PaginationOptions,
|
||||
options: FindManyOptions<AssetFaceEntity> = {},
|
||||
): Paginated<AssetFaceEntity> {
|
||||
return paginate(this.assetFaceRepository, pagination, options);
|
||||
getAllFaces(options: Partial<AssetFaceEntity> = {}): AsyncIterableIterator<AssetFaceEntity> {
|
||||
return this.db
|
||||
.selectFrom('asset_faces')
|
||||
.selectAll('asset_faces')
|
||||
.$if(options.personId === null, (qb) => qb.where('asset_faces.personId', 'is', null))
|
||||
.$if(!!options.personId, (qb) => qb.where('asset_faces.personId', '=', options.personId!))
|
||||
.$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!))
|
||||
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
|
||||
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
|
||||
.stream() as AsyncIterableIterator<AssetFaceEntity>;
|
||||
}
|
||||
|
||||
getAll(pagination: PaginationOptions, options: FindManyOptions<PersonEntity> = {}): Paginated<PersonEntity> {
|
||||
return paginate(this.personRepository, pagination, options);
|
||||
getAll(options: Partial<PersonEntity> = {}): AsyncIterableIterator<PersonEntity> {
|
||||
return this.db
|
||||
.selectFrom('person')
|
||||
.selectAll('person')
|
||||
.$if(!!options.ownerId, (qb) => qb.where('person.ownerId', '=', options.ownerId!))
|
||||
.$if(!!options.thumbnailPath, (qb) => qb.where('person.thumbnailPath', '=', options.thumbnailPath!))
|
||||
.$if(options.faceAssetId === null, (qb) => qb.where('person.faceAssetId', 'is', null))
|
||||
.$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!))
|
||||
.$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!))
|
||||
.stream() as AsyncIterableIterator<PersonEntity>;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ take: 10, skip: 10 }, DummyValue.UUID] })
|
||||
async getAllForUser(
|
||||
pagination: PaginationOptions,
|
||||
userId: string,
|
||||
options?: PersonSearchOptions,
|
||||
): Paginated<PersonEntity> {
|
||||
const queryBuilder = this.personRepository
|
||||
.createQueryBuilder('person')
|
||||
.innerJoin('person.faces', 'face')
|
||||
.where('person.ownerId = :userId', { userId })
|
||||
.innerJoin('face.asset', 'asset')
|
||||
.andWhere('asset.isArchived = false')
|
||||
.orderBy('person.isHidden', 'ASC')
|
||||
.addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC')
|
||||
.addOrderBy('COUNT(face.assetId)', 'DESC')
|
||||
.addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST')
|
||||
.addOrderBy('person.createdAt')
|
||||
.having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 })
|
||||
.groupBy('person.id');
|
||||
if (options?.closestFaceAssetId) {
|
||||
const innerQueryBuilder = this.faceSearchRepository
|
||||
.createQueryBuilder('face_search')
|
||||
.select('embedding', 'embedding')
|
||||
.where('"face_search"."faceId" = "person"."faceAssetId"');
|
||||
const faceSelectQueryBuilder = this.faceSearchRepository
|
||||
.createQueryBuilder('face_search')
|
||||
.select('embedding', 'embedding')
|
||||
.where('"face_search"."faceId" = :faceId', { faceId: options.closestFaceAssetId });
|
||||
queryBuilder
|
||||
.orderBy('(' + innerQueryBuilder.getQuery() + ') <=> (' + faceSelectQueryBuilder.getQuery() + ')')
|
||||
.setParameters(faceSelectQueryBuilder.getParameters());
|
||||
const items = (await this.db
|
||||
.selectFrom('person')
|
||||
.selectAll('person')
|
||||
.innerJoin('asset_faces', 'asset_faces.personId', 'person.id')
|
||||
.innerJoin('assets', (join) =>
|
||||
join
|
||||
.onRef('asset_faces.assetId', '=', 'assets.id')
|
||||
.on('assets.isArchived', '=', false)
|
||||
.on('assets.deletedAt', 'is', null),
|
||||
)
|
||||
.where('person.ownerId', '=', userId)
|
||||
.orderBy('person.isHidden', 'asc')
|
||||
.orderBy(sql`NULLIF(person.name, '') is null`, 'asc')
|
||||
.orderBy((eb) => eb.fn.count('asset_faces.assetId'), 'desc')
|
||||
.orderBy(sql`NULLIF(person.name, '')`, sql`asc nulls last`)
|
||||
.orderBy('person.createdAt')
|
||||
.having((eb) =>
|
||||
eb.or([
|
||||
eb('person.name', '!=', ''),
|
||||
eb((innerEb) => innerEb.fn.count('asset_faces.assetId'), '>=', options?.minimumFaceCount || 1),
|
||||
]),
|
||||
)
|
||||
.groupBy('person.id')
|
||||
.$if(!!options?.closestFaceAssetId, (qb) =>
|
||||
qb.orderBy((eb) =>
|
||||
eb(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('face_search')
|
||||
.select('face_search.embedding')
|
||||
.whereRef('face_search.faceId', '=', 'person.faceAssetId'),
|
||||
'<=>',
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('face_search')
|
||||
.select('face_search.embedding')
|
||||
.where('face_search.faceId', '=', options!.closestFaceAssetId!),
|
||||
),
|
||||
),
|
||||
)
|
||||
.$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false))
|
||||
.offset(pagination.skip ?? 0)
|
||||
.limit(pagination.take + 1)
|
||||
.execute()) as PersonEntity[];
|
||||
|
||||
if (items.length > pagination.take) {
|
||||
return { items: items.slice(0, -1), hasNextPage: true };
|
||||
}
|
||||
if (!options?.withHidden) {
|
||||
queryBuilder.andWhere('person.isHidden = false');
|
||||
}
|
||||
return paginatedBuilder(queryBuilder, {
|
||||
mode: PaginationMode.LIMIT_OFFSET,
|
||||
...pagination,
|
||||
});
|
||||
|
||||
return { items, hasNextPage: false };
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
getAllWithoutFaces(): Promise<PersonEntity[]> {
|
||||
return this.personRepository
|
||||
.createQueryBuilder('person')
|
||||
.leftJoin('person.faces', 'face')
|
||||
.having('COUNT(face.assetId) = 0')
|
||||
return this.db
|
||||
.selectFrom('person')
|
||||
.selectAll('person')
|
||||
.leftJoin('asset_faces', 'asset_faces.personId', 'person.id')
|
||||
.having((eb) => eb.fn.count('asset_faces.assetId'), '=', 0)
|
||||
.groupBy('person.id')
|
||||
.withDeleted()
|
||||
.getMany();
|
||||
.execute() as Promise<PersonEntity[]>;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getFaces(assetId: string): Promise<AssetFaceEntity[]> {
|
||||
return this.assetFaceRepository.find({
|
||||
where: { assetId },
|
||||
relations: {
|
||||
person: true,
|
||||
},
|
||||
order: {
|
||||
boundingBoxX1: 'ASC',
|
||||
},
|
||||
});
|
||||
return this.db
|
||||
.selectFrom('asset_faces')
|
||||
.selectAll('asset_faces')
|
||||
.select(withPerson)
|
||||
.where('asset_faces.assetId', '=', assetId)
|
||||
.orderBy('asset_faces.boundingBoxX1', 'asc')
|
||||
.execute() as Promise<AssetFaceEntity[]>;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getFaceById(id: string): Promise<AssetFaceEntity> {
|
||||
// TODO return null instead of find or fail
|
||||
return this.assetFaceRepository.findOneOrFail({
|
||||
where: { id },
|
||||
relations: {
|
||||
person: true,
|
||||
},
|
||||
});
|
||||
return this.db
|
||||
.selectFrom('asset_faces')
|
||||
.selectAll('asset_faces')
|
||||
.select(withPerson)
|
||||
.where('asset_faces.id', '=', id)
|
||||
.executeTakeFirstOrThrow() as Promise<AssetFaceEntity>;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getFaceByIdWithAssets(
|
||||
id: string,
|
||||
relations: FindOptionsRelations<AssetFaceEntity>,
|
||||
select: FindOptionsSelect<AssetFaceEntity>,
|
||||
relations?: FindOptionsRelations<AssetFaceEntity>,
|
||||
select?: SelectFaceOptions,
|
||||
): Promise<AssetFaceEntity | null> {
|
||||
return this.assetFaceRepository.findOne(
|
||||
_.omitBy(
|
||||
{
|
||||
where: { id },
|
||||
relations: {
|
||||
...relations,
|
||||
person: true,
|
||||
asset: true,
|
||||
},
|
||||
select,
|
||||
},
|
||||
_.isUndefined,
|
||||
),
|
||||
);
|
||||
return (this.db
|
||||
.selectFrom('asset_faces')
|
||||
.$if(!!select, (qb) =>
|
||||
qb.select(
|
||||
Object.keys(
|
||||
_.omitBy({ ...select!, faceSearch: undefined, asset: undefined }, _.isUndefined),
|
||||
) as SelectExpression<DB, 'asset_faces'>[],
|
||||
),
|
||||
)
|
||||
.$if(!select, (qb) => qb.selectAll('asset_faces'))
|
||||
.select(withPerson)
|
||||
.select(withAsset)
|
||||
.$if(!!relations?.faceSearch, (qb) => qb.select(withFaceSearch))
|
||||
.where('asset_faces.id', '=', id)
|
||||
.executeTakeFirst() ?? null) as Promise<AssetFaceEntity | null>;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
||||
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
|
||||
const result = await this.assetFaceRepository
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
const result = await this.db
|
||||
.updateTable('asset_faces')
|
||||
.set({ personId: newPersonId })
|
||||
.where({ id: assetFaceId })
|
||||
.execute();
|
||||
.where('asset_faces.id', '=', assetFaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return result.affected ?? 0;
|
||||
return Number(result.numChangedRows) ?? 0;
|
||||
}
|
||||
|
||||
getById(personId: string): Promise<PersonEntity | null> {
|
||||
return this.personRepository.findOne({ where: { id: personId } });
|
||||
return (this.db //
|
||||
.selectFrom('person')
|
||||
.selectAll('person')
|
||||
.where('person.id', '=', personId)
|
||||
.executeTakeFirst() ?? null) as Promise<PersonEntity | null>;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
|
||||
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise<PersonEntity[]> {
|
||||
const queryBuilder = this.personRepository
|
||||
.createQueryBuilder('person')
|
||||
.where(
|
||||
'person.ownerId = :userId AND (LOWER(person.name) LIKE :nameStart OR LOWER(person.name) LIKE :nameAnywhere)',
|
||||
{ userId, nameStart: `${personName.toLowerCase()}%`, nameAnywhere: `% ${personName.toLowerCase()}%` },
|
||||
return this.db
|
||||
.selectFrom('person')
|
||||
.selectAll('person')
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
eb('person.ownerId', '=', userId),
|
||||
eb.or([
|
||||
eb(eb.fn('lower', ['person.name']), 'like', `${personName.toLowerCase()}%`),
|
||||
eb(eb.fn('lower', ['person.name']), 'like', `% ${personName.toLowerCase()}%`),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.limit(1000);
|
||||
|
||||
if (!withHidden) {
|
||||
queryBuilder.andWhere('person.isHidden = false');
|
||||
}
|
||||
return queryBuilder.getMany();
|
||||
.limit(1000)
|
||||
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
|
||||
.execute() as Promise<PersonEntity[]>;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] })
|
||||
getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise<PersonNameResponse[]> {
|
||||
const queryBuilder = this.personRepository
|
||||
.createQueryBuilder('person')
|
||||
return this.db
|
||||
.selectFrom('person')
|
||||
.select(['person.id', 'person.name'])
|
||||
.distinctOn(['lower(person.name)'])
|
||||
.where(`person.ownerId = :userId AND person.name != ''`, { userId });
|
||||
|
||||
if (!withHidden) {
|
||||
queryBuilder.andWhere('person.isHidden = false');
|
||||
}
|
||||
|
||||
return queryBuilder.getMany();
|
||||
.distinctOn((eb) => eb.fn('lower', ['person.name']))
|
||||
.where((eb) => eb.and([eb('person.ownerId', '=', userId), eb('person.name', '!=', '')]))
|
||||
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getStatistics(personId: string): Promise<PersonStatistics> {
|
||||
const items = await this.assetFaceRepository
|
||||
.createQueryBuilder('face')
|
||||
.leftJoin('face.asset', 'asset')
|
||||
.where('face.personId = :personId', { personId })
|
||||
.andWhere('asset.isArchived = false')
|
||||
.andWhere('asset.deletedAt IS NULL')
|
||||
.andWhere('asset.livePhotoVideoId IS NULL')
|
||||
.select('COUNT(DISTINCT(asset.id))', 'count')
|
||||
.getRawOne();
|
||||
const result = await this.db
|
||||
.selectFrom('asset_faces')
|
||||
.leftJoin('assets', (join) =>
|
||||
join
|
||||
.onRef('assets.id', '=', 'asset_faces.assetId')
|
||||
.on('asset_faces.personId', '=', personId)
|
||||
.on('assets.isArchived', '=', false)
|
||||
.on('assets.deletedAt', 'is', null)
|
||||
.on('assets.livePhotoVideoId', 'is', null),
|
||||
)
|
||||
.select((eb) => eb.fn.count(eb.fn('distinct', ['assets.id'])).as('count'))
|
||||
.executeTakeFirst();
|
||||
|
||||
return {
|
||||
assets: items.count ?? 0,
|
||||
assets: result ? Number(result.count) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getNumberOfPeople(userId: string): Promise<PeopleStatistics> {
|
||||
const items = await this.personRepository
|
||||
.createQueryBuilder('person')
|
||||
.innerJoin('person.faces', 'face')
|
||||
.where('person.ownerId = :userId', { userId })
|
||||
.innerJoin('face.asset', 'asset')
|
||||
.andWhere('asset.isArchived = false')
|
||||
.select('COUNT(DISTINCT(person.id))', 'total')
|
||||
.addSelect('COUNT(DISTINCT(person.id)) FILTER (WHERE person.isHidden = true)', 'hidden')
|
||||
.getRawOne();
|
||||
const items = await this.db
|
||||
.selectFrom('person')
|
||||
.innerJoin('asset_faces', 'asset_faces.personId', 'person.id')
|
||||
.where('person.ownerId', '=', userId)
|
||||
.innerJoin('assets', (join) =>
|
||||
join
|
||||
.onRef('assets.id', '=', 'asset_faces.assetId')
|
||||
.on('assets.deletedAt', 'is', null)
|
||||
.on('assets.isArchived', '=', false),
|
||||
)
|
||||
.select((eb) => eb.fn.count(eb.fn('distinct', ['person.id'])).as('total'))
|
||||
.select((eb) =>
|
||||
eb.fn
|
||||
.count(eb.fn('distinct', ['person.id']))
|
||||
.filterWhere('person.isHidden', '=', true)
|
||||
.as('hidden'),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (items == undefined) {
|
||||
return { total: 0, hidden: 0 };
|
||||
}
|
||||
|
||||
const result: PeopleStatistics = {
|
||||
total: items.total ?? 0,
|
||||
hidden: items.hidden ?? 0,
|
||||
return {
|
||||
total: Number(items.total),
|
||||
hidden: Number(items.hidden),
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
create(person: Partial<PersonEntity>): Promise<PersonEntity> {
|
||||
return this.save(person);
|
||||
create(person: Insertable<Person>): Promise<PersonEntity> {
|
||||
return this.db.insertInto('person').values(person).returningAll().executeTakeFirst() as Promise<PersonEntity>;
|
||||
}
|
||||
|
||||
async createAll(people: Partial<PersonEntity>[]): Promise<string[]> {
|
||||
const results = await this.personRepository.save(people);
|
||||
return results.map((person) => person.id);
|
||||
async createAll(people: Insertable<Person>[]): Promise<string[]> {
|
||||
const results = await this.db.insertInto('person').values(people).returningAll().execute();
|
||||
return results.map(({ id }) => id);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] })
|
||||
async refreshFaces(
|
||||
facesToAdd: Partial<AssetFaceEntity>[],
|
||||
facesToAdd: (Insertable<AssetFaces> & { assetId: string })[],
|
||||
faceIdsToRemove: string[],
|
||||
embeddingsToAdd?: FaceSearchEntity[],
|
||||
embeddingsToAdd?: Insertable<FaceSearch>[],
|
||||
): Promise<void> {
|
||||
const query = this.faceSearchRepository.createQueryBuilder().select('1').fromDummy();
|
||||
let query = this.db;
|
||||
if (facesToAdd.length > 0) {
|
||||
const insertCte = this.assetFaceRepository.createQueryBuilder().insert().values(facesToAdd);
|
||||
query.addCommonTableExpression(insertCte, 'added');
|
||||
(query as any) = query.with('added', (db) => db.insertInto('asset_faces').values(facesToAdd));
|
||||
}
|
||||
|
||||
if (faceIdsToRemove.length > 0) {
|
||||
const deleteCte = this.assetFaceRepository
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('id = any(:faceIdsToRemove)', { faceIdsToRemove });
|
||||
query.addCommonTableExpression(deleteCte, 'deleted');
|
||||
(query as any) = query.with('removed', (db) =>
|
||||
db.deleteFrom('asset_faces').where('asset_faces.id', '=', (eb) => eb.fn.any(eb.val(faceIdsToRemove))),
|
||||
);
|
||||
}
|
||||
|
||||
if (embeddingsToAdd?.length) {
|
||||
const embeddingCte = this.faceSearchRepository.createQueryBuilder().insert().values(embeddingsToAdd).orIgnore();
|
||||
query.addCommonTableExpression(embeddingCte, 'embeddings');
|
||||
query.getQuery(); // typeorm mixes up parameters without this
|
||||
(query as any) = query.with('added_embeddings', (db) => db.insertInto('face_search').values(embeddingsToAdd));
|
||||
}
|
||||
|
||||
await query.execute();
|
||||
await query.selectFrom(sql`(select 1)`.as('dummy')).execute();
|
||||
}
|
||||
|
||||
async update(person: Partial<PersonEntity>): Promise<PersonEntity> {
|
||||
return this.save(person);
|
||||
async update(person: Partial<PersonEntity> & { id: string }): Promise<PersonEntity> {
|
||||
return this.db
|
||||
.updateTable('person')
|
||||
.set(person)
|
||||
.where('person.id', '=', person.id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow() as Promise<PersonEntity>;
|
||||
}
|
||||
|
||||
async updateAll(people: Partial<PersonEntity>[]): Promise<void> {
|
||||
await this.personRepository.save(people);
|
||||
async updateAll(people: Insertable<Person>[]): Promise<void> {
|
||||
if (people.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db
|
||||
.insertInto('person')
|
||||
.values(people)
|
||||
.onConflict((oc) => oc.column('id').doUpdateSet(() => mapUpsertColumns('person', people[0], ['id'])))
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
|
||||
@ChunkedArray()
|
||||
async getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> {
|
||||
return this.assetFaceRepository.find({ where: ids, relations: { asset: true }, withDeleted: true });
|
||||
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> {
|
||||
const { assetIds, personIds }: { assetIds: string[]; personIds: string[] } = { assetIds: [], personIds: [] };
|
||||
|
||||
for (const { assetId, personId } of ids) {
|
||||
assetIds.push(assetId);
|
||||
personIds.push(personId);
|
||||
}
|
||||
|
||||
return this.db
|
||||
.selectFrom('asset_faces')
|
||||
.selectAll('asset_faces')
|
||||
.select(withAsset)
|
||||
.select(withPerson)
|
||||
.where('asset_faces.assetId', 'in', assetIds)
|
||||
.where('asset_faces.personId', 'in', personIds)
|
||||
.execute() as Promise<AssetFaceEntity[]>;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getRandomFace(personId: string): Promise<AssetFaceEntity | null> {
|
||||
return this.assetFaceRepository.findOneBy({ personId });
|
||||
getRandomFace(personId: string): Promise<AssetFaceEntity | null> {
|
||||
return (this.db
|
||||
.selectFrom('asset_faces')
|
||||
.selectAll('asset_faces')
|
||||
.where('asset_faces.personId', '=', personId)
|
||||
.executeTakeFirst() ?? null) as Promise<AssetFaceEntity | null>;
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
async getLatestFaceDate(): Promise<string | undefined> {
|
||||
const result: { latestDate?: string } | undefined = await this.jobStatusRepository
|
||||
.createQueryBuilder('jobStatus')
|
||||
.select('MAX(jobStatus.facesRecognizedAt)::text', 'latestDate')
|
||||
.getRawOne();
|
||||
const result = (await this.db
|
||||
.selectFrom('asset_job_status')
|
||||
.select((eb) => sql`${eb.fn.max('asset_job_status.facesRecognizedAt')}::text`.as('latestDate'))
|
||||
.executeTakeFirst()) as { latestDate: string } | undefined;
|
||||
|
||||
return result?.latestDate;
|
||||
}
|
||||
|
||||
private async save(person: Partial<PersonEntity>): Promise<PersonEntity> {
|
||||
const { id } = await this.personRepository.save(person);
|
||||
return this.personRepository.findOneByOrFail({ id });
|
||||
}
|
||||
|
||||
private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise<void> {
|
||||
await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search, person');
|
||||
await this.assetFaceRepository.query('REINDEX TABLE asset_faces');
|
||||
await this.assetFaceRepository.query('REINDEX TABLE person');
|
||||
await sql`VACUUM ANALYZE asset_faces, face_search, person`.execute(this.db);
|
||||
await sql`REINDEX TABLE asset_faces`.execute(this.db);
|
||||
await sql`REINDEX TABLE person`.execute(this.db);
|
||||
if (reindexVectors) {
|
||||
await this.assetFaceRepository.query('REINDEX TABLE face_search');
|
||||
await sql`REINDEX TABLE face_search`.execute(this.db);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ import {
|
|||
SearchPaginationOptions,
|
||||
SmartSearchOptions,
|
||||
} from 'src/interfaces/search.interface';
|
||||
import { anyUuid, asUuid, asVector } from 'src/utils/database';
|
||||
import { anyUuid, asUuid } from 'src/utils/database';
|
||||
import { Paginated } from 'src/utils/pagination';
|
||||
import { isValidInteger } from 'src/validation';
|
||||
|
||||
|
@ -82,7 +82,7 @@ export class SearchRepository implements ISearchRepository {
|
|||
{ page: 1, size: 200 },
|
||||
{
|
||||
takenAfter: DummyValue.DATE,
|
||||
embedding: Array.from({ length: 512 }, Math.random),
|
||||
embedding: DummyValue.VECTOR,
|
||||
lensModel: DummyValue.STRING,
|
||||
withStacked: true,
|
||||
isFavorite: true,
|
||||
|
@ -97,7 +97,7 @@ export class SearchRepository implements ISearchRepository {
|
|||
|
||||
const items = (await searchAssetBuilder(this.db, options)
|
||||
.innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
|
||||
.orderBy(sql`smart_search.embedding <=> ${asVector(options.embedding)}`)
|
||||
.orderBy(sql`smart_search.embedding <=> ${options.embedding}`)
|
||||
.limit(pagination.size + 1)
|
||||
.offset((pagination.page - 1) * pagination.size)
|
||||
.execute()) as any as AssetEntity[];
|
||||
|
@ -111,7 +111,7 @@ export class SearchRepository implements ISearchRepository {
|
|||
params: [
|
||||
{
|
||||
assetId: DummyValue.UUID,
|
||||
embedding: Array.from({ length: 512 }, Math.random),
|
||||
embedding: DummyValue.VECTOR,
|
||||
maxDistance: 0.6,
|
||||
type: AssetType.IMAGE,
|
||||
userIds: [DummyValue.UUID],
|
||||
|
@ -119,7 +119,6 @@ export class SearchRepository implements ISearchRepository {
|
|||
],
|
||||
})
|
||||
searchDuplicates({ assetId, embedding, maxDistance, type, userIds }: AssetDuplicateSearch) {
|
||||
const vector = asVector(embedding);
|
||||
return this.db
|
||||
.with('cte', (qb) =>
|
||||
qb
|
||||
|
@ -127,7 +126,7 @@ export class SearchRepository implements ISearchRepository {
|
|||
.select([
|
||||
'assets.id as assetId',
|
||||
'assets.duplicateId',
|
||||
sql<number>`smart_search.embedding <=> ${vector}`.as('distance'),
|
||||
sql<number>`smart_search.embedding <=> ${embedding}`.as('distance'),
|
||||
])
|
||||
.innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
|
||||
.where('assets.ownerId', '=', anyUuid(userIds))
|
||||
|
@ -135,7 +134,7 @@ export class SearchRepository implements ISearchRepository {
|
|||
.where('assets.isVisible', '=', true)
|
||||
.where('assets.type', '=', type)
|
||||
.where('assets.id', '!=', asUuid(assetId))
|
||||
.orderBy(sql`smart_search.embedding <=> ${vector}`)
|
||||
.orderBy(sql`smart_search.embedding <=> ${embedding}`)
|
||||
.limit(64),
|
||||
)
|
||||
.selectFrom('cte')
|
||||
|
@ -148,7 +147,7 @@ export class SearchRepository implements ISearchRepository {
|
|||
params: [
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
embedding: Array.from({ length: 512 }, Math.random),
|
||||
embedding: DummyValue.VECTOR,
|
||||
numResults: 10,
|
||||
maxDistance: 0.6,
|
||||
},
|
||||
|
@ -159,7 +158,6 @@ export class SearchRepository implements ISearchRepository {
|
|||
throw new Error(`Invalid value for 'numResults': ${numResults}`);
|
||||
}
|
||||
|
||||
const vector = asVector(embedding);
|
||||
return this.db
|
||||
.with('cte', (qb) =>
|
||||
qb
|
||||
|
@ -167,14 +165,14 @@ export class SearchRepository implements ISearchRepository {
|
|||
.select([
|
||||
'asset_faces.id',
|
||||
'asset_faces.personId',
|
||||
sql<number>`face_search.embedding <=> ${vector}`.as('distance'),
|
||||
sql<number>`face_search.embedding <=> ${embedding}`.as('distance'),
|
||||
])
|
||||
.innerJoin('assets', 'assets.id', 'asset_faces.assetId')
|
||||
.innerJoin('face_search', 'face_search.faceId', 'asset_faces.id')
|
||||
.where('assets.ownerId', '=', anyUuid(userIds))
|
||||
.where('assets.deletedAt', 'is', null)
|
||||
.$if(!!hasPerson, (qb) => qb.where('asset_faces.personId', 'is not', null))
|
||||
.orderBy(sql`face_search.embedding <=> ${vector}`)
|
||||
.orderBy(sql`face_search.embedding <=> ${embedding}`)
|
||||
.limit(numResults),
|
||||
)
|
||||
.selectFrom('cte')
|
||||
|
@ -258,12 +256,11 @@ export class SearchRepository implements ISearchRepository {
|
|||
.execute() as any as Promise<AssetEntity[]>;
|
||||
}
|
||||
|
||||
async upsert(assetId: string, embedding: number[]): Promise<void> {
|
||||
const vector = asVector(embedding);
|
||||
async upsert(assetId: string, embedding: string): Promise<void> {
|
||||
await this.db
|
||||
.insertInto('smart_search')
|
||||
.values({ assetId: asUuid(assetId), embedding: vector } as any)
|
||||
.onConflict((oc) => oc.column('assetId').doUpdateSet({ embedding: vector } as any))
|
||||
.values({ assetId: asUuid(assetId), embedding } as any)
|
||||
.onConflict((oc) => oc.column('assetId').doUpdateSet({ embedding } as any))
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
|
|
@ -201,21 +201,22 @@ export class AuditService extends BaseService {
|
|||
}
|
||||
}
|
||||
|
||||
const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||
this.personRepository.getAll(pagination),
|
||||
);
|
||||
for await (const people of personPagination) {
|
||||
for (const { id, thumbnailPath } of people) {
|
||||
track(thumbnailPath);
|
||||
const entity = { entityId: id, entityType: PathEntityType.PERSON };
|
||||
if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) {
|
||||
orphans.push({ ...entity, pathType: PersonPathType.FACE, pathValue: thumbnailPath });
|
||||
}
|
||||
let peopleCount = 0;
|
||||
for await (const { id, thumbnailPath } of this.personRepository.getAll()) {
|
||||
track(thumbnailPath);
|
||||
const entity = { entityId: id, entityType: PathEntityType.PERSON };
|
||||
if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) {
|
||||
orphans.push({ ...entity, pathType: PersonPathType.FACE, pathValue: thumbnailPath });
|
||||
}
|
||||
|
||||
this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${people.length} people`);
|
||||
if (peopleCount === JOBS_ASSET_PAGINATION_SIZE) {
|
||||
this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${peopleCount} people`);
|
||||
peopleCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${peopleCount} people`);
|
||||
|
||||
const extras: string[] = [];
|
||||
for (const file of allFiles) {
|
||||
extras.push(file);
|
||||
|
|
|
@ -25,7 +25,7 @@ import { assetStub } from 'test/fixtures/asset.stub';
|
|||
import { faceStub } from 'test/fixtures/face.stub';
|
||||
import { probeStub } from 'test/fixtures/media.stub';
|
||||
import { personStub } from 'test/fixtures/person.stub';
|
||||
import { newTestService } from 'test/utils';
|
||||
import { makeStream, newTestService } from 'test/utils';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
describe(MediaService.name, () => {
|
||||
|
@ -55,10 +55,8 @@ describe(MediaService.name, () => {
|
|||
items: [assetStub.image],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAll.mockResolvedValue({
|
||||
items: [personStub.newThumbnail],
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
personMock.getAll.mockReturnValue(makeStream([personStub.newThumbnail]));
|
||||
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
||||
|
||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||
|
@ -72,7 +70,7 @@ describe(MediaService.name, () => {
|
|||
},
|
||||
]);
|
||||
|
||||
expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, {});
|
||||
expect(personMock.getAll).toHaveBeenCalledWith(undefined);
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||
|
@ -86,10 +84,7 @@ describe(MediaService.name, () => {
|
|||
items: [assetStub.trashed],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAll.mockResolvedValue({
|
||||
items: [],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAll.mockReturnValue(makeStream());
|
||||
|
||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||
|
||||
|
@ -111,10 +106,7 @@ describe(MediaService.name, () => {
|
|||
items: [assetStub.archived],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAll.mockResolvedValue({
|
||||
items: [],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAll.mockReturnValue(makeStream());
|
||||
|
||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||
|
||||
|
@ -136,10 +128,7 @@ describe(MediaService.name, () => {
|
|||
items: [assetStub.image],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAll.mockResolvedValue({
|
||||
items: [personStub.noThumbnail, personStub.noThumbnail],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail]));
|
||||
personMock.getRandomFace.mockResolvedValueOnce(faceStub.face1);
|
||||
|
||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||
|
@ -147,7 +136,7 @@ describe(MediaService.name, () => {
|
|||
expect(assetMock.getAll).not.toHaveBeenCalled();
|
||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
|
||||
|
||||
expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } });
|
||||
expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
|
||||
expect(personMock.getRandomFace).toHaveBeenCalled();
|
||||
expect(personMock.update).toHaveBeenCalledTimes(1);
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
|
@ -165,11 +154,7 @@ describe(MediaService.name, () => {
|
|||
items: [assetStub.noResizePath],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAll.mockResolvedValue({
|
||||
items: [],
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
personMock.getAll.mockReturnValue(makeStream());
|
||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||
|
||||
expect(assetMock.getAll).not.toHaveBeenCalled();
|
||||
|
@ -181,7 +166,7 @@ describe(MediaService.name, () => {
|
|||
},
|
||||
]);
|
||||
|
||||
expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } });
|
||||
expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
|
||||
});
|
||||
|
||||
it('should queue all assets with missing webp path', async () => {
|
||||
|
@ -189,11 +174,7 @@ describe(MediaService.name, () => {
|
|||
items: [assetStub.noWebpPath],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAll.mockResolvedValue({
|
||||
items: [],
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
personMock.getAll.mockReturnValue(makeStream());
|
||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||
|
||||
expect(assetMock.getAll).not.toHaveBeenCalled();
|
||||
|
@ -205,7 +186,7 @@ describe(MediaService.name, () => {
|
|||
},
|
||||
]);
|
||||
|
||||
expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } });
|
||||
expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
|
||||
});
|
||||
|
||||
it('should queue all assets with missing thumbhash', async () => {
|
||||
|
@ -213,11 +194,7 @@ describe(MediaService.name, () => {
|
|||
items: [assetStub.noThumbhash],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAll.mockResolvedValue({
|
||||
items: [],
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
personMock.getAll.mockReturnValue(makeStream());
|
||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||
|
||||
expect(assetMock.getAll).not.toHaveBeenCalled();
|
||||
|
@ -229,7 +206,7 @@ describe(MediaService.name, () => {
|
|||
},
|
||||
]);
|
||||
|
||||
expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } });
|
||||
expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' });
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -237,7 +214,7 @@ describe(MediaService.name, () => {
|
|||
it('should remove empty directories and queue jobs', async () => {
|
||||
assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] });
|
||||
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts);
|
||||
personMock.getAll.mockResolvedValue({ hasNextPage: false, items: [personStub.withName] });
|
||||
personMock.getAll.mockReturnValue(makeStream([personStub.withName]));
|
||||
|
||||
await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.SUCCESS);
|
||||
|
||||
|
@ -730,10 +707,7 @@ describe(MediaService.name, () => {
|
|||
items: [assetStub.video],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAll.mockResolvedValue({
|
||||
items: [],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAll.mockReturnValue(makeStream());
|
||||
|
||||
await sut.handleQueueVideoConversion({ force: true });
|
||||
|
||||
|
|
|
@ -72,23 +72,20 @@ export class MediaService extends BaseService {
|
|||
}
|
||||
|
||||
const jobs: JobItem[] = [];
|
||||
const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||
this.personRepository.getAll(pagination, { where: force ? undefined : { thumbnailPath: '' } }),
|
||||
);
|
||||
|
||||
for await (const people of personPagination) {
|
||||
for (const person of people) {
|
||||
if (!person.faceAssetId) {
|
||||
const face = await this.personRepository.getRandomFace(person.id);
|
||||
if (!face) {
|
||||
continue;
|
||||
}
|
||||
const people = this.personRepository.getAll(force ? undefined : { thumbnailPath: '' });
|
||||
|
||||
await this.personRepository.update({ id: person.id, faceAssetId: face.id });
|
||||
for await (const person of people) {
|
||||
if (!person.faceAssetId) {
|
||||
const face = await this.personRepository.getRandomFace(person.id);
|
||||
if (!face) {
|
||||
continue;
|
||||
}
|
||||
|
||||
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } });
|
||||
await this.personRepository.update({ id: person.id, faceAssetId: face.id });
|
||||
}
|
||||
|
||||
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } });
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
|
@ -114,16 +111,19 @@ export class MediaService extends BaseService {
|
|||
);
|
||||
}
|
||||
|
||||
const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||
this.personRepository.getAll(pagination),
|
||||
);
|
||||
let jobs: { name: JobName.MIGRATE_PERSON; data: { id: string } }[] = [];
|
||||
|
||||
for await (const people of personPagination) {
|
||||
await this.jobRepository.queueAll(
|
||||
people.map((person) => ({ name: JobName.MIGRATE_PERSON, data: { id: person.id } })),
|
||||
);
|
||||
for await (const person of this.personRepository.getAll()) {
|
||||
jobs.push({ name: JobName.MIGRATE_PERSON, data: { id: person.id } });
|
||||
|
||||
if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
jobs = [];
|
||||
}
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
|
|
|
@ -1086,7 +1086,9 @@ describe(MetadataService.name, () => {
|
|||
],
|
||||
[],
|
||||
);
|
||||
expect(personMock.updateAll).toHaveBeenCalledWith([{ id: 'random-uuid', faceAssetId: 'random-uuid' }]);
|
||||
expect(personMock.updateAll).toHaveBeenCalledWith([
|
||||
{ id: 'random-uuid', ownerId: 'admin-id', faceAssetId: 'random-uuid' },
|
||||
]);
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||
|
|
|
@ -509,11 +509,11 @@ export class MetadataService extends BaseService {
|
|||
return;
|
||||
}
|
||||
|
||||
const facesToAdd: Partial<AssetFaceEntity>[] = [];
|
||||
const facesToAdd: (Partial<AssetFaceEntity> & { assetId: string })[] = [];
|
||||
const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true });
|
||||
const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id]));
|
||||
const missing: Partial<PersonEntity>[] = [];
|
||||
const missingWithFaceAsset: Partial<PersonEntity>[] = [];
|
||||
const missing: (Partial<PersonEntity> & { ownerId: string })[] = [];
|
||||
const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = [];
|
||||
for (const region of tags.RegionInfo.RegionList) {
|
||||
if (!region.Name) {
|
||||
continue;
|
||||
|
@ -540,7 +540,7 @@ export class MetadataService extends BaseService {
|
|||
facesToAdd.push(face);
|
||||
if (!existingNameMap.has(loweredName)) {
|
||||
missing.push({ id: personId, ownerId: asset.ownerId, name: region.Name });
|
||||
missingWithFaceAsset.push({ id: personId, faceAssetId: face.id });
|
||||
missingWithFaceAsset.push({ id: personId, ownerId: asset.ownerId, faceAssetId: face.id });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -557,7 +557,7 @@ export class MetadataService extends BaseService {
|
|||
}
|
||||
|
||||
if (facesToAdd.length > 0) {
|
||||
this.logger.debug(`Creating ${facesToAdd} faces from metadata for asset ${asset.id}`);
|
||||
this.logger.debug(`Creating ${facesToAdd.length} faces from metadata for asset ${asset.id}`);
|
||||
}
|
||||
|
||||
if (facesToRemove.length > 0 || facesToAdd.length > 0) {
|
||||
|
|
|
@ -20,8 +20,7 @@ import { faceStub } from 'test/fixtures/face.stub';
|
|||
import { personStub } from 'test/fixtures/person.stub';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
import { newTestService } from 'test/utils';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { makeStream, newTestService } from 'test/utils';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
const responseDto: PersonResponseDto = {
|
||||
|
@ -46,7 +45,7 @@ const face = {
|
|||
imageHeight: 500,
|
||||
imageWidth: 400,
|
||||
};
|
||||
const faceSearch = { faceId, embedding: [1, 2, 3, 4] };
|
||||
const faceSearch = { faceId, embedding: '[1, 2, 3, 4]' };
|
||||
const detectFaceMock: DetectedFaces = {
|
||||
faces: [
|
||||
{
|
||||
|
@ -495,14 +494,8 @@ describe(PersonService.name, () => {
|
|||
});
|
||||
|
||||
it('should delete existing people and faces if forced', async () => {
|
||||
personMock.getAll.mockResolvedValue({
|
||||
items: [faceStub.face1.person, personStub.randomPerson],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAllFaces.mockResolvedValue({
|
||||
items: [faceStub.face1],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson]));
|
||||
personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
|
||||
assetMock.getAll.mockResolvedValue({
|
||||
items: [assetStub.image],
|
||||
hasNextPage: false,
|
||||
|
@ -544,18 +537,12 @@ describe(PersonService.name, () => {
|
|||
|
||||
it('should queue missing assets', async () => {
|
||||
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
|
||||
personMock.getAllFaces.mockResolvedValue({
|
||||
items: [faceStub.face1],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
|
||||
personMock.getAllWithoutFaces.mockResolvedValue([]);
|
||||
|
||||
await sut.handleQueueRecognizeFaces({});
|
||||
|
||||
expect(personMock.getAllFaces).toHaveBeenCalledWith(
|
||||
{ skip: 0, take: 1000 },
|
||||
{ where: { personId: IsNull(), sourceType: SourceType.MACHINE_LEARNING } },
|
||||
);
|
||||
expect(personMock.getAllFaces).toHaveBeenCalledWith({ personId: null, sourceType: SourceType.MACHINE_LEARNING });
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.FACIAL_RECOGNITION,
|
||||
|
@ -569,19 +556,13 @@ describe(PersonService.name, () => {
|
|||
|
||||
it('should queue all assets', async () => {
|
||||
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
|
||||
personMock.getAll.mockResolvedValue({
|
||||
items: [],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAllFaces.mockResolvedValue({
|
||||
items: [faceStub.face1],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAll.mockReturnValue(makeStream());
|
||||
personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
|
||||
personMock.getAllWithoutFaces.mockResolvedValue([]);
|
||||
|
||||
await sut.handleQueueRecognizeFaces({ force: true });
|
||||
|
||||
expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, {});
|
||||
expect(personMock.getAllFaces).toHaveBeenCalledWith(undefined);
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.FACIAL_RECOGNITION,
|
||||
|
@ -595,26 +576,17 @@ describe(PersonService.name, () => {
|
|||
|
||||
it('should run nightly if new face has been added since last run', async () => {
|
||||
personMock.getLatestFaceDate.mockResolvedValue(new Date().toISOString());
|
||||
personMock.getAllFaces.mockResolvedValue({
|
||||
items: [faceStub.face1],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
|
||||
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
|
||||
personMock.getAll.mockResolvedValue({
|
||||
items: [],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAllFaces.mockResolvedValue({
|
||||
items: [faceStub.face1],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAll.mockReturnValue(makeStream());
|
||||
personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
|
||||
personMock.getAllWithoutFaces.mockResolvedValue([]);
|
||||
|
||||
await sut.handleQueueRecognizeFaces({ force: true, nightly: true });
|
||||
|
||||
expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE);
|
||||
expect(personMock.getLatestFaceDate).toHaveBeenCalledOnce();
|
||||
expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, {});
|
||||
expect(personMock.getAllFaces).toHaveBeenCalledWith(undefined);
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.FACIAL_RECOGNITION,
|
||||
|
@ -631,10 +603,7 @@ describe(PersonService.name, () => {
|
|||
|
||||
systemMock.get.mockResolvedValue({ lastRun: lastRun.toISOString() });
|
||||
personMock.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString());
|
||||
personMock.getAllFaces.mockResolvedValue({
|
||||
items: [faceStub.face1],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
|
||||
personMock.getAllWithoutFaces.mockResolvedValue([]);
|
||||
|
||||
await sut.handleQueueRecognizeFaces({ force: true, nightly: true });
|
||||
|
@ -648,15 +617,8 @@ describe(PersonService.name, () => {
|
|||
|
||||
it('should delete existing people if forced', async () => {
|
||||
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
|
||||
personMock.getAll.mockResolvedValue({
|
||||
items: [faceStub.face1.person, personStub.randomPerson],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getAllFaces.mockResolvedValue({
|
||||
items: [faceStub.face1],
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
personMock.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson]));
|
||||
personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
|
||||
personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]);
|
||||
|
||||
await sut.handleQueueRecognizeFaces({ force: true });
|
||||
|
|
|
@ -50,7 +50,6 @@ import { ImmichFileResponse } from 'src/utils/file';
|
|||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
import { IsNull } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class PersonService extends BaseService {
|
||||
|
@ -306,7 +305,7 @@ export class PersonService extends BaseService {
|
|||
);
|
||||
this.logger.debug(`${faces.length} faces detected in ${previewFile.path}`);
|
||||
|
||||
const facesToAdd: (Partial<AssetFaceEntity> & { id: string })[] = [];
|
||||
const facesToAdd: (Partial<AssetFaceEntity> & { id: string; assetId: string })[] = [];
|
||||
const embeddings: FaceSearchEntity[] = [];
|
||||
const mlFaceIds = new Set<string>();
|
||||
for (const face of asset.faces) {
|
||||
|
@ -414,18 +413,22 @@ export class PersonService extends BaseService {
|
|||
}
|
||||
|
||||
const lastRun = new Date().toISOString();
|
||||
const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||
this.personRepository.getAllFaces(pagination, {
|
||||
where: force ? undefined : { personId: IsNull(), sourceType: SourceType.MACHINE_LEARNING },
|
||||
}),
|
||||
const facePagination = this.personRepository.getAllFaces(
|
||||
force ? undefined : { personId: null, sourceType: SourceType.MACHINE_LEARNING },
|
||||
);
|
||||
|
||||
for await (const page of facePagination) {
|
||||
await this.jobRepository.queueAll(
|
||||
page.map((face) => ({ name: JobName.FACIAL_RECOGNITION, data: { id: face.id, deferred: false } })),
|
||||
);
|
||||
let jobs: { name: JobName.FACIAL_RECOGNITION; data: { id: string; deferred: false } }[] = [];
|
||||
for await (const face of facePagination) {
|
||||
jobs.push({ name: JobName.FACIAL_RECOGNITION, data: { id: face.id, deferred: false } });
|
||||
|
||||
if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
jobs = [];
|
||||
}
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun });
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
|
@ -441,7 +444,7 @@ export class PersonService extends BaseService {
|
|||
const face = await this.personRepository.getFaceByIdWithAssets(
|
||||
id,
|
||||
{ person: true, asset: true, faceSearch: true },
|
||||
{ id: true, personId: true, sourceType: true, faceSearch: { embedding: true } },
|
||||
{ id: true, personId: true, sourceType: true, faceSearch: true },
|
||||
);
|
||||
if (!face || !face.asset) {
|
||||
this.logger.warn(`Face ${id} not found`);
|
||||
|
|
|
@ -284,7 +284,7 @@ describe(SmartInfoService.name, () => {
|
|||
});
|
||||
|
||||
it('should save the returned objects', async () => {
|
||||
machineLearningMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
|
||||
machineLearningMock.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]');
|
||||
|
||||
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS);
|
||||
|
||||
|
@ -293,7 +293,7 @@ describe(SmartInfoService.name, () => {
|
|||
'/uploads/user-id/thumbs/path.jpg',
|
||||
expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
|
||||
);
|
||||
expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, [0.01, 0.02, 0.03]);
|
||||
expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]');
|
||||
});
|
||||
|
||||
it('should skip invisible assets', async () => {
|
||||
|
@ -315,7 +315,7 @@ describe(SmartInfoService.name, () => {
|
|||
});
|
||||
|
||||
it('should wait for database', async () => {
|
||||
machineLearningMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
|
||||
machineLearningMock.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]');
|
||||
databaseMock.isBusy.mockReturnValue(true);
|
||||
|
||||
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS);
|
||||
|
@ -326,7 +326,7 @@ describe(SmartInfoService.name, () => {
|
|||
'/uploads/user-id/thumbs/path.jpg',
|
||||
expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
|
||||
);
|
||||
expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, [0.01, 0.02, 0.03]);
|
||||
expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -42,7 +42,7 @@ export const asUuid = (id: string | Expression<string>) => sql<string>`${id}::uu
|
|||
|
||||
export const anyUuid = (ids: string[]) => sql<string>`any(${`{${ids}}`}::uuid[])`;
|
||||
|
||||
export const asVector = (embedding: number[]) => sql<number[]>`${`[${embedding}]`}::vector`;
|
||||
export const asVector = (embedding: number[]) => sql<string>`${`[${embedding}]`}::vector`;
|
||||
|
||||
/**
|
||||
* Mainly for type debugging to make VS Code display a more useful tooltip.
|
||||
|
|
4
server/test/fixtures/asset.stub.ts
vendored
4
server/test/fixtures/asset.stub.ts
vendored
|
@ -824,7 +824,7 @@ export const assetStub = {
|
|||
duplicateId: null,
|
||||
smartSearch: {
|
||||
assetId: 'asset-id',
|
||||
embedding: Array.from({ length: 512 }, Math.random),
|
||||
embedding: '[1, 2, 3, 4]',
|
||||
},
|
||||
isOffline: false,
|
||||
}),
|
||||
|
@ -866,7 +866,7 @@ export const assetStub = {
|
|||
duplicateId: 'duplicate-id',
|
||||
smartSearch: {
|
||||
assetId: 'asset-id',
|
||||
embedding: Array.from({ length: 512 }, Math.random),
|
||||
embedding: '[1, 2, 3, 4]',
|
||||
},
|
||||
isOffline: false,
|
||||
}),
|
||||
|
|
16
server/test/fixtures/face.stub.ts
vendored
16
server/test/fixtures/face.stub.ts
vendored
|
@ -19,7 +19,7 @@ export const faceStub = {
|
|||
imageHeight: 1024,
|
||||
imageWidth: 1024,
|
||||
sourceType: SourceType.MACHINE_LEARNING,
|
||||
faceSearch: { faceId: 'assetFaceId1', embedding: [1, 2, 3, 4] },
|
||||
faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' },
|
||||
}),
|
||||
primaryFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||
id: 'assetFaceId2',
|
||||
|
@ -34,7 +34,7 @@ export const faceStub = {
|
|||
imageHeight: 1024,
|
||||
imageWidth: 1024,
|
||||
sourceType: SourceType.MACHINE_LEARNING,
|
||||
faceSearch: { faceId: 'assetFaceId2', embedding: [1, 2, 3, 4] },
|
||||
faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' },
|
||||
}),
|
||||
mergeFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||
id: 'assetFaceId3',
|
||||
|
@ -49,7 +49,7 @@ export const faceStub = {
|
|||
imageHeight: 1024,
|
||||
imageWidth: 1024,
|
||||
sourceType: SourceType.MACHINE_LEARNING,
|
||||
faceSearch: { faceId: 'assetFaceId3', embedding: [1, 2, 3, 4] },
|
||||
faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' },
|
||||
}),
|
||||
start: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||
id: 'assetFaceId5',
|
||||
|
@ -64,7 +64,7 @@ export const faceStub = {
|
|||
imageHeight: 2880,
|
||||
imageWidth: 2160,
|
||||
sourceType: SourceType.MACHINE_LEARNING,
|
||||
faceSearch: { faceId: 'assetFaceId5', embedding: [1, 2, 3, 4] },
|
||||
faceSearch: { faceId: 'assetFaceId5', embedding: '[1, 2, 3, 4]' },
|
||||
}),
|
||||
middle: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||
id: 'assetFaceId6',
|
||||
|
@ -79,7 +79,7 @@ export const faceStub = {
|
|||
imageHeight: 500,
|
||||
imageWidth: 400,
|
||||
sourceType: SourceType.MACHINE_LEARNING,
|
||||
faceSearch: { faceId: 'assetFaceId6', embedding: [1, 2, 3, 4] },
|
||||
faceSearch: { faceId: 'assetFaceId6', embedding: '[1, 2, 3, 4]' },
|
||||
}),
|
||||
end: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||
id: 'assetFaceId7',
|
||||
|
@ -94,7 +94,7 @@ export const faceStub = {
|
|||
imageHeight: 500,
|
||||
imageWidth: 500,
|
||||
sourceType: SourceType.MACHINE_LEARNING,
|
||||
faceSearch: { faceId: 'assetFaceId7', embedding: [1, 2, 3, 4] },
|
||||
faceSearch: { faceId: 'assetFaceId7', embedding: '[1, 2, 3, 4]' },
|
||||
}),
|
||||
noPerson1: Object.freeze<AssetFaceEntity>({
|
||||
id: 'assetFaceId8',
|
||||
|
@ -109,7 +109,7 @@ export const faceStub = {
|
|||
imageHeight: 1024,
|
||||
imageWidth: 1024,
|
||||
sourceType: SourceType.MACHINE_LEARNING,
|
||||
faceSearch: { faceId: 'assetFaceId8', embedding: [1, 2, 3, 4] },
|
||||
faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' },
|
||||
}),
|
||||
noPerson2: Object.freeze<AssetFaceEntity>({
|
||||
id: 'assetFaceId9',
|
||||
|
@ -124,7 +124,7 @@ export const faceStub = {
|
|||
imageHeight: 1024,
|
||||
imageWidth: 1024,
|
||||
sourceType: SourceType.MACHINE_LEARNING,
|
||||
faceSearch: { faceId: 'assetFaceId9', embedding: [1, 2, 3, 4] },
|
||||
faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' },
|
||||
}),
|
||||
fromExif1: Object.freeze<AssetFaceEntity>({
|
||||
id: 'assetFaceId9',
|
||||
|
|
|
@ -254,3 +254,10 @@ export const mockSpawn = vitest.fn((exitCode: number, stdout: string, stderr: st
|
|||
}),
|
||||
} as unknown as ChildProcessWithoutNullStreams;
|
||||
});
|
||||
|
||||
export async function* makeStream<T>(items: T[] = []): AsyncIterableIterator<T> {
|
||||
for (const item of items) {
|
||||
await Promise.resolve();
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue