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