mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
feat: facial recognition (#2180)
This commit is contained in:
parent
115a47d4c6
commit
93863b0629
@ -1 +1,3 @@
|
|||||||
venv/
|
venv/
|
||||||
|
*.zip
|
||||||
|
*.onnx
|
168
machine-learning/.gitignore
vendored
168
machine-learning/.gitignore
vendored
@ -3,4 +3,170 @@
|
|||||||
upload/
|
upload/
|
||||||
venv/
|
venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
model-cache/
|
model-cache/
|
||||||
|
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/#use-with-ide
|
||||||
|
.pdm.toml
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
|
||||||
|
*.onnx
|
||||||
|
*.zip
|
@ -8,7 +8,8 @@ RUN python -m venv /opt/venv
|
|||||||
RUN /opt/venv/bin/pip install torch --index-url https://download.pytorch.org/whl/cpu
|
RUN /opt/venv/bin/pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||||
RUN /opt/venv/bin/pip install transformers tqdm numpy scikit-learn scipy nltk sentencepiece fastapi Pillow uvicorn[standard]
|
RUN /opt/venv/bin/pip install transformers tqdm numpy scikit-learn scipy nltk sentencepiece fastapi Pillow uvicorn[standard]
|
||||||
RUN /opt/venv/bin/pip install --no-deps sentence-transformers
|
RUN /opt/venv/bin/pip install --no-deps sentence-transformers
|
||||||
|
# Facial Recognition Stuff
|
||||||
|
RUN /opt/venv/bin/pip install insightface onnxruntime
|
||||||
|
|
||||||
FROM python:3.10-slim
|
FROM python:3.10-slim
|
||||||
|
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
|
import os
|
||||||
|
import numpy as np
|
||||||
|
import cv2 as cv
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from insightface.app import FaceAnalysis
|
||||||
from transformers import pipeline
|
from transformers import pipeline
|
||||||
from sentence_transformers import SentenceTransformer, util
|
from sentence_transformers import SentenceTransformer, util
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
import uvicorn
|
|
||||||
import os
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@ -15,15 +19,6 @@ class ClipRequestBody(BaseModel):
|
|||||||
text: str
|
text: str
|
||||||
|
|
||||||
|
|
||||||
is_dev = os.getenv('NODE_ENV') == 'development'
|
|
||||||
server_port = os.getenv('MACHINE_LEARNING_PORT', 3003)
|
|
||||||
server_host = os.getenv('MACHINE_LEARNING_HOST', '0.0.0.0')
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
"""
|
|
||||||
Model Initialization
|
|
||||||
"""
|
|
||||||
classification_model = os.getenv(
|
classification_model = os.getenv(
|
||||||
'MACHINE_LEARNING_CLASSIFICATION_MODEL', 'microsoft/resnet-50')
|
'MACHINE_LEARNING_CLASSIFICATION_MODEL', 'microsoft/resnet-50')
|
||||||
object_model = os.getenv('MACHINE_LEARNING_OBJECT_MODEL', 'hustvl/yolos-tiny')
|
object_model = os.getenv('MACHINE_LEARNING_OBJECT_MODEL', 'hustvl/yolos-tiny')
|
||||||
@ -31,9 +26,15 @@ clip_image_model = os.getenv(
|
|||||||
'MACHINE_LEARNING_CLIP_IMAGE_MODEL', 'clip-ViT-B-32')
|
'MACHINE_LEARNING_CLIP_IMAGE_MODEL', 'clip-ViT-B-32')
|
||||||
clip_text_model = os.getenv(
|
clip_text_model = os.getenv(
|
||||||
'MACHINE_LEARNING_CLIP_TEXT_MODEL', 'clip-ViT-B-32')
|
'MACHINE_LEARNING_CLIP_TEXT_MODEL', 'clip-ViT-B-32')
|
||||||
|
facial_recognition_model = os.getenv(
|
||||||
|
'MACHINE_LEARNING_FACIAL_RECOGNITION_MODEL', 'buffalo_l')
|
||||||
|
|
||||||
|
cache_folder = os.getenv('MACHINE_LEARNING_CACHE_FOLDER', '/cache')
|
||||||
|
|
||||||
_model_cache = {}
|
_model_cache = {}
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
@ -73,6 +74,36 @@ def clip_encode_text(payload: ClipRequestBody):
|
|||||||
return model.encode(text).tolist()
|
return model.encode(text).tolist()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/facial-recognition/detect-faces", status_code=200)
|
||||||
|
def facial_recognition(payload: MlRequestBody):
|
||||||
|
model = _get_model(facial_recognition_model, 'facial-recognition')
|
||||||
|
assetPath = payload.thumbnailPath
|
||||||
|
img = cv.imread(assetPath)
|
||||||
|
height, width, _ = img.shape
|
||||||
|
results = []
|
||||||
|
faces = model.get(img)
|
||||||
|
for face in faces:
|
||||||
|
if face.det_score < 0.7:
|
||||||
|
continue
|
||||||
|
x1, y1, x2, y2 = face.bbox
|
||||||
|
# min face size as percent of original image
|
||||||
|
# if (x2 - x1) / width < 0.03 or (y2 - y1) / height < 0.05:
|
||||||
|
# continue
|
||||||
|
results.append({
|
||||||
|
"imageWidth": width,
|
||||||
|
"imageHeight": height,
|
||||||
|
"boundingBox": {
|
||||||
|
"x1": round(x1),
|
||||||
|
"y1": round(y1),
|
||||||
|
"x2": round(x2),
|
||||||
|
"y2": round(y2),
|
||||||
|
},
|
||||||
|
"score": face.det_score.item(),
|
||||||
|
"embedding": face.normed_embedding.tolist()
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def run_engine(engine, path):
|
def run_engine(engine, path):
|
||||||
result = []
|
result = []
|
||||||
predictions = engine(path)
|
predictions = engine(path)
|
||||||
@ -93,12 +124,22 @@ def _get_model(model, task=None):
|
|||||||
key = '|'.join([model, str(task)])
|
key = '|'.join([model, str(task)])
|
||||||
if key not in _model_cache:
|
if key not in _model_cache:
|
||||||
if task:
|
if task:
|
||||||
_model_cache[key] = pipeline(model=model, task=task)
|
if task == 'facial-recognition':
|
||||||
|
face_model = FaceAnalysis(
|
||||||
|
name=model, root=cache_folder, allowed_modules=["detection", "recognition"])
|
||||||
|
face_model.prepare(ctx_id=0, det_size=(640, 640))
|
||||||
|
_model_cache[key] = face_model
|
||||||
|
else:
|
||||||
|
_model_cache[key] = pipeline(model=model, task=task)
|
||||||
else:
|
else:
|
||||||
_model_cache[key] = SentenceTransformer(model)
|
_model_cache[key] = SentenceTransformer(
|
||||||
|
model, cache_folder=cache_folder)
|
||||||
return _model_cache[key]
|
return _model_cache[key]
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
uvicorn.run("main:app", host=server_host,
|
host = os.getenv('MACHINE_LEARNING_HOST', '0.0.0.0')
|
||||||
port=int(server_port), reload=is_dev, workers=1)
|
port = int(os.getenv('MACHINE_LEARNING_PORT', 3003))
|
||||||
|
is_dev = os.getenv('NODE_ENV') == 'development'
|
||||||
|
|
||||||
|
uvicorn.run("main:app", host=host, port=port, reload=is_dev, workers=1)
|
||||||
|
9
mobile/openapi/.openapi-generator/FILES
generated
9
mobile/openapi/.openapi-generator/FILES
generated
@ -61,6 +61,9 @@ doc/OAuthCallbackDto.md
|
|||||||
doc/OAuthConfigDto.md
|
doc/OAuthConfigDto.md
|
||||||
doc/OAuthConfigResponseDto.md
|
doc/OAuthConfigResponseDto.md
|
||||||
doc/PartnerApi.md
|
doc/PartnerApi.md
|
||||||
|
doc/PersonApi.md
|
||||||
|
doc/PersonResponseDto.md
|
||||||
|
doc/PersonUpdateDto.md
|
||||||
doc/QueueStatusDto.md
|
doc/QueueStatusDto.md
|
||||||
doc/RemoveAssetsDto.md
|
doc/RemoveAssetsDto.md
|
||||||
doc/SearchAlbumResponseDto.md
|
doc/SearchAlbumResponseDto.md
|
||||||
@ -113,6 +116,7 @@ lib/api/authentication_api.dart
|
|||||||
lib/api/job_api.dart
|
lib/api/job_api.dart
|
||||||
lib/api/o_auth_api.dart
|
lib/api/o_auth_api.dart
|
||||||
lib/api/partner_api.dart
|
lib/api/partner_api.dart
|
||||||
|
lib/api/person_api.dart
|
||||||
lib/api/search_api.dart
|
lib/api/search_api.dart
|
||||||
lib/api/server_info_api.dart
|
lib/api/server_info_api.dart
|
||||||
lib/api/share_api.dart
|
lib/api/share_api.dart
|
||||||
@ -178,6 +182,8 @@ lib/model/map_marker_response_dto.dart
|
|||||||
lib/model/o_auth_callback_dto.dart
|
lib/model/o_auth_callback_dto.dart
|
||||||
lib/model/o_auth_config_dto.dart
|
lib/model/o_auth_config_dto.dart
|
||||||
lib/model/o_auth_config_response_dto.dart
|
lib/model/o_auth_config_response_dto.dart
|
||||||
|
lib/model/person_response_dto.dart
|
||||||
|
lib/model/person_update_dto.dart
|
||||||
lib/model/queue_status_dto.dart
|
lib/model/queue_status_dto.dart
|
||||||
lib/model/remove_assets_dto.dart
|
lib/model/remove_assets_dto.dart
|
||||||
lib/model/search_album_response_dto.dart
|
lib/model/search_album_response_dto.dart
|
||||||
@ -274,6 +280,9 @@ test/o_auth_callback_dto_test.dart
|
|||||||
test/o_auth_config_dto_test.dart
|
test/o_auth_config_dto_test.dart
|
||||||
test/o_auth_config_response_dto_test.dart
|
test/o_auth_config_response_dto_test.dart
|
||||||
test/partner_api_test.dart
|
test/partner_api_test.dart
|
||||||
|
test/person_api_test.dart
|
||||||
|
test/person_response_dto_test.dart
|
||||||
|
test/person_update_dto_test.dart
|
||||||
test/queue_status_dto_test.dart
|
test/queue_status_dto_test.dart
|
||||||
test/remove_assets_dto_test.dart
|
test/remove_assets_dto_test.dart
|
||||||
test/search_album_response_dto_test.dart
|
test/search_album_response_dto_test.dart
|
||||||
|
7
mobile/openapi/README.md
generated
7
mobile/openapi/README.md
generated
@ -132,6 +132,11 @@ Class | Method | HTTP request | Description
|
|||||||
*PartnerApi* | [**createPartner**](doc//PartnerApi.md#createpartner) | **POST** /partner/{id} |
|
*PartnerApi* | [**createPartner**](doc//PartnerApi.md#createpartner) | **POST** /partner/{id} |
|
||||||
*PartnerApi* | [**getPartners**](doc//PartnerApi.md#getpartners) | **GET** /partner |
|
*PartnerApi* | [**getPartners**](doc//PartnerApi.md#getpartners) | **GET** /partner |
|
||||||
*PartnerApi* | [**removePartner**](doc//PartnerApi.md#removepartner) | **DELETE** /partner/{id} |
|
*PartnerApi* | [**removePartner**](doc//PartnerApi.md#removepartner) | **DELETE** /partner/{id} |
|
||||||
|
*PersonApi* | [**getAllPeople**](doc//PersonApi.md#getallpeople) | **GET** /person |
|
||||||
|
*PersonApi* | [**getPerson**](doc//PersonApi.md#getperson) | **GET** /person/{id} |
|
||||||
|
*PersonApi* | [**getPersonAssets**](doc//PersonApi.md#getpersonassets) | **GET** /person/{id}/assets |
|
||||||
|
*PersonApi* | [**getPersonThumbnail**](doc//PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail |
|
||||||
|
*PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} |
|
||||||
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore |
|
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore |
|
||||||
*SearchApi* | [**getSearchConfig**](doc//SearchApi.md#getsearchconfig) | **GET** /search/config |
|
*SearchApi* | [**getSearchConfig**](doc//SearchApi.md#getsearchconfig) | **GET** /search/config |
|
||||||
*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search |
|
*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search |
|
||||||
@ -218,6 +223,8 @@ Class | Method | HTTP request | Description
|
|||||||
- [OAuthCallbackDto](doc//OAuthCallbackDto.md)
|
- [OAuthCallbackDto](doc//OAuthCallbackDto.md)
|
||||||
- [OAuthConfigDto](doc//OAuthConfigDto.md)
|
- [OAuthConfigDto](doc//OAuthConfigDto.md)
|
||||||
- [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md)
|
- [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md)
|
||||||
|
- [PersonResponseDto](doc//PersonResponseDto.md)
|
||||||
|
- [PersonUpdateDto](doc//PersonUpdateDto.md)
|
||||||
- [QueueStatusDto](doc//QueueStatusDto.md)
|
- [QueueStatusDto](doc//QueueStatusDto.md)
|
||||||
- [RemoveAssetsDto](doc//RemoveAssetsDto.md)
|
- [RemoveAssetsDto](doc//RemoveAssetsDto.md)
|
||||||
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
|
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
|
||||||
|
1
mobile/openapi/doc/AllJobStatusResponseDto.md
generated
1
mobile/openapi/doc/AllJobStatusResponseDto.md
generated
@ -16,6 +16,7 @@ Name | Type | Description | Notes
|
|||||||
**storageTemplateMigrationQueue** | [**JobStatusDto**](JobStatusDto.md) | |
|
**storageTemplateMigrationQueue** | [**JobStatusDto**](JobStatusDto.md) | |
|
||||||
**backgroundTaskQueue** | [**JobStatusDto**](JobStatusDto.md) | |
|
**backgroundTaskQueue** | [**JobStatusDto**](JobStatusDto.md) | |
|
||||||
**searchQueue** | [**JobStatusDto**](JobStatusDto.md) | |
|
**searchQueue** | [**JobStatusDto**](JobStatusDto.md) | |
|
||||||
|
**recognizeFacesQueue** | [**JobStatusDto**](JobStatusDto.md) | |
|
||||||
|
|
||||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
1
mobile/openapi/doc/AssetResponseDto.md
generated
1
mobile/openapi/doc/AssetResponseDto.md
generated
@ -29,6 +29,7 @@ Name | Type | Description | Notes
|
|||||||
**smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional]
|
**smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional]
|
||||||
**livePhotoVideoId** | **String** | | [optional]
|
**livePhotoVideoId** | **String** | | [optional]
|
||||||
**tags** | [**List<TagResponseDto>**](TagResponseDto.md) | | [optional] [default to const []]
|
**tags** | [**List<TagResponseDto>**](TagResponseDto.md) | | [optional] [default to const []]
|
||||||
|
**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [optional] [default to const []]
|
||||||
|
|
||||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
291
mobile/openapi/doc/PersonApi.md
generated
Normal file
291
mobile/openapi/doc/PersonApi.md
generated
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
# openapi.api.PersonApi
|
||||||
|
|
||||||
|
## Load the API package
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
```
|
||||||
|
|
||||||
|
All URIs are relative to */api*
|
||||||
|
|
||||||
|
Method | HTTP request | Description
|
||||||
|
------------- | ------------- | -------------
|
||||||
|
[**getAllPeople**](PersonApi.md#getallpeople) | **GET** /person |
|
||||||
|
[**getPerson**](PersonApi.md#getperson) | **GET** /person/{id} |
|
||||||
|
[**getPersonAssets**](PersonApi.md#getpersonassets) | **GET** /person/{id}/assets |
|
||||||
|
[**getPersonThumbnail**](PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail |
|
||||||
|
[**updatePerson**](PersonApi.md#updateperson) | **PUT** /person/{id} |
|
||||||
|
|
||||||
|
|
||||||
|
# **getAllPeople**
|
||||||
|
> List<PersonResponseDto> getAllPeople()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
// TODO Configure API key authorization: cookie
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||||
|
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||||
|
// TODO Configure API key authorization: api_key
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||||
|
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||||
|
// TODO Configure HTTP Bearer authorization: bearer
|
||||||
|
// Case 1. Use String Token
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||||
|
// Case 2. Use Function which generate token.
|
||||||
|
// String yourTokenGeneratorFunction() { ... }
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||||
|
|
||||||
|
final api_instance = PersonApi();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = api_instance.getAllPeople();
|
||||||
|
print(result);
|
||||||
|
} catch (e) {
|
||||||
|
print('Exception when calling PersonApi->getAllPeople: $e\n');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
This endpoint does not need any parameter.
|
||||||
|
|
||||||
|
### Return type
|
||||||
|
|
||||||
|
[**List<PersonResponseDto>**](PersonResponseDto.md)
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
|
||||||
|
|
||||||
|
### HTTP request headers
|
||||||
|
|
||||||
|
- **Content-Type**: Not defined
|
||||||
|
- **Accept**: application/json
|
||||||
|
|
||||||
|
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
# **getPerson**
|
||||||
|
> PersonResponseDto getPerson(id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
// TODO Configure API key authorization: cookie
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||||
|
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||||
|
// TODO Configure API key authorization: api_key
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||||
|
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||||
|
// TODO Configure HTTP Bearer authorization: bearer
|
||||||
|
// Case 1. Use String Token
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||||
|
// Case 2. Use Function which generate token.
|
||||||
|
// String yourTokenGeneratorFunction() { ... }
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||||
|
|
||||||
|
final api_instance = PersonApi();
|
||||||
|
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = api_instance.getPerson(id);
|
||||||
|
print(result);
|
||||||
|
} catch (e) {
|
||||||
|
print('Exception when calling PersonApi->getPerson: $e\n');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
Name | Type | Description | Notes
|
||||||
|
------------- | ------------- | ------------- | -------------
|
||||||
|
**id** | **String**| |
|
||||||
|
|
||||||
|
### Return type
|
||||||
|
|
||||||
|
[**PersonResponseDto**](PersonResponseDto.md)
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
|
||||||
|
|
||||||
|
### HTTP request headers
|
||||||
|
|
||||||
|
- **Content-Type**: Not defined
|
||||||
|
- **Accept**: application/json
|
||||||
|
|
||||||
|
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
# **getPersonAssets**
|
||||||
|
> List<AssetResponseDto> getPersonAssets(id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
// TODO Configure API key authorization: cookie
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||||
|
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||||
|
// TODO Configure API key authorization: api_key
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||||
|
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||||
|
// TODO Configure HTTP Bearer authorization: bearer
|
||||||
|
// Case 1. Use String Token
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||||
|
// Case 2. Use Function which generate token.
|
||||||
|
// String yourTokenGeneratorFunction() { ... }
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||||
|
|
||||||
|
final api_instance = PersonApi();
|
||||||
|
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = api_instance.getPersonAssets(id);
|
||||||
|
print(result);
|
||||||
|
} catch (e) {
|
||||||
|
print('Exception when calling PersonApi->getPersonAssets: $e\n');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
Name | Type | Description | Notes
|
||||||
|
------------- | ------------- | ------------- | -------------
|
||||||
|
**id** | **String**| |
|
||||||
|
|
||||||
|
### Return type
|
||||||
|
|
||||||
|
[**List<AssetResponseDto>**](AssetResponseDto.md)
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
|
||||||
|
|
||||||
|
### HTTP request headers
|
||||||
|
|
||||||
|
- **Content-Type**: Not defined
|
||||||
|
- **Accept**: application/json
|
||||||
|
|
||||||
|
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
# **getPersonThumbnail**
|
||||||
|
> MultipartFile getPersonThumbnail(id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
// TODO Configure API key authorization: cookie
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||||
|
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||||
|
// TODO Configure API key authorization: api_key
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||||
|
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||||
|
// TODO Configure HTTP Bearer authorization: bearer
|
||||||
|
// Case 1. Use String Token
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||||
|
// Case 2. Use Function which generate token.
|
||||||
|
// String yourTokenGeneratorFunction() { ... }
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||||
|
|
||||||
|
final api_instance = PersonApi();
|
||||||
|
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = api_instance.getPersonThumbnail(id);
|
||||||
|
print(result);
|
||||||
|
} catch (e) {
|
||||||
|
print('Exception when calling PersonApi->getPersonThumbnail: $e\n');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
Name | Type | Description | Notes
|
||||||
|
------------- | ------------- | ------------- | -------------
|
||||||
|
**id** | **String**| |
|
||||||
|
|
||||||
|
### Return type
|
||||||
|
|
||||||
|
[**MultipartFile**](MultipartFile.md)
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
|
||||||
|
|
||||||
|
### HTTP request headers
|
||||||
|
|
||||||
|
- **Content-Type**: Not defined
|
||||||
|
- **Accept**: application/octet-stream
|
||||||
|
|
||||||
|
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
# **updatePerson**
|
||||||
|
> PersonResponseDto updatePerson(id, personUpdateDto)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
// TODO Configure API key authorization: cookie
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||||
|
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||||
|
// TODO Configure API key authorization: api_key
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||||
|
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||||
|
// TODO Configure HTTP Bearer authorization: bearer
|
||||||
|
// Case 1. Use String Token
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||||
|
// Case 2. Use Function which generate token.
|
||||||
|
// String yourTokenGeneratorFunction() { ... }
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||||
|
|
||||||
|
final api_instance = PersonApi();
|
||||||
|
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
|
||||||
|
final personUpdateDto = PersonUpdateDto(); // PersonUpdateDto |
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = api_instance.updatePerson(id, personUpdateDto);
|
||||||
|
print(result);
|
||||||
|
} catch (e) {
|
||||||
|
print('Exception when calling PersonApi->updatePerson: $e\n');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
Name | Type | Description | Notes
|
||||||
|
------------- | ------------- | ------------- | -------------
|
||||||
|
**id** | **String**| |
|
||||||
|
**personUpdateDto** | [**PersonUpdateDto**](PersonUpdateDto.md)| |
|
||||||
|
|
||||||
|
### Return type
|
||||||
|
|
||||||
|
[**PersonResponseDto**](PersonResponseDto.md)
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
|
||||||
|
|
||||||
|
### HTTP request headers
|
||||||
|
|
||||||
|
- **Content-Type**: application/json
|
||||||
|
- **Accept**: application/json
|
||||||
|
|
||||||
|
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||||
|
|
17
mobile/openapi/doc/PersonResponseDto.md
generated
Normal file
17
mobile/openapi/doc/PersonResponseDto.md
generated
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# openapi.model.PersonResponseDto
|
||||||
|
|
||||||
|
## Load the model package
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Properties
|
||||||
|
Name | Type | Description | Notes
|
||||||
|
------------ | ------------- | ------------- | -------------
|
||||||
|
**id** | **String** | |
|
||||||
|
**name** | **String** | |
|
||||||
|
**thumbnailPath** | **String** | |
|
||||||
|
|
||||||
|
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
|
15
mobile/openapi/doc/PersonUpdateDto.md
generated
Normal file
15
mobile/openapi/doc/PersonUpdateDto.md
generated
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# openapi.model.PersonUpdateDto
|
||||||
|
|
||||||
|
## Load the model package
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Properties
|
||||||
|
Name | Type | Description | Notes
|
||||||
|
------------ | ------------- | ------------- | -------------
|
||||||
|
**name** | **String** | |
|
||||||
|
|
||||||
|
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
|
3
mobile/openapi/lib/api.dart
generated
3
mobile/openapi/lib/api.dart
generated
@ -35,6 +35,7 @@ part 'api/authentication_api.dart';
|
|||||||
part 'api/job_api.dart';
|
part 'api/job_api.dart';
|
||||||
part 'api/o_auth_api.dart';
|
part 'api/o_auth_api.dart';
|
||||||
part 'api/partner_api.dart';
|
part 'api/partner_api.dart';
|
||||||
|
part 'api/person_api.dart';
|
||||||
part 'api/search_api.dart';
|
part 'api/search_api.dart';
|
||||||
part 'api/server_info_api.dart';
|
part 'api/server_info_api.dart';
|
||||||
part 'api/share_api.dart';
|
part 'api/share_api.dart';
|
||||||
@ -93,6 +94,8 @@ part 'model/map_marker_response_dto.dart';
|
|||||||
part 'model/o_auth_callback_dto.dart';
|
part 'model/o_auth_callback_dto.dart';
|
||||||
part 'model/o_auth_config_dto.dart';
|
part 'model/o_auth_config_dto.dart';
|
||||||
part 'model/o_auth_config_response_dto.dart';
|
part 'model/o_auth_config_response_dto.dart';
|
||||||
|
part 'model/person_response_dto.dart';
|
||||||
|
part 'model/person_update_dto.dart';
|
||||||
part 'model/queue_status_dto.dart';
|
part 'model/queue_status_dto.dart';
|
||||||
part 'model/remove_assets_dto.dart';
|
part 'model/remove_assets_dto.dart';
|
||||||
part 'model/search_album_response_dto.dart';
|
part 'model/search_album_response_dto.dart';
|
||||||
|
261
mobile/openapi/lib/api/person_api.dart
generated
Normal file
261
mobile/openapi/lib/api/person_api.dart
generated
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.12
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
|
||||||
|
class PersonApi {
|
||||||
|
PersonApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
|
||||||
|
|
||||||
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
/// Performs an HTTP 'GET /person' operation and returns the [Response].
|
||||||
|
Future<Response> getAllPeopleWithHttpInfo() async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/person';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
path,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<PersonResponseDto>?> getAllPeople() async {
|
||||||
|
final response = await getAllPeopleWithHttpInfo();
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
final responseBody = await _decodeBodyBytes(response);
|
||||||
|
return (await apiClient.deserializeAsync(responseBody, 'List<PersonResponseDto>') as List)
|
||||||
|
.cast<PersonResponseDto>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'GET /person/{id}' operation and returns the [Response].
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] id (required):
|
||||||
|
Future<Response> getPersonWithHttpInfo(String id,) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/person/{id}'
|
||||||
|
.replaceAll('{id}', id);
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
path,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] id (required):
|
||||||
|
Future<PersonResponseDto?> getPerson(String id,) async {
|
||||||
|
final response = await getPersonWithHttpInfo(id,);
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'PersonResponseDto',) as PersonResponseDto;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'GET /person/{id}/assets' operation and returns the [Response].
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] id (required):
|
||||||
|
Future<Response> getPersonAssetsWithHttpInfo(String id,) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/person/{id}/assets'
|
||||||
|
.replaceAll('{id}', id);
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
path,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] id (required):
|
||||||
|
Future<List<AssetResponseDto>?> getPersonAssets(String id,) async {
|
||||||
|
final response = await getPersonAssetsWithHttpInfo(id,);
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
final responseBody = await _decodeBodyBytes(response);
|
||||||
|
return (await apiClient.deserializeAsync(responseBody, 'List<AssetResponseDto>') as List)
|
||||||
|
.cast<AssetResponseDto>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'GET /person/{id}/thumbnail' operation and returns the [Response].
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] id (required):
|
||||||
|
Future<Response> getPersonThumbnailWithHttpInfo(String id,) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/person/{id}/thumbnail'
|
||||||
|
.replaceAll('{id}', id);
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
path,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] id (required):
|
||||||
|
Future<MultipartFile?> getPersonThumbnail(String id,) async {
|
||||||
|
final response = await getPersonThumbnailWithHttpInfo(id,);
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'PUT /person/{id}' operation and returns the [Response].
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] id (required):
|
||||||
|
///
|
||||||
|
/// * [PersonUpdateDto] personUpdateDto (required):
|
||||||
|
Future<Response> updatePersonWithHttpInfo(String id, PersonUpdateDto personUpdateDto,) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/person/{id}'
|
||||||
|
.replaceAll('{id}', id);
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody = personUpdateDto;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>['application/json'];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
path,
|
||||||
|
'PUT',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] id (required):
|
||||||
|
///
|
||||||
|
/// * [PersonUpdateDto] personUpdateDto (required):
|
||||||
|
Future<PersonResponseDto?> updatePerson(String id, PersonUpdateDto personUpdateDto,) async {
|
||||||
|
final response = await updatePersonWithHttpInfo(id, personUpdateDto,);
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'PersonResponseDto',) as PersonResponseDto;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
4
mobile/openapi/lib/api_client.dart
generated
4
mobile/openapi/lib/api_client.dart
generated
@ -283,6 +283,10 @@ class ApiClient {
|
|||||||
return OAuthConfigDto.fromJson(value);
|
return OAuthConfigDto.fromJson(value);
|
||||||
case 'OAuthConfigResponseDto':
|
case 'OAuthConfigResponseDto':
|
||||||
return OAuthConfigResponseDto.fromJson(value);
|
return OAuthConfigResponseDto.fromJson(value);
|
||||||
|
case 'PersonResponseDto':
|
||||||
|
return PersonResponseDto.fromJson(value);
|
||||||
|
case 'PersonUpdateDto':
|
||||||
|
return PersonUpdateDto.fromJson(value);
|
||||||
case 'QueueStatusDto':
|
case 'QueueStatusDto':
|
||||||
return QueueStatusDto.fromJson(value);
|
return QueueStatusDto.fromJson(value);
|
||||||
case 'RemoveAssetsDto':
|
case 'RemoveAssetsDto':
|
||||||
|
@ -21,6 +21,7 @@ class AllJobStatusResponseDto {
|
|||||||
required this.storageTemplateMigrationQueue,
|
required this.storageTemplateMigrationQueue,
|
||||||
required this.backgroundTaskQueue,
|
required this.backgroundTaskQueue,
|
||||||
required this.searchQueue,
|
required this.searchQueue,
|
||||||
|
required this.recognizeFacesQueue,
|
||||||
});
|
});
|
||||||
|
|
||||||
JobStatusDto thumbnailGenerationQueue;
|
JobStatusDto thumbnailGenerationQueue;
|
||||||
@ -39,6 +40,8 @@ class AllJobStatusResponseDto {
|
|||||||
|
|
||||||
JobStatusDto searchQueue;
|
JobStatusDto searchQueue;
|
||||||
|
|
||||||
|
JobStatusDto recognizeFacesQueue;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto &&
|
bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto &&
|
||||||
other.thumbnailGenerationQueue == thumbnailGenerationQueue &&
|
other.thumbnailGenerationQueue == thumbnailGenerationQueue &&
|
||||||
@ -48,7 +51,8 @@ class AllJobStatusResponseDto {
|
|||||||
other.clipEncodingQueue == clipEncodingQueue &&
|
other.clipEncodingQueue == clipEncodingQueue &&
|
||||||
other.storageTemplateMigrationQueue == storageTemplateMigrationQueue &&
|
other.storageTemplateMigrationQueue == storageTemplateMigrationQueue &&
|
||||||
other.backgroundTaskQueue == backgroundTaskQueue &&
|
other.backgroundTaskQueue == backgroundTaskQueue &&
|
||||||
other.searchQueue == searchQueue;
|
other.searchQueue == searchQueue &&
|
||||||
|
other.recognizeFacesQueue == recognizeFacesQueue;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
@ -60,10 +64,11 @@ class AllJobStatusResponseDto {
|
|||||||
(clipEncodingQueue.hashCode) +
|
(clipEncodingQueue.hashCode) +
|
||||||
(storageTemplateMigrationQueue.hashCode) +
|
(storageTemplateMigrationQueue.hashCode) +
|
||||||
(backgroundTaskQueue.hashCode) +
|
(backgroundTaskQueue.hashCode) +
|
||||||
(searchQueue.hashCode);
|
(searchQueue.hashCode) +
|
||||||
|
(recognizeFacesQueue.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'AllJobStatusResponseDto[thumbnailGenerationQueue=$thumbnailGenerationQueue, metadataExtractionQueue=$metadataExtractionQueue, videoConversionQueue=$videoConversionQueue, objectTaggingQueue=$objectTaggingQueue, clipEncodingQueue=$clipEncodingQueue, storageTemplateMigrationQueue=$storageTemplateMigrationQueue, backgroundTaskQueue=$backgroundTaskQueue, searchQueue=$searchQueue]';
|
String toString() => 'AllJobStatusResponseDto[thumbnailGenerationQueue=$thumbnailGenerationQueue, metadataExtractionQueue=$metadataExtractionQueue, videoConversionQueue=$videoConversionQueue, objectTaggingQueue=$objectTaggingQueue, clipEncodingQueue=$clipEncodingQueue, storageTemplateMigrationQueue=$storageTemplateMigrationQueue, backgroundTaskQueue=$backgroundTaskQueue, searchQueue=$searchQueue, recognizeFacesQueue=$recognizeFacesQueue]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@ -75,6 +80,7 @@ class AllJobStatusResponseDto {
|
|||||||
json[r'storage-template-migration-queue'] = this.storageTemplateMigrationQueue;
|
json[r'storage-template-migration-queue'] = this.storageTemplateMigrationQueue;
|
||||||
json[r'background-task-queue'] = this.backgroundTaskQueue;
|
json[r'background-task-queue'] = this.backgroundTaskQueue;
|
||||||
json[r'search-queue'] = this.searchQueue;
|
json[r'search-queue'] = this.searchQueue;
|
||||||
|
json[r'recognize-faces-queue'] = this.recognizeFacesQueue;
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,6 +111,7 @@ class AllJobStatusResponseDto {
|
|||||||
storageTemplateMigrationQueue: JobStatusDto.fromJson(json[r'storage-template-migration-queue'])!,
|
storageTemplateMigrationQueue: JobStatusDto.fromJson(json[r'storage-template-migration-queue'])!,
|
||||||
backgroundTaskQueue: JobStatusDto.fromJson(json[r'background-task-queue'])!,
|
backgroundTaskQueue: JobStatusDto.fromJson(json[r'background-task-queue'])!,
|
||||||
searchQueue: JobStatusDto.fromJson(json[r'search-queue'])!,
|
searchQueue: JobStatusDto.fromJson(json[r'search-queue'])!,
|
||||||
|
recognizeFacesQueue: JobStatusDto.fromJson(json[r'recognize-faces-queue'])!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -160,6 +167,7 @@ class AllJobStatusResponseDto {
|
|||||||
'storage-template-migration-queue',
|
'storage-template-migration-queue',
|
||||||
'background-task-queue',
|
'background-task-queue',
|
||||||
'search-queue',
|
'search-queue',
|
||||||
|
'recognize-faces-queue',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
13
mobile/openapi/lib/model/asset_response_dto.dart
generated
13
mobile/openapi/lib/model/asset_response_dto.dart
generated
@ -34,6 +34,7 @@ class AssetResponseDto {
|
|||||||
this.smartInfo,
|
this.smartInfo,
|
||||||
this.livePhotoVideoId,
|
this.livePhotoVideoId,
|
||||||
this.tags = const [],
|
this.tags = const [],
|
||||||
|
this.people = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
AssetTypeEnum type;
|
AssetTypeEnum type;
|
||||||
@ -90,6 +91,8 @@ class AssetResponseDto {
|
|||||||
|
|
||||||
List<TagResponseDto> tags;
|
List<TagResponseDto> tags;
|
||||||
|
|
||||||
|
List<PersonResponseDto> people;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
||||||
other.type == type &&
|
other.type == type &&
|
||||||
@ -112,7 +115,8 @@ class AssetResponseDto {
|
|||||||
other.exifInfo == exifInfo &&
|
other.exifInfo == exifInfo &&
|
||||||
other.smartInfo == smartInfo &&
|
other.smartInfo == smartInfo &&
|
||||||
other.livePhotoVideoId == livePhotoVideoId &&
|
other.livePhotoVideoId == livePhotoVideoId &&
|
||||||
other.tags == tags;
|
other.tags == tags &&
|
||||||
|
other.people == people;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
@ -137,10 +141,11 @@ class AssetResponseDto {
|
|||||||
(exifInfo == null ? 0 : exifInfo!.hashCode) +
|
(exifInfo == null ? 0 : exifInfo!.hashCode) +
|
||||||
(smartInfo == null ? 0 : smartInfo!.hashCode) +
|
(smartInfo == null ? 0 : smartInfo!.hashCode) +
|
||||||
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
|
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
|
||||||
(tags.hashCode);
|
(tags.hashCode) +
|
||||||
|
(people.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, originalFileName=$originalFileName, resizePath=$resizePath, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, updatedAt=$updatedAt, isFavorite=$isFavorite, isArchived=$isArchived, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId, tags=$tags]';
|
String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, originalFileName=$originalFileName, resizePath=$resizePath, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, updatedAt=$updatedAt, isFavorite=$isFavorite, isArchived=$isArchived, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId, tags=$tags, people=$people]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@ -193,6 +198,7 @@ class AssetResponseDto {
|
|||||||
// json[r'livePhotoVideoId'] = null;
|
// json[r'livePhotoVideoId'] = null;
|
||||||
}
|
}
|
||||||
json[r'tags'] = this.tags;
|
json[r'tags'] = this.tags;
|
||||||
|
json[r'people'] = this.people;
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -236,6 +242,7 @@ class AssetResponseDto {
|
|||||||
smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
|
smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
|
||||||
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
|
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
|
||||||
tags: TagResponseDto.listFromJson(json[r'tags']),
|
tags: TagResponseDto.listFromJson(json[r'tags']),
|
||||||
|
people: PersonResponseDto.listFromJson(json[r'people']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
3
mobile/openapi/lib/model/job_name.dart
generated
3
mobile/openapi/lib/model/job_name.dart
generated
@ -27,6 +27,7 @@ class JobName {
|
|||||||
static const metadataExtractionQueue = JobName._(r'metadata-extraction-queue');
|
static const metadataExtractionQueue = JobName._(r'metadata-extraction-queue');
|
||||||
static const videoConversionQueue = JobName._(r'video-conversion-queue');
|
static const videoConversionQueue = JobName._(r'video-conversion-queue');
|
||||||
static const objectTaggingQueue = JobName._(r'object-tagging-queue');
|
static const objectTaggingQueue = JobName._(r'object-tagging-queue');
|
||||||
|
static const recognizeFacesQueue = JobName._(r'recognize-faces-queue');
|
||||||
static const clipEncodingQueue = JobName._(r'clip-encoding-queue');
|
static const clipEncodingQueue = JobName._(r'clip-encoding-queue');
|
||||||
static const backgroundTaskQueue = JobName._(r'background-task-queue');
|
static const backgroundTaskQueue = JobName._(r'background-task-queue');
|
||||||
static const storageTemplateMigrationQueue = JobName._(r'storage-template-migration-queue');
|
static const storageTemplateMigrationQueue = JobName._(r'storage-template-migration-queue');
|
||||||
@ -38,6 +39,7 @@ class JobName {
|
|||||||
metadataExtractionQueue,
|
metadataExtractionQueue,
|
||||||
videoConversionQueue,
|
videoConversionQueue,
|
||||||
objectTaggingQueue,
|
objectTaggingQueue,
|
||||||
|
recognizeFacesQueue,
|
||||||
clipEncodingQueue,
|
clipEncodingQueue,
|
||||||
backgroundTaskQueue,
|
backgroundTaskQueue,
|
||||||
storageTemplateMigrationQueue,
|
storageTemplateMigrationQueue,
|
||||||
@ -84,6 +86,7 @@ class JobNameTypeTransformer {
|
|||||||
case r'metadata-extraction-queue': return JobName.metadataExtractionQueue;
|
case r'metadata-extraction-queue': return JobName.metadataExtractionQueue;
|
||||||
case r'video-conversion-queue': return JobName.videoConversionQueue;
|
case r'video-conversion-queue': return JobName.videoConversionQueue;
|
||||||
case r'object-tagging-queue': return JobName.objectTaggingQueue;
|
case r'object-tagging-queue': return JobName.objectTaggingQueue;
|
||||||
|
case r'recognize-faces-queue': return JobName.recognizeFacesQueue;
|
||||||
case r'clip-encoding-queue': return JobName.clipEncodingQueue;
|
case r'clip-encoding-queue': return JobName.clipEncodingQueue;
|
||||||
case r'background-task-queue': return JobName.backgroundTaskQueue;
|
case r'background-task-queue': return JobName.backgroundTaskQueue;
|
||||||
case r'storage-template-migration-queue': return JobName.storageTemplateMigrationQueue;
|
case r'storage-template-migration-queue': return JobName.storageTemplateMigrationQueue;
|
||||||
|
125
mobile/openapi/lib/model/person_response_dto.dart
generated
Normal file
125
mobile/openapi/lib/model/person_response_dto.dart
generated
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.12
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class PersonResponseDto {
|
||||||
|
/// Returns a new [PersonResponseDto] instance.
|
||||||
|
PersonResponseDto({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.thumbnailPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
String id;
|
||||||
|
|
||||||
|
String name;
|
||||||
|
|
||||||
|
String thumbnailPath;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto &&
|
||||||
|
other.id == id &&
|
||||||
|
other.name == name &&
|
||||||
|
other.thumbnailPath == thumbnailPath;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(id.hashCode) +
|
||||||
|
(name.hashCode) +
|
||||||
|
(thumbnailPath.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'PersonResponseDto[id=$id, name=$name, thumbnailPath=$thumbnailPath]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'id'] = this.id;
|
||||||
|
json[r'name'] = this.name;
|
||||||
|
json[r'thumbnailPath'] = this.thumbnailPath;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [PersonResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static PersonResponseDto? fromJson(dynamic value) {
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
// Ensure that the map contains the required keys.
|
||||||
|
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||||
|
// Note 2: this code is stripped in release mode!
|
||||||
|
assert(() {
|
||||||
|
requiredKeys.forEach((key) {
|
||||||
|
assert(json.containsKey(key), 'Required key "PersonResponseDto[$key]" is missing from JSON.');
|
||||||
|
assert(json[key] != null, 'Required key "PersonResponseDto[$key]" has a null value in JSON.');
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}());
|
||||||
|
|
||||||
|
return PersonResponseDto(
|
||||||
|
id: mapValueOfType<String>(json, r'id')!,
|
||||||
|
name: mapValueOfType<String>(json, r'name')!,
|
||||||
|
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<PersonResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <PersonResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = PersonResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, PersonResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, PersonResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = PersonResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of PersonResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<PersonResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<PersonResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = PersonResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'thumbnailPath',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
109
mobile/openapi/lib/model/person_update_dto.dart
generated
Normal file
109
mobile/openapi/lib/model/person_update_dto.dart
generated
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.12
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class PersonUpdateDto {
|
||||||
|
/// Returns a new [PersonUpdateDto] instance.
|
||||||
|
PersonUpdateDto({
|
||||||
|
required this.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
String name;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto &&
|
||||||
|
other.name == name;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(name.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'PersonUpdateDto[name=$name]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'name'] = this.name;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [PersonUpdateDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static PersonUpdateDto? fromJson(dynamic value) {
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
// Ensure that the map contains the required keys.
|
||||||
|
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||||
|
// Note 2: this code is stripped in release mode!
|
||||||
|
assert(() {
|
||||||
|
requiredKeys.forEach((key) {
|
||||||
|
assert(json.containsKey(key), 'Required key "PersonUpdateDto[$key]" is missing from JSON.');
|
||||||
|
assert(json[key] != null, 'Required key "PersonUpdateDto[$key]" has a null value in JSON.');
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}());
|
||||||
|
|
||||||
|
return PersonUpdateDto(
|
||||||
|
name: mapValueOfType<String>(json, r'name')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<PersonUpdateDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <PersonUpdateDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = PersonUpdateDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, PersonUpdateDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, PersonUpdateDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = PersonUpdateDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of PersonUpdateDto-objects as value to a dart map
|
||||||
|
static Map<String, List<PersonUpdateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<PersonUpdateDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = PersonUpdateDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'name',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,11 @@ void main() {
|
|||||||
// TODO
|
// TODO
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// JobStatusDto recognizeFacesQueue
|
||||||
|
test('to test the property `recognizeFacesQueue`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
5
mobile/openapi/test/asset_response_dto_test.dart
generated
5
mobile/openapi/test/asset_response_dto_test.dart
generated
@ -121,6 +121,11 @@ void main() {
|
|||||||
// TODO
|
// TODO
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// List<PersonResponseDto> people (default value: const [])
|
||||||
|
test('to test the property `people`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
46
mobile/openapi/test/person_api_test.dart
generated
Normal file
46
mobile/openapi/test/person_api_test.dart
generated
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.12
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
|
||||||
|
/// tests for PersonApi
|
||||||
|
void main() {
|
||||||
|
// final instance = PersonApi();
|
||||||
|
|
||||||
|
group('tests for PersonApi', () {
|
||||||
|
//Future<List<PersonResponseDto>> getAllPeople() async
|
||||||
|
test('test getAllPeople', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
//Future<PersonResponseDto> getPerson(String id) async
|
||||||
|
test('test getPerson', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
//Future<List<AssetResponseDto>> getPersonAssets(String id) async
|
||||||
|
test('test getPersonAssets', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
//Future<MultipartFile> getPersonThumbnail(String id) async
|
||||||
|
test('test getPersonThumbnail', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
//Future<PersonResponseDto> updatePerson(String id, PersonUpdateDto personUpdateDto) async
|
||||||
|
test('test updatePerson', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
37
mobile/openapi/test/person_response_dto_test.dart
generated
Normal file
37
mobile/openapi/test/person_response_dto_test.dart
generated
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.12
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
// tests for PersonResponseDto
|
||||||
|
void main() {
|
||||||
|
// final instance = PersonResponseDto();
|
||||||
|
|
||||||
|
group('test PersonResponseDto', () {
|
||||||
|
// String id
|
||||||
|
test('to test the property `id`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
// String name
|
||||||
|
test('to test the property `name`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
// String thumbnailPath
|
||||||
|
test('to test the property `thumbnailPath`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
27
mobile/openapi/test/person_update_dto_test.dart
generated
Normal file
27
mobile/openapi/test/person_update_dto_test.dart
generated
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.12
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
// tests for PersonUpdateDto
|
||||||
|
void main() {
|
||||||
|
// final instance = PersonUpdateDto();
|
||||||
|
|
||||||
|
group('test PersonUpdateDto', () {
|
||||||
|
// String name
|
||||||
|
test('to test the property `name`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
@ -210,7 +210,15 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
where: {
|
where: {
|
||||||
id: assetId,
|
id: assetId,
|
||||||
},
|
},
|
||||||
relations: ['exifInfo', 'tags', 'sharedLinks', 'smartInfo'],
|
relations: {
|
||||||
|
exifInfo: true,
|
||||||
|
tags: true,
|
||||||
|
sharedLinks: true,
|
||||||
|
smartInfo: true,
|
||||||
|
faces: {
|
||||||
|
person: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,7 +247,14 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get(id: string): Promise<AssetEntity | null> {
|
get(id: string): Promise<AssetEntity | null> {
|
||||||
return this.assetRepository.findOne({ where: { id } });
|
return this.assetRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: {
|
||||||
|
faces: {
|
||||||
|
person: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
@ -264,11 +279,6 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
asset.isFavorite = dto.isFavorite ?? asset.isFavorite;
|
asset.isFavorite = dto.isFavorite ?? asset.isFavorite;
|
||||||
asset.isArchived = dto.isArchived ?? asset.isArchived;
|
asset.isArchived = dto.isArchived ?? asset.isArchived;
|
||||||
|
|
||||||
if (dto.tagIds) {
|
|
||||||
const tags = await this._tagRepository.getByIds(userId, dto.tagIds);
|
|
||||||
asset.tags = tags;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (asset.exifInfo != null) {
|
if (asset.exifInfo != null) {
|
||||||
asset.exifInfo.description = dto.description || '';
|
asset.exifInfo.description = dto.description || '';
|
||||||
await this.exifRepository.save(asset.exifInfo);
|
await this.exifRepository.save(asset.exifInfo);
|
||||||
@ -280,7 +290,12 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
asset.exifInfo = exifInfo;
|
asset.exifInfo = exifInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.assetRepository.save(asset);
|
await this.assetRepository.update(asset.id, {
|
||||||
|
isFavorite: asset.isFavorite,
|
||||||
|
isArchived: asset.isArchived,
|
||||||
|
});
|
||||||
|
|
||||||
|
return asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -38,6 +38,7 @@ export class AssetCore {
|
|||||||
tags: [],
|
tags: [],
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: parse(file.originalName).name,
|
originalFileName: parse(file.originalName).name,
|
||||||
|
faces: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.jobRepository.queue({ name: JobName.ASSET_UPLOADED, data: { asset, fileName: file.originalName } });
|
await this.jobRepository.queue({ name: JobName.ASSET_UPLOADED, data: { asset, fileName: file.originalName } });
|
||||||
|
@ -355,6 +355,14 @@ export class AssetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (asset.faces) {
|
||||||
|
await Promise.all(
|
||||||
|
asset.faces.map(({ assetId, personId }) =>
|
||||||
|
this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this._assetRepository.remove(asset);
|
await this._assetRepository.remove(asset);
|
||||||
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [id] } });
|
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [id] } });
|
||||||
|
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { UserService } from '@app/domain';
|
import { JobService } from '@app/domain';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppCronJobs {
|
export class AppCronJobs {
|
||||||
constructor(private userService: UserService) {}
|
constructor(private jobService: JobService) {}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_DAY_AT_11PM)
|
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||||
async onQueueUserDeleteCheck() {
|
async onNightlyJob() {
|
||||||
await this.userService.handleQueueUserDelete();
|
await this.jobService.handleNightlyJobs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
AlbumController,
|
AlbumController,
|
||||||
APIKeyController,
|
APIKeyController,
|
||||||
AuthController,
|
AuthController,
|
||||||
|
PersonController,
|
||||||
JobController,
|
JobController,
|
||||||
OAuthController,
|
OAuthController,
|
||||||
PartnerController,
|
PartnerController,
|
||||||
@ -44,6 +45,7 @@ import { AppCronJobs } from './app.cron-jobs';
|
|||||||
SharedLinkController,
|
SharedLinkController,
|
||||||
SystemConfigController,
|
SystemConfigController,
|
||||||
UserController,
|
UserController,
|
||||||
|
PersonController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
//
|
//
|
||||||
|
@ -4,6 +4,7 @@ export * from './auth.controller';
|
|||||||
export * from './job.controller';
|
export * from './job.controller';
|
||||||
export * from './oauth.controller';
|
export * from './oauth.controller';
|
||||||
export * from './partner.controller';
|
export * from './partner.controller';
|
||||||
|
export * from './person.controller';
|
||||||
export * from './search.controller';
|
export * from './search.controller';
|
||||||
export * from './server-info.controller';
|
export * from './server-info.controller';
|
||||||
export * from './shared-link.controller';
|
export * from './shared-link.controller';
|
||||||
|
57
server/apps/immich/src/controllers/person.controller.ts
Normal file
57
server/apps/immich/src/controllers/person.controller.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
AssetResponseDto,
|
||||||
|
AuthUserDto,
|
||||||
|
ImmichReadStream,
|
||||||
|
PersonResponseDto,
|
||||||
|
PersonService,
|
||||||
|
PersonUpdateDto,
|
||||||
|
} from '@app/domain';
|
||||||
|
import { Body, Controller, Get, Param, Put, StreamableFile } from '@nestjs/common';
|
||||||
|
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { GetAuthUser } from '../decorators/auth-user.decorator';
|
||||||
|
|
||||||
|
import { Authenticated } from '../decorators/authenticated.decorator';
|
||||||
|
import { UseValidation } from '../decorators/use-validation.decorator';
|
||||||
|
import { UUIDParamDto } from './dto/uuid-param.dto';
|
||||||
|
|
||||||
|
function asStreamableFile({ stream, type, length }: ImmichReadStream) {
|
||||||
|
return new StreamableFile(stream, { type, length });
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Person')
|
||||||
|
@Controller('person')
|
||||||
|
@Authenticated()
|
||||||
|
@UseValidation()
|
||||||
|
export class PersonController {
|
||||||
|
constructor(private service: PersonService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getAllPeople(@GetAuthUser() authUser: AuthUserDto): Promise<PersonResponseDto[]> {
|
||||||
|
return this.service.getAll(authUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
getPerson(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<PersonResponseDto> {
|
||||||
|
return this.service.getById(authUser, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
updatePerson(
|
||||||
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
|
@Param() { id }: UUIDParamDto,
|
||||||
|
@Body() dto: PersonUpdateDto,
|
||||||
|
): Promise<PersonResponseDto> {
|
||||||
|
return this.service.update(authUser, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/thumbnail')
|
||||||
|
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
|
||||||
|
getPersonThumbnail(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
|
||||||
|
return this.service.getThumbnail(authUser, id).then(asStreamableFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/assets')
|
||||||
|
getPersonAssets(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto[]> {
|
||||||
|
return this.service.getAssets(authUser, id);
|
||||||
|
}
|
||||||
|
}
|
@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import {
|
import {
|
||||||
BackgroundTaskProcessor,
|
BackgroundTaskProcessor,
|
||||||
ClipEncodingProcessor,
|
ClipEncodingProcessor,
|
||||||
|
FacialRecognitionProcessor,
|
||||||
ObjectTaggingProcessor,
|
ObjectTaggingProcessor,
|
||||||
SearchIndexProcessor,
|
SearchIndexProcessor,
|
||||||
StorageTemplateMigrationProcessor,
|
StorageTemplateMigrationProcessor,
|
||||||
@ -29,6 +30,7 @@ import { MetadataExtractionProcessor } from './processors/metadata-extraction.pr
|
|||||||
StorageTemplateMigrationProcessor,
|
StorageTemplateMigrationProcessor,
|
||||||
BackgroundTaskProcessor,
|
BackgroundTaskProcessor,
|
||||||
SearchIndexProcessor,
|
SearchIndexProcessor,
|
||||||
|
FacialRecognitionProcessor,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class MicroservicesModule {}
|
export class MicroservicesModule {}
|
||||||
|
@ -1,13 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
AssetService,
|
AssetService,
|
||||||
|
FacialRecognitionService,
|
||||||
|
IAssetFaceJob,
|
||||||
IAssetJob,
|
IAssetJob,
|
||||||
IAssetUploadedJob,
|
IAssetUploadedJob,
|
||||||
IBaseJob,
|
IBaseJob,
|
||||||
IBulkEntityJob,
|
IBulkEntityJob,
|
||||||
IDeleteFilesJob,
|
IDeleteFilesJob,
|
||||||
|
IFaceThumbnailJob,
|
||||||
IUserDeletionJob,
|
IUserDeletionJob,
|
||||||
JobName,
|
JobName,
|
||||||
MediaService,
|
MediaService,
|
||||||
|
PersonService,
|
||||||
QueueName,
|
QueueName,
|
||||||
SearchService,
|
SearchService,
|
||||||
SmartInfoService,
|
SmartInfoService,
|
||||||
@ -23,6 +27,7 @@ import { Job } from 'bull';
|
|||||||
export class BackgroundTaskProcessor {
|
export class BackgroundTaskProcessor {
|
||||||
constructor(
|
constructor(
|
||||||
private assetService: AssetService,
|
private assetService: AssetService,
|
||||||
|
private personService: PersonService,
|
||||||
private storageService: StorageService,
|
private storageService: StorageService,
|
||||||
private systemConfigService: SystemConfigService,
|
private systemConfigService: SystemConfigService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
@ -43,10 +48,20 @@ export class BackgroundTaskProcessor {
|
|||||||
await this.systemConfigService.refreshConfig();
|
await this.systemConfigService.refreshConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Process(JobName.USER_DELETE_CHECK)
|
||||||
|
async onUserDeleteCheck() {
|
||||||
|
await this.userService.handleUserDeleteCheck();
|
||||||
|
}
|
||||||
|
|
||||||
@Process(JobName.USER_DELETION)
|
@Process(JobName.USER_DELETION)
|
||||||
async onUserDelete(job: Job<IUserDeletionJob>) {
|
async onUserDelete(job: Job<IUserDeletionJob>) {
|
||||||
await this.userService.handleUserDelete(job.data);
|
await this.userService.handleUserDelete(job.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Process(JobName.PERSON_CLEANUP)
|
||||||
|
async onPersonCleanup() {
|
||||||
|
await this.personService.handlePersonCleanup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Processor(QueueName.OBJECT_TAGGING)
|
@Processor(QueueName.OBJECT_TAGGING)
|
||||||
@ -69,6 +84,26 @@ export class ObjectTaggingProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Processor(QueueName.RECOGNIZE_FACES)
|
||||||
|
export class FacialRecognitionProcessor {
|
||||||
|
constructor(private facialRecognitionService: FacialRecognitionService) {}
|
||||||
|
|
||||||
|
@Process({ name: JobName.QUEUE_RECOGNIZE_FACES, concurrency: 1 })
|
||||||
|
async onQueueRecognizeFaces(job: Job<IBaseJob>) {
|
||||||
|
await this.facialRecognitionService.handleQueueRecognizeFaces(job.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Process({ name: JobName.RECOGNIZE_FACES, concurrency: 1 })
|
||||||
|
async onRecognizeFaces(job: Job<IAssetJob>) {
|
||||||
|
await this.facialRecognitionService.handleRecognizeFaces(job.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Process({ name: JobName.GENERATE_FACE_THUMBNAIL, concurrency: 1 })
|
||||||
|
async onGenerateFaceThumbnail(job: Job<IFaceThumbnailJob>) {
|
||||||
|
await this.facialRecognitionService.handleGenerateFaceThumbnail(job.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Processor(QueueName.CLIP_ENCODING)
|
@Processor(QueueName.CLIP_ENCODING)
|
||||||
export class ClipEncodingProcessor {
|
export class ClipEncodingProcessor {
|
||||||
constructor(private smartInfoService: SmartInfoService) {}
|
constructor(private smartInfoService: SmartInfoService) {}
|
||||||
@ -98,6 +133,11 @@ export class SearchIndexProcessor {
|
|||||||
await this.searchService.handleIndexAssets();
|
await this.searchService.handleIndexAssets();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Process(JobName.SEARCH_INDEX_FACES)
|
||||||
|
async onIndexFaces() {
|
||||||
|
await this.searchService.handleIndexFaces();
|
||||||
|
}
|
||||||
|
|
||||||
@Process(JobName.SEARCH_INDEX_ALBUM)
|
@Process(JobName.SEARCH_INDEX_ALBUM)
|
||||||
onIndexAlbum(job: Job<IBulkEntityJob>) {
|
onIndexAlbum(job: Job<IBulkEntityJob>) {
|
||||||
this.searchService.handleIndexAlbum(job.data);
|
this.searchService.handleIndexAlbum(job.data);
|
||||||
@ -108,6 +148,11 @@ export class SearchIndexProcessor {
|
|||||||
this.searchService.handleIndexAsset(job.data);
|
this.searchService.handleIndexAsset(job.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Process(JobName.SEARCH_INDEX_FACE)
|
||||||
|
async onIndexFace(job: Job<IAssetFaceJob>) {
|
||||||
|
await this.searchService.handleIndexFace(job.data);
|
||||||
|
}
|
||||||
|
|
||||||
@Process(JobName.SEARCH_REMOVE_ALBUM)
|
@Process(JobName.SEARCH_REMOVE_ALBUM)
|
||||||
onRemoveAlbum(job: Job<IBulkEntityJob>) {
|
onRemoveAlbum(job: Job<IBulkEntityJob>) {
|
||||||
this.searchService.handleRemoveAlbum(job.data);
|
this.searchService.handleRemoveAlbum(job.data);
|
||||||
@ -117,6 +162,11 @@ export class SearchIndexProcessor {
|
|||||||
onRemoveAsset(job: Job<IBulkEntityJob>) {
|
onRemoveAsset(job: Job<IBulkEntityJob>) {
|
||||||
this.searchService.handleRemoveAsset(job.data);
|
this.searchService.handleRemoveAsset(job.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Process(JobName.SEARCH_REMOVE_FACE)
|
||||||
|
onRemoveFace(job: Job<IAssetFaceJob>) {
|
||||||
|
this.searchService.handleRemoveFace(job.data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Processor(QueueName.STORAGE_TEMPLATE_MIGRATION)
|
@Processor(QueueName.STORAGE_TEMPLATE_MIGRATION)
|
||||||
|
@ -1988,6 +1988,221 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/person": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getAllPeople",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PersonResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Person"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/person/{id}": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getPerson",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PersonResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Person"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"operationId": "updatePerson",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PersonUpdateDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PersonResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Person"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/person/{id}/thumbnail": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getPersonThumbnail",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/octet-stream": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Person"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/person/{id}/assets": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getPersonAssets",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/AssetResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Person"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/asset/upload": {
|
"/asset/upload": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "uploadFile",
|
"operationId": "uploadFile",
|
||||||
@ -4130,6 +4345,25 @@
|
|||||||
"userId"
|
"userId"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"PersonResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"thumbnailPath": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"thumbnailPath"
|
||||||
|
]
|
||||||
|
},
|
||||||
"AssetResponseDto": {
|
"AssetResponseDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -4203,6 +4437,12 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/TagResponseDto"
|
"$ref": "#/components/schemas/TagResponseDto"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"people": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PersonResponseDto"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@ -4618,6 +4858,9 @@
|
|||||||
},
|
},
|
||||||
"search-queue": {
|
"search-queue": {
|
||||||
"$ref": "#/components/schemas/JobStatusDto"
|
"$ref": "#/components/schemas/JobStatusDto"
|
||||||
|
},
|
||||||
|
"recognize-faces-queue": {
|
||||||
|
"$ref": "#/components/schemas/JobStatusDto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@ -4628,7 +4871,8 @@
|
|||||||
"clip-encoding-queue",
|
"clip-encoding-queue",
|
||||||
"storage-template-migration-queue",
|
"storage-template-migration-queue",
|
||||||
"background-task-queue",
|
"background-task-queue",
|
||||||
"search-queue"
|
"search-queue",
|
||||||
|
"recognize-faces-queue"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"JobName": {
|
"JobName": {
|
||||||
@ -4638,6 +4882,7 @@
|
|||||||
"metadata-extraction-queue",
|
"metadata-extraction-queue",
|
||||||
"video-conversion-queue",
|
"video-conversion-queue",
|
||||||
"object-tagging-queue",
|
"object-tagging-queue",
|
||||||
|
"recognize-faces-queue",
|
||||||
"clip-encoding-queue",
|
"clip-encoding-queue",
|
||||||
"background-task-queue",
|
"background-task-queue",
|
||||||
"storage-template-migration-queue",
|
"storage-template-migration-queue",
|
||||||
@ -5375,6 +5620,17 @@
|
|||||||
"profileImagePath"
|
"profileImagePath"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"PersonUpdateDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
},
|
||||||
"CreateAssetDto": {
|
"CreateAssetDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
@ -18,6 +18,7 @@ export enum WithoutProperty {
|
|||||||
EXIF = 'exif',
|
EXIF = 'exif',
|
||||||
CLIP_ENCODING = 'clip-embedding',
|
CLIP_ENCODING = 'clip-embedding',
|
||||||
OBJECT_TAGS = 'object-tags',
|
OBJECT_TAGS = 'object-tags',
|
||||||
|
FACES = 'faces',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IAssetRepository = 'IAssetRepository';
|
export const IAssetRepository = 'IAssetRepository';
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { AssetEntity, AssetType } from '@app/infra/entities';
|
import { AssetEntity, AssetType } from '@app/infra/entities';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { mapFace, PersonResponseDto } from '../../person';
|
||||||
import { mapTag, TagResponseDto } from '../../tag';
|
import { mapTag, TagResponseDto } from '../../tag';
|
||||||
import { ExifResponseDto, mapExif } from './exif-response.dto';
|
import { ExifResponseDto, mapExif } from './exif-response.dto';
|
||||||
import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
|
import { mapSmartInfo, SmartInfoResponseDto } from './smart-info-response.dto';
|
||||||
|
|
||||||
export class AssetResponseDto {
|
export class AssetResponseDto {
|
||||||
id!: string;
|
id!: string;
|
||||||
@ -28,6 +29,7 @@ export class AssetResponseDto {
|
|||||||
smartInfo?: SmartInfoResponseDto;
|
smartInfo?: SmartInfoResponseDto;
|
||||||
livePhotoVideoId?: string | null;
|
livePhotoVideoId?: string | null;
|
||||||
tags?: TagResponseDto[];
|
tags?: TagResponseDto[];
|
||||||
|
people?: PersonResponseDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
||||||
@ -53,6 +55,7 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
|
|||||||
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
||||||
livePhotoVideoId: entity.livePhotoVideoId,
|
livePhotoVideoId: entity.livePhotoVideoId,
|
||||||
tags: entity.tags?.map(mapTag),
|
tags: entity.tags?.map(mapTag),
|
||||||
|
people: entity.faces?.map(mapFace),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,5 +82,6 @@ export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
|
|||||||
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
||||||
livePhotoVideoId: entity.livePhotoVideoId,
|
livePhotoVideoId: entity.livePhotoVideoId,
|
||||||
tags: entity.tags?.map(mapTag),
|
tags: entity.tags?.map(mapTag),
|
||||||
|
people: entity.faces?.map(mapFace),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -3,27 +3,31 @@ import { AlbumService } from './album';
|
|||||||
import { APIKeyService } from './api-key';
|
import { APIKeyService } from './api-key';
|
||||||
import { AssetService } from './asset';
|
import { AssetService } from './asset';
|
||||||
import { AuthService } from './auth';
|
import { AuthService } from './auth';
|
||||||
|
import { FacialRecognitionService } from './facial-recognition';
|
||||||
import { JobService } from './job';
|
import { JobService } from './job';
|
||||||
import { MediaService } from './media';
|
import { MediaService } from './media';
|
||||||
import { OAuthService } from './oauth';
|
import { OAuthService } from './oauth';
|
||||||
import { PartnerService } from './partner';
|
import { PartnerService } from './partner';
|
||||||
|
import { PersonService } from './person';
|
||||||
import { SearchService } from './search';
|
import { SearchService } from './search';
|
||||||
import { ServerInfoService } from './server-info';
|
import { ServerInfoService } from './server-info';
|
||||||
import { ShareService } from './share';
|
import { ShareService } from './share';
|
||||||
import { SmartInfoService } from './smart-info';
|
import { SmartInfoService } from './smart-info';
|
||||||
import { StorageService } from './storage';
|
import { StorageService } from './storage';
|
||||||
import { StorageTemplateService } from './storage-template';
|
import { StorageTemplateService } from './storage-template';
|
||||||
import { UserService } from './user';
|
|
||||||
import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
|
import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
|
||||||
|
import { UserService } from './user';
|
||||||
|
|
||||||
const providers: Provider[] = [
|
const providers: Provider[] = [
|
||||||
AlbumService,
|
AlbumService,
|
||||||
APIKeyService,
|
APIKeyService,
|
||||||
AssetService,
|
AssetService,
|
||||||
AuthService,
|
AuthService,
|
||||||
|
FacialRecognitionService,
|
||||||
JobService,
|
JobService,
|
||||||
MediaService,
|
MediaService,
|
||||||
OAuthService,
|
OAuthService,
|
||||||
|
PersonService,
|
||||||
PartnerService,
|
PartnerService,
|
||||||
SearchService,
|
SearchService,
|
||||||
ServerInfoService,
|
ServerInfoService,
|
||||||
|
14
server/libs/domain/src/facial-recognition/face.repository.ts
Normal file
14
server/libs/domain/src/facial-recognition/face.repository.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { AssetFaceEntity } from '@app/infra/entities';
|
||||||
|
|
||||||
|
export const IFaceRepository = 'IFaceRepository';
|
||||||
|
|
||||||
|
export interface AssetFaceId {
|
||||||
|
assetId: string;
|
||||||
|
personId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFaceRepository {
|
||||||
|
getAll(): Promise<AssetFaceEntity[]>;
|
||||||
|
getByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
|
||||||
|
create(entity: Partial<AssetFaceEntity>): Promise<AssetFaceEntity>;
|
||||||
|
}
|
@ -0,0 +1,320 @@
|
|||||||
|
import {
|
||||||
|
assetEntityStub,
|
||||||
|
faceStub,
|
||||||
|
newAssetRepositoryMock,
|
||||||
|
newFaceRepositoryMock,
|
||||||
|
newJobRepositoryMock,
|
||||||
|
newMachineLearningRepositoryMock,
|
||||||
|
newMediaRepositoryMock,
|
||||||
|
newPersonRepositoryMock,
|
||||||
|
newSearchRepositoryMock,
|
||||||
|
newStorageRepositoryMock,
|
||||||
|
personStub,
|
||||||
|
} from '../../test';
|
||||||
|
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||||
|
import { IJobRepository, JobName } from '../job';
|
||||||
|
import { IMediaRepository } from '../media';
|
||||||
|
import { IPersonRepository } from '../person';
|
||||||
|
import { ISearchRepository } from '../search';
|
||||||
|
import { IMachineLearningRepository } from '../smart-info';
|
||||||
|
import { IStorageRepository } from '../storage';
|
||||||
|
import { IFaceRepository } from './face.repository';
|
||||||
|
import { FacialRecognitionService } from './facial-recognition.services';
|
||||||
|
|
||||||
|
const croppedFace = Buffer.from('Cropped Face');
|
||||||
|
|
||||||
|
const face = {
|
||||||
|
start: {
|
||||||
|
assetId: 'asset-1',
|
||||||
|
personId: 'person-1',
|
||||||
|
boundingBox: {
|
||||||
|
x1: 5,
|
||||||
|
y1: 5,
|
||||||
|
x2: 505,
|
||||||
|
y2: 505,
|
||||||
|
},
|
||||||
|
imageHeight: 1000,
|
||||||
|
imageWidth: 1000,
|
||||||
|
},
|
||||||
|
middle: {
|
||||||
|
assetId: 'asset-1',
|
||||||
|
personId: 'person-1',
|
||||||
|
boundingBox: {
|
||||||
|
x1: 100,
|
||||||
|
y1: 100,
|
||||||
|
x2: 200,
|
||||||
|
y2: 200,
|
||||||
|
},
|
||||||
|
imageHeight: 500,
|
||||||
|
imageWidth: 400,
|
||||||
|
embedding: [1, 2, 3, 4],
|
||||||
|
score: 0.2,
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
assetId: 'asset-1',
|
||||||
|
personId: 'person-1',
|
||||||
|
boundingBox: {
|
||||||
|
x1: 300,
|
||||||
|
y1: 300,
|
||||||
|
x2: 495,
|
||||||
|
y2: 495,
|
||||||
|
},
|
||||||
|
imageHeight: 500,
|
||||||
|
imageWidth: 500,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const faceSearch = {
|
||||||
|
noMatch: {
|
||||||
|
total: 0,
|
||||||
|
count: 0,
|
||||||
|
page: 1,
|
||||||
|
items: [],
|
||||||
|
distances: [],
|
||||||
|
facets: [],
|
||||||
|
},
|
||||||
|
oneMatch: {
|
||||||
|
total: 1,
|
||||||
|
count: 1,
|
||||||
|
page: 1,
|
||||||
|
items: [faceStub.face1],
|
||||||
|
distances: [0.1],
|
||||||
|
facets: [],
|
||||||
|
},
|
||||||
|
oneRemoteMatch: {
|
||||||
|
total: 1,
|
||||||
|
count: 1,
|
||||||
|
page: 1,
|
||||||
|
items: [faceStub.face1],
|
||||||
|
distances: [0.8],
|
||||||
|
facets: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe(FacialRecognitionService.name, () => {
|
||||||
|
let sut: FacialRecognitionService;
|
||||||
|
let assetMock: jest.Mocked<IAssetRepository>;
|
||||||
|
let faceMock: jest.Mocked<IFaceRepository>;
|
||||||
|
let jobMock: jest.Mocked<IJobRepository>;
|
||||||
|
let machineLearningMock: jest.Mocked<IMachineLearningRepository>;
|
||||||
|
let mediaMock: jest.Mocked<IMediaRepository>;
|
||||||
|
let personMock: jest.Mocked<IPersonRepository>;
|
||||||
|
let searchMock: jest.Mocked<ISearchRepository>;
|
||||||
|
let storageMock: jest.Mocked<IStorageRepository>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
assetMock = newAssetRepositoryMock();
|
||||||
|
faceMock = newFaceRepositoryMock();
|
||||||
|
jobMock = newJobRepositoryMock();
|
||||||
|
machineLearningMock = newMachineLearningRepositoryMock();
|
||||||
|
mediaMock = newMediaRepositoryMock();
|
||||||
|
personMock = newPersonRepositoryMock();
|
||||||
|
searchMock = newSearchRepositoryMock();
|
||||||
|
storageMock = newStorageRepositoryMock();
|
||||||
|
|
||||||
|
mediaMock.crop.mockResolvedValue(croppedFace);
|
||||||
|
|
||||||
|
sut = new FacialRecognitionService(
|
||||||
|
assetMock,
|
||||||
|
faceMock,
|
||||||
|
jobMock,
|
||||||
|
machineLearningMock,
|
||||||
|
mediaMock,
|
||||||
|
personMock,
|
||||||
|
searchMock,
|
||||||
|
storageMock,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(sut).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleQueueRecognizeFaces', () => {
|
||||||
|
it('should queue missing assets', async () => {
|
||||||
|
assetMock.getWithout.mockResolvedValue([assetEntityStub.image]);
|
||||||
|
await sut.handleQueueRecognizeFaces({});
|
||||||
|
|
||||||
|
expect(assetMock.getWithout).toHaveBeenCalledWith(WithoutProperty.FACES);
|
||||||
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
|
name: JobName.RECOGNIZE_FACES,
|
||||||
|
data: { asset: assetEntityStub.image },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should queue all assets', async () => {
|
||||||
|
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
|
||||||
|
personMock.deleteAll.mockResolvedValue(5);
|
||||||
|
searchMock.deleteAllFaces.mockResolvedValue(100);
|
||||||
|
|
||||||
|
await sut.handleQueueRecognizeFaces({ force: true });
|
||||||
|
|
||||||
|
expect(assetMock.getAll).toHaveBeenCalled();
|
||||||
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
|
name: JobName.RECOGNIZE_FACES,
|
||||||
|
data: { asset: assetEntityStub.image },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log an error', async () => {
|
||||||
|
assetMock.getWithout.mockRejectedValue(new Error('Database unavailable'));
|
||||||
|
await sut.handleQueueRecognizeFaces({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleRecognizeFaces', () => {
|
||||||
|
it('should skip when no resize path', async () => {
|
||||||
|
await sut.handleRecognizeFaces({ asset: assetEntityStub.noResizePath });
|
||||||
|
expect(machineLearningMock.detectFaces).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle no results', async () => {
|
||||||
|
machineLearningMock.detectFaces.mockResolvedValue([]);
|
||||||
|
await sut.handleRecognizeFaces({ asset: assetEntityStub.image });
|
||||||
|
expect(machineLearningMock.detectFaces).toHaveBeenCalledWith({
|
||||||
|
thumbnailPath: assetEntityStub.image.resizePath,
|
||||||
|
});
|
||||||
|
expect(faceMock.create).not.toHaveBeenCalled();
|
||||||
|
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match existing people', async () => {
|
||||||
|
machineLearningMock.detectFaces.mockResolvedValue([face.middle]);
|
||||||
|
searchMock.searchFaces.mockResolvedValue(faceSearch.oneMatch);
|
||||||
|
|
||||||
|
await sut.handleRecognizeFaces({ asset: assetEntityStub.image });
|
||||||
|
|
||||||
|
expect(faceMock.create).toHaveBeenCalledWith({
|
||||||
|
personId: 'person-1',
|
||||||
|
assetId: 'asset-id',
|
||||||
|
embedding: [1, 2, 3, 4],
|
||||||
|
});
|
||||||
|
expect(jobMock.queue.mock.calls).toEqual([
|
||||||
|
[{ name: JobName.SEARCH_INDEX_FACE, data: { personId: 'person-1', assetId: 'asset-id' } }],
|
||||||
|
[{ name: JobName.SEARCH_INDEX_ASSET, data: { ids: ['asset-id'] } }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new person', async () => {
|
||||||
|
machineLearningMock.detectFaces.mockResolvedValue([face.middle]);
|
||||||
|
searchMock.searchFaces.mockResolvedValue(faceSearch.oneRemoteMatch);
|
||||||
|
personMock.create.mockResolvedValue(personStub.noName);
|
||||||
|
|
||||||
|
await sut.handleRecognizeFaces({ asset: assetEntityStub.image });
|
||||||
|
|
||||||
|
expect(personMock.create).toHaveBeenCalledWith({ ownerId: assetEntityStub.image.ownerId });
|
||||||
|
expect(faceMock.create).toHaveBeenCalledWith({
|
||||||
|
personId: 'person-1',
|
||||||
|
assetId: 'asset-id',
|
||||||
|
embedding: [1, 2, 3, 4],
|
||||||
|
});
|
||||||
|
expect(jobMock.queue.mock.calls).toEqual([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: JobName.GENERATE_FACE_THUMBNAIL,
|
||||||
|
data: {
|
||||||
|
assetId: 'asset-1',
|
||||||
|
personId: 'person-1',
|
||||||
|
boundingBox: {
|
||||||
|
x1: 100,
|
||||||
|
y1: 100,
|
||||||
|
x2: 200,
|
||||||
|
y2: 200,
|
||||||
|
},
|
||||||
|
imageHeight: 500,
|
||||||
|
imageWidth: 400,
|
||||||
|
score: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[{ name: JobName.SEARCH_INDEX_FACE, data: { personId: 'person-1', assetId: 'asset-id' } }],
|
||||||
|
[{ name: JobName.SEARCH_INDEX_ASSET, data: { ids: ['asset-id'] } }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log an error', async () => {
|
||||||
|
machineLearningMock.detectFaces.mockRejectedValue(new Error('machine learning unavailable'));
|
||||||
|
await sut.handleRecognizeFaces({ asset: assetEntityStub.image });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleGenerateFaceThumbnail', () => {
|
||||||
|
it('should skip an asset not found', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await sut.handleGenerateFaceThumbnail(face.middle);
|
||||||
|
|
||||||
|
expect(mediaMock.crop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip an asset without a thumbnail', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
|
||||||
|
|
||||||
|
await sut.handleGenerateFaceThumbnail(face.middle);
|
||||||
|
|
||||||
|
expect(mediaMock.crop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate a thumbnail', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||||
|
|
||||||
|
await sut.handleGenerateFaceThumbnail(face.middle);
|
||||||
|
|
||||||
|
expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']);
|
||||||
|
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||||
|
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', {
|
||||||
|
left: 95,
|
||||||
|
top: 95,
|
||||||
|
width: 110,
|
||||||
|
height: 110,
|
||||||
|
});
|
||||||
|
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/person-1.jpeg', {
|
||||||
|
format: 'jpeg',
|
||||||
|
size: 250,
|
||||||
|
});
|
||||||
|
expect(personMock.update).toHaveBeenCalledWith({
|
||||||
|
id: 'person-1',
|
||||||
|
thumbnailPath: 'upload/thumbs/user-id/person-1.jpeg',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate a thumbnail without going negative', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||||
|
|
||||||
|
await sut.handleGenerateFaceThumbnail(face.start);
|
||||||
|
|
||||||
|
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', {
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 510,
|
||||||
|
height: 510,
|
||||||
|
});
|
||||||
|
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/person-1.jpeg', {
|
||||||
|
format: 'jpeg',
|
||||||
|
size: 250,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate a thumbnail without overflowing', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||||
|
|
||||||
|
await sut.handleGenerateFaceThumbnail(face.end);
|
||||||
|
|
||||||
|
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', {
|
||||||
|
left: 297,
|
||||||
|
top: 297,
|
||||||
|
width: 202,
|
||||||
|
height: 202,
|
||||||
|
});
|
||||||
|
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/person-1.jpeg', {
|
||||||
|
format: 'jpeg',
|
||||||
|
size: 250,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log an error', async () => {
|
||||||
|
assetMock.getByIds.mockRejectedValue(new Error('Database unavailable'));
|
||||||
|
await sut.handleGenerateFaceThumbnail(face.middle);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,144 @@
|
|||||||
|
import { Inject, Logger } from '@nestjs/common';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||||
|
import { MACHINE_LEARNING_ENABLED } from '../domain.constant';
|
||||||
|
import { IAssetJob, IBaseJob, IFaceThumbnailJob, IJobRepository, JobName } from '../job';
|
||||||
|
import { CropOptions, FACE_THUMBNAIL_SIZE, IMediaRepository } from '../media';
|
||||||
|
import { IPersonRepository } from '../person/person.repository';
|
||||||
|
import { ISearchRepository } from '../search/search.repository';
|
||||||
|
import { IMachineLearningRepository } from '../smart-info';
|
||||||
|
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
|
||||||
|
import { AssetFaceId, IFaceRepository } from './face.repository';
|
||||||
|
|
||||||
|
export class FacialRecognitionService {
|
||||||
|
private logger = new Logger(FacialRecognitionService.name);
|
||||||
|
private storageCore = new StorageCore();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||||
|
@Inject(IFaceRepository) private faceRepository: IFaceRepository,
|
||||||
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
|
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
|
||||||
|
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
||||||
|
@Inject(IPersonRepository) private personRepository: IPersonRepository,
|
||||||
|
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
|
||||||
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handleQueueRecognizeFaces({ force }: IBaseJob) {
|
||||||
|
try {
|
||||||
|
const assets = force
|
||||||
|
? await this.assetRepository.getAll()
|
||||||
|
: await this.assetRepository.getWithout(WithoutProperty.FACES);
|
||||||
|
|
||||||
|
if (force) {
|
||||||
|
const people = await this.personRepository.deleteAll();
|
||||||
|
const faces = await this.searchRepository.deleteAllFaces();
|
||||||
|
this.logger.debug(`Deleted ${people} people and ${faces} faces`);
|
||||||
|
}
|
||||||
|
for (const asset of assets) {
|
||||||
|
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: { asset } });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Unable to queue recognize faces`, error?.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRecognizeFaces(data: IAssetJob) {
|
||||||
|
const { asset } = data;
|
||||||
|
|
||||||
|
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const faces = await this.machineLearning.detectFaces({ thumbnailPath: asset.resizePath });
|
||||||
|
|
||||||
|
this.logger.debug(`${faces.length} faces detected in ${asset.resizePath}`);
|
||||||
|
this.logger.verbose(faces.map((face) => ({ ...face, embedding: `float[${face.embedding.length}]` })));
|
||||||
|
|
||||||
|
for (const { embedding, ...rest } of faces) {
|
||||||
|
const faceSearchResult = await this.searchRepository.searchFaces(embedding, { ownerId: asset.ownerId });
|
||||||
|
|
||||||
|
let personId: string | null = null;
|
||||||
|
|
||||||
|
// try to find a matching face and link to the associated person
|
||||||
|
// The closer to 0, the better the match. Range is from 0 to 2
|
||||||
|
if (faceSearchResult.total && faceSearchResult.distances[0] < 0.6) {
|
||||||
|
this.logger.verbose(`Match face with distance ${faceSearchResult.distances[0]}`);
|
||||||
|
personId = faceSearchResult.items[0].personId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!personId) {
|
||||||
|
this.logger.debug('No matches, creating a new person.');
|
||||||
|
const person = await this.personRepository.create({ ownerId: asset.ownerId });
|
||||||
|
personId = person.id;
|
||||||
|
await this.jobRepository.queue({
|
||||||
|
name: JobName.GENERATE_FACE_THUMBNAIL,
|
||||||
|
data: { assetId: asset.id, personId, ...rest },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const faceId: AssetFaceId = { assetId: asset.id, personId };
|
||||||
|
|
||||||
|
await this.faceRepository.create({ ...faceId, embedding });
|
||||||
|
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId });
|
||||||
|
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// queue all faces for asset
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Unable run facial recognition pipeline: ${asset.id}`, error?.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleGenerateFaceThumbnail(data: IFaceThumbnailJob) {
|
||||||
|
const { assetId, personId, boundingBox, imageWidth, imageHeight } = data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [asset] = await this.assetRepository.getByIds([assetId]);
|
||||||
|
if (!asset || !asset.resizePath) {
|
||||||
|
this.logger.warn(`Asset not found for facial cropping: ${assetId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.verbose(`Cropping face for person: ${personId}`);
|
||||||
|
|
||||||
|
const outputFolder = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId);
|
||||||
|
const output = join(outputFolder, `${personId}.jpeg`);
|
||||||
|
this.storageRepository.mkdirSync(outputFolder);
|
||||||
|
|
||||||
|
const { x1, y1, x2, y2 } = boundingBox;
|
||||||
|
|
||||||
|
const halfWidth = (x2 - x1) / 2;
|
||||||
|
const halfHeight = (y2 - y1) / 2;
|
||||||
|
|
||||||
|
const middleX = Math.round(x1 + halfWidth);
|
||||||
|
const middleY = Math.round(y1 + halfHeight);
|
||||||
|
|
||||||
|
// zoom out 10%
|
||||||
|
const targetHalfSize = Math.floor(Math.max(halfWidth, halfHeight) * 1.1);
|
||||||
|
|
||||||
|
// get the longest distance from the center of the image without overflowing
|
||||||
|
const newHalfSize = Math.min(
|
||||||
|
middleX - Math.max(0, middleX - targetHalfSize),
|
||||||
|
middleY - Math.max(0, middleY - targetHalfSize),
|
||||||
|
Math.min(imageWidth - 1, middleX + targetHalfSize) - middleX,
|
||||||
|
Math.min(imageHeight - 1, middleY + targetHalfSize) - middleY,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cropOptions: CropOptions = {
|
||||||
|
left: middleX - newHalfSize,
|
||||||
|
top: middleY - newHalfSize,
|
||||||
|
width: newHalfSize * 2,
|
||||||
|
height: newHalfSize * 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const croppedOutput = await this.mediaRepository.crop(asset.resizePath, cropOptions);
|
||||||
|
await this.mediaRepository.resize(croppedOutput, output, { size: FACE_THUMBNAIL_SIZE, format: 'jpeg' });
|
||||||
|
await this.personRepository.update({ id: personId, thumbnailPath: output });
|
||||||
|
} catch (error: Error | any) {
|
||||||
|
this.logger.error(`Failed to crop face for asset: ${assetId}, person: ${personId} - ${error}`, error.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
2
server/libs/domain/src/facial-recognition/index.ts
Normal file
2
server/libs/domain/src/facial-recognition/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './facial-recognition.services';
|
||||||
|
export * from './face.repository';
|
@ -8,10 +8,12 @@ export * from './domain.config';
|
|||||||
export * from './domain.constant';
|
export * from './domain.constant';
|
||||||
export * from './domain.module';
|
export * from './domain.module';
|
||||||
export * from './domain.util';
|
export * from './domain.util';
|
||||||
|
export * from './facial-recognition';
|
||||||
export * from './job';
|
export * from './job';
|
||||||
export * from './media';
|
export * from './media';
|
||||||
export * from './metadata';
|
export * from './metadata';
|
||||||
export * from './oauth';
|
export * from './oauth';
|
||||||
|
export * from './person';
|
||||||
export * from './search';
|
export * from './search';
|
||||||
export * from './server-info';
|
export * from './server-info';
|
||||||
export * from './partner';
|
export * from './partner';
|
||||||
|
@ -3,6 +3,7 @@ export enum QueueName {
|
|||||||
METADATA_EXTRACTION = 'metadata-extraction-queue',
|
METADATA_EXTRACTION = 'metadata-extraction-queue',
|
||||||
VIDEO_CONVERSION = 'video-conversion-queue',
|
VIDEO_CONVERSION = 'video-conversion-queue',
|
||||||
OBJECT_TAGGING = 'object-tagging-queue',
|
OBJECT_TAGGING = 'object-tagging-queue',
|
||||||
|
RECOGNIZE_FACES = 'recognize-faces-queue',
|
||||||
CLIP_ENCODING = 'clip-encoding-queue',
|
CLIP_ENCODING = 'clip-encoding-queue',
|
||||||
BACKGROUND_TASK = 'background-task-queue',
|
BACKGROUND_TASK = 'background-task-queue',
|
||||||
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue',
|
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue',
|
||||||
@ -48,16 +49,25 @@ export enum JobName {
|
|||||||
DETECT_OBJECTS = 'detect-objects',
|
DETECT_OBJECTS = 'detect-objects',
|
||||||
CLASSIFY_IMAGE = 'classify-image',
|
CLASSIFY_IMAGE = 'classify-image',
|
||||||
|
|
||||||
|
// facial recognition
|
||||||
|
QUEUE_RECOGNIZE_FACES = 'queue-recognize-faces',
|
||||||
|
RECOGNIZE_FACES = 'recognize-faces',
|
||||||
|
GENERATE_FACE_THUMBNAIL = 'generate-face-thumbnail',
|
||||||
|
PERSON_CLEANUP = 'person-cleanup',
|
||||||
|
|
||||||
// cleanup
|
// cleanup
|
||||||
DELETE_FILES = 'delete-files',
|
DELETE_FILES = 'delete-files',
|
||||||
|
|
||||||
// search
|
// search
|
||||||
SEARCH_INDEX_ASSETS = 'search-index-assets',
|
SEARCH_INDEX_ASSETS = 'search-index-assets',
|
||||||
SEARCH_INDEX_ASSET = 'search-index-asset',
|
SEARCH_INDEX_ASSET = 'search-index-asset',
|
||||||
|
SEARCH_INDEX_FACE = 'search-index-face',
|
||||||
|
SEARCH_INDEX_FACES = 'search-index-faces',
|
||||||
SEARCH_INDEX_ALBUMS = 'search-index-albums',
|
SEARCH_INDEX_ALBUMS = 'search-index-albums',
|
||||||
SEARCH_INDEX_ALBUM = 'search-index-album',
|
SEARCH_INDEX_ALBUM = 'search-index-album',
|
||||||
SEARCH_REMOVE_ALBUM = 'search-remove-album',
|
SEARCH_REMOVE_ALBUM = 'search-remove-album',
|
||||||
SEARCH_REMOVE_ASSET = 'search-remove-asset',
|
SEARCH_REMOVE_ASSET = 'search-remove-asset',
|
||||||
|
SEARCH_REMOVE_FACE = 'search-remove-face',
|
||||||
|
|
||||||
// clip
|
// clip
|
||||||
QUEUE_ENCODE_CLIP = 'queue-clip-encode',
|
QUEUE_ENCODE_CLIP = 'queue-clip-encode',
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities';
|
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities';
|
||||||
|
import { BoundingBox } from '../smart-info';
|
||||||
|
|
||||||
export interface IBaseJob {
|
export interface IBaseJob {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
@ -12,6 +13,19 @@ export interface IAssetJob extends IBaseJob {
|
|||||||
asset: AssetEntity;
|
asset: AssetEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IAssetFaceJob extends IBaseJob {
|
||||||
|
assetId: string;
|
||||||
|
personId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFaceThumbnailJob extends IAssetFaceJob {
|
||||||
|
imageWidth: number;
|
||||||
|
imageHeight: number;
|
||||||
|
boundingBox: BoundingBox;
|
||||||
|
assetId: string;
|
||||||
|
personId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IBulkEntityJob extends IBaseJob {
|
export interface IBulkEntityJob extends IBaseJob {
|
||||||
ids: string[];
|
ids: string[];
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { JobName, QueueName } from './job.constants';
|
import { JobName, QueueName } from './job.constants';
|
||||||
import {
|
import {
|
||||||
|
IAssetFaceJob,
|
||||||
IAssetJob,
|
IAssetJob,
|
||||||
IAssetUploadedJob,
|
IAssetUploadedJob,
|
||||||
IBaseJob,
|
IBaseJob,
|
||||||
IBulkEntityJob,
|
IBulkEntityJob,
|
||||||
IDeleteFilesJob,
|
IDeleteFilesJob,
|
||||||
|
IFaceThumbnailJob,
|
||||||
IUserDeletionJob,
|
IUserDeletionJob,
|
||||||
} from './job.interface';
|
} from './job.interface';
|
||||||
|
|
||||||
@ -54,6 +56,11 @@ export type JobItem =
|
|||||||
| { name: JobName.DETECT_OBJECTS; data: IAssetJob }
|
| { name: JobName.DETECT_OBJECTS; data: IAssetJob }
|
||||||
| { name: JobName.CLASSIFY_IMAGE; data: IAssetJob }
|
| { name: JobName.CLASSIFY_IMAGE; data: IAssetJob }
|
||||||
|
|
||||||
|
// Recognize Faces
|
||||||
|
| { name: JobName.QUEUE_RECOGNIZE_FACES; data: IBaseJob }
|
||||||
|
| { name: JobName.RECOGNIZE_FACES; data: IAssetJob }
|
||||||
|
| { name: JobName.GENERATE_FACE_THUMBNAIL; data: IFaceThumbnailJob }
|
||||||
|
|
||||||
// Clip Embedding
|
// Clip Embedding
|
||||||
| { name: JobName.QUEUE_ENCODE_CLIP; data: IBaseJob }
|
| { name: JobName.QUEUE_ENCODE_CLIP; data: IBaseJob }
|
||||||
| { name: JobName.ENCODE_CLIP; data: IAssetJob }
|
| { name: JobName.ENCODE_CLIP; data: IAssetJob }
|
||||||
@ -61,13 +68,19 @@ export type JobItem =
|
|||||||
// Filesystem
|
// Filesystem
|
||||||
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
|
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
|
||||||
|
|
||||||
|
// Asset Deletion
|
||||||
|
| { name: JobName.PERSON_CLEANUP }
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
| { name: JobName.SEARCH_INDEX_ASSETS }
|
| { name: JobName.SEARCH_INDEX_ASSETS }
|
||||||
| { name: JobName.SEARCH_INDEX_ASSET; data: IBulkEntityJob }
|
| { name: JobName.SEARCH_INDEX_ASSET; data: IBulkEntityJob }
|
||||||
|
| { name: JobName.SEARCH_INDEX_FACES }
|
||||||
|
| { name: JobName.SEARCH_INDEX_FACE; data: IAssetFaceJob }
|
||||||
| { name: JobName.SEARCH_INDEX_ALBUMS }
|
| { name: JobName.SEARCH_INDEX_ALBUMS }
|
||||||
| { name: JobName.SEARCH_INDEX_ALBUM; data: IBulkEntityJob }
|
| { name: JobName.SEARCH_INDEX_ALBUM; data: IBulkEntityJob }
|
||||||
| { name: JobName.SEARCH_REMOVE_ASSET; data: IBulkEntityJob }
|
| { name: JobName.SEARCH_REMOVE_ASSET; data: IBulkEntityJob }
|
||||||
| { name: JobName.SEARCH_REMOVE_ALBUM; data: IBulkEntityJob };
|
| { name: JobName.SEARCH_REMOVE_ALBUM; data: IBulkEntityJob }
|
||||||
|
| { name: JobName.SEARCH_REMOVE_FACE; data: IAssetFaceJob };
|
||||||
|
|
||||||
export const IJobRepository = 'IJobRepository';
|
export const IJobRepository = 'IJobRepository';
|
||||||
|
|
||||||
|
@ -15,6 +15,17 @@ describe(JobService.name, () => {
|
|||||||
expect(sut).toBeDefined();
|
expect(sut).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('handleNightlyJobs', () => {
|
||||||
|
it('should run the scheduled jobs', async () => {
|
||||||
|
await sut.handleNightlyJobs();
|
||||||
|
|
||||||
|
expect(jobMock.queue.mock.calls).toEqual([
|
||||||
|
[{ name: JobName.USER_DELETE_CHECK }],
|
||||||
|
[{ name: JobName.PERSON_CLEANUP }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getAllJobStatus', () => {
|
describe('getAllJobStatus', () => {
|
||||||
it('should get all job statuses', async () => {
|
it('should get all job statuses', async () => {
|
||||||
jobMock.getJobCounts.mockResolvedValue({
|
jobMock.getJobCounts.mockResolvedValue({
|
||||||
@ -54,6 +65,7 @@ describe(JobService.name, () => {
|
|||||||
'storage-template-migration-queue': expectedJobStatus,
|
'storage-template-migration-queue': expectedJobStatus,
|
||||||
'thumbnail-generation-queue': expectedJobStatus,
|
'thumbnail-generation-queue': expectedJobStatus,
|
||||||
'video-conversion-queue': expectedJobStatus,
|
'video-conversion-queue': expectedJobStatus,
|
||||||
|
'recognize-faces-queue': expectedJobStatus,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -11,6 +11,11 @@ export class JobService {
|
|||||||
|
|
||||||
constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {}
|
constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {}
|
||||||
|
|
||||||
|
async handleNightlyJobs() {
|
||||||
|
await this.jobRepository.queue({ name: JobName.USER_DELETE_CHECK });
|
||||||
|
await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP });
|
||||||
|
}
|
||||||
|
|
||||||
handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> {
|
handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> {
|
||||||
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
|
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
|
||||||
|
|
||||||
@ -73,6 +78,9 @@ export class JobService {
|
|||||||
case QueueName.THUMBNAIL_GENERATION:
|
case QueueName.THUMBNAIL_GENERATION:
|
||||||
return this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force } });
|
return this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force } });
|
||||||
|
|
||||||
|
case QueueName.RECOGNIZE_FACES:
|
||||||
|
return this.jobRepository.queue({ name: JobName.QUEUE_RECOGNIZE_FACES, data: { force } });
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new BadRequestException(`Invalid job name: ${name}`);
|
throw new BadRequestException(`Invalid job name: ${name}`);
|
||||||
}
|
}
|
||||||
|
@ -53,4 +53,7 @@ export class AllJobStatusResponseDto implements Record<QueueName, JobStatusDto>
|
|||||||
|
|
||||||
@ApiProperty({ type: JobStatusDto })
|
@ApiProperty({ type: JobStatusDto })
|
||||||
[QueueName.SEARCH]!: JobStatusDto;
|
[QueueName.SEARCH]!: JobStatusDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: JobStatusDto })
|
||||||
|
[QueueName.RECOGNIZE_FACES]!: JobStatusDto;
|
||||||
}
|
}
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
|
export * from './media.constant';
|
||||||
export * from './media.repository';
|
export * from './media.repository';
|
||||||
export * from './media.service';
|
export * from './media.service';
|
||||||
|
3
server/libs/domain/src/media/media.constant.ts
Normal file
3
server/libs/domain/src/media/media.constant.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const JPEG_THUMBNAIL_SIZE = 1440;
|
||||||
|
export const WEBP_THUMBNAIL_SIZE = 250;
|
||||||
|
export const FACE_THUMBNAIL_SIZE = 250;
|
@ -31,10 +31,18 @@ export interface VideoInfo {
|
|||||||
audioStreams: AudioStreamInfo[];
|
audioStreams: AudioStreamInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CropOptions {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IMediaRepository {
|
export interface IMediaRepository {
|
||||||
// image
|
// image
|
||||||
extractThumbnailFromExif(input: string, output: string): Promise<void>;
|
extractThumbnailFromExif(input: string, output: string): Promise<void>;
|
||||||
resize(input: string, output: string, options: ResizeOptions): Promise<void>;
|
resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>;
|
||||||
|
crop(input: string, options: CropOptions): Promise<Buffer>;
|
||||||
|
|
||||||
// video
|
// video
|
||||||
extractVideoThumbnail(input: string, output: string, size: number): Promise<void>;
|
extractVideoThumbnail(input: string, output: string, size: number): Promise<void>;
|
||||||
|
@ -34,6 +34,7 @@ describe(MediaService.name, () => {
|
|||||||
jobMock = newJobRepositoryMock();
|
jobMock = newJobRepositoryMock();
|
||||||
mediaMock = newMediaRepositoryMock();
|
mediaMock = newMediaRepositoryMock();
|
||||||
storageMock = newStorageRepositoryMock();
|
storageMock = newStorageRepositoryMock();
|
||||||
|
|
||||||
sut = new MediaService(assetMock, communicationMock, jobMock, mediaMock, storageMock, configMock);
|
sut = new MediaService(assetMock, communicationMock, jobMock, mediaMock, storageMock, configMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job';
|
|||||||
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
|
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
|
||||||
import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
|
import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
|
||||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||||
|
import { JPEG_THUMBNAIL_SIZE, WEBP_THUMBNAIL_SIZE } from './media.constant';
|
||||||
import { AudioStreamInfo, IMediaRepository, VideoStreamInfo } from './media.repository';
|
import { AudioStreamInfo, IMediaRepository, VideoStreamInfo } from './media.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -57,11 +58,10 @@ export class MediaService {
|
|||||||
this.storageRepository.mkdirSync(resizePath);
|
this.storageRepository.mkdirSync(resizePath);
|
||||||
const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
|
const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
|
||||||
|
|
||||||
const thumbnailDimension = 1440;
|
|
||||||
if (asset.type == AssetType.IMAGE) {
|
if (asset.type == AssetType.IMAGE) {
|
||||||
try {
|
try {
|
||||||
await this.mediaRepository.resize(asset.originalPath, jpegThumbnailPath, {
|
await this.mediaRepository.resize(asset.originalPath, jpegThumbnailPath, {
|
||||||
size: thumbnailDimension,
|
size: JPEG_THUMBNAIL_SIZE,
|
||||||
format: 'jpeg',
|
format: 'jpeg',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -74,7 +74,7 @@ export class MediaService {
|
|||||||
|
|
||||||
if (asset.type == AssetType.VIDEO) {
|
if (asset.type == AssetType.VIDEO) {
|
||||||
this.logger.log('Start Generating Video Thumbnail');
|
this.logger.log('Start Generating Video Thumbnail');
|
||||||
await this.mediaRepository.extractVideoThumbnail(asset.originalPath, jpegThumbnailPath, thumbnailDimension);
|
await this.mediaRepository.extractVideoThumbnail(asset.originalPath, jpegThumbnailPath, JPEG_THUMBNAIL_SIZE);
|
||||||
this.logger.log(`Generating Video Thumbnail Success ${asset.id}`);
|
this.logger.log(`Generating Video Thumbnail Success ${asset.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,6 +86,7 @@ export class MediaService {
|
|||||||
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { asset } });
|
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { asset } });
|
||||||
await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } });
|
await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } });
|
||||||
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
|
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
|
||||||
|
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: { asset } });
|
||||||
|
|
||||||
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
|
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -103,7 +104,7 @@ export class MediaService {
|
|||||||
const webpPath = asset.resizePath.replace('jpeg', 'webp');
|
const webpPath = asset.resizePath.replace('jpeg', 'webp');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.mediaRepository.resize(asset.resizePath, webpPath, { size: 250, format: 'webp' });
|
await this.mediaRepository.resize(asset.resizePath, webpPath, { size: WEBP_THUMBNAIL_SIZE, format: 'webp' });
|
||||||
await this.assetRepository.save({ id: asset.id, webpPath: webpPath });
|
await this.assetRepository.save({ id: asset.id, webpPath: webpPath });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to generate webp thumbnail for asset: ${asset.id}`, error.stack);
|
this.logger.error(`Failed to generate webp thumbnail for asset: ${asset.id}`, error.stack);
|
||||||
|
1
server/libs/domain/src/person/dto/index.ts
Normal file
1
server/libs/domain/src/person/dto/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './person-update.dto';
|
7
server/libs/domain/src/person/dto/person-update.dto.ts
Normal file
7
server/libs/domain/src/person/dto/person-update.dto.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class PersonUpdateDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
name!: string;
|
||||||
|
}
|
4
server/libs/domain/src/person/index.ts
Normal file
4
server/libs/domain/src/person/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './dto';
|
||||||
|
export * from './person.repository';
|
||||||
|
export * from './person.service';
|
||||||
|
export * from './response-dto';
|
19
server/libs/domain/src/person/person.repository.ts
Normal file
19
server/libs/domain/src/person/person.repository.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { AssetEntity, PersonEntity } from '@app/infra/entities';
|
||||||
|
|
||||||
|
export const IPersonRepository = 'IPersonRepository';
|
||||||
|
|
||||||
|
export interface PersonSearchOptions {
|
||||||
|
minimumFaceCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPersonRepository {
|
||||||
|
getAll(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>;
|
||||||
|
getAllWithoutFaces(): Promise<PersonEntity[]>;
|
||||||
|
getById(userId: string, personId: string): Promise<PersonEntity | null>;
|
||||||
|
getAssets(userId: string, id: string): Promise<AssetEntity[]>;
|
||||||
|
|
||||||
|
create(entity: Partial<PersonEntity>): Promise<PersonEntity>;
|
||||||
|
update(entity: Partial<PersonEntity>): Promise<PersonEntity>;
|
||||||
|
delete(entity: PersonEntity): Promise<PersonEntity | null>;
|
||||||
|
deleteAll(): Promise<number>;
|
||||||
|
}
|
135
server/libs/domain/src/person/person.service.spec.ts
Normal file
135
server/libs/domain/src/person/person.service.spec.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { IJobRepository, JobName } from '..';
|
||||||
|
import {
|
||||||
|
assetEntityStub,
|
||||||
|
authStub,
|
||||||
|
newJobRepositoryMock,
|
||||||
|
newPersonRepositoryMock,
|
||||||
|
newStorageRepositoryMock,
|
||||||
|
personStub,
|
||||||
|
} from '../../test';
|
||||||
|
import { IStorageRepository } from '../storage';
|
||||||
|
import { IPersonRepository } from './person.repository';
|
||||||
|
import { PersonService } from './person.service';
|
||||||
|
import { PersonResponseDto } from './response-dto';
|
||||||
|
|
||||||
|
const responseDto: PersonResponseDto = {
|
||||||
|
id: 'person-1',
|
||||||
|
name: 'Person 1',
|
||||||
|
thumbnailPath: '/path/to/thumbnail',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe(PersonService.name, () => {
|
||||||
|
let sut: PersonService;
|
||||||
|
let personMock: jest.Mocked<IPersonRepository>;
|
||||||
|
let storageMock: jest.Mocked<IStorageRepository>;
|
||||||
|
let jobMock: jest.Mocked<IJobRepository>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
personMock = newPersonRepositoryMock();
|
||||||
|
storageMock = newStorageRepositoryMock();
|
||||||
|
jobMock = newJobRepositoryMock();
|
||||||
|
sut = new PersonService(personMock, storageMock, jobMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(sut).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAll', () => {
|
||||||
|
it('should get all people with thumbnails', async () => {
|
||||||
|
personMock.getAll.mockResolvedValue([personStub.withName, personStub.noThumbnail]);
|
||||||
|
await expect(sut.getAll(authStub.admin)).resolves.toEqual([responseDto]);
|
||||||
|
expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getById', () => {
|
||||||
|
it('should throw a bad request when person is not found', async () => {
|
||||||
|
personMock.getById.mockResolvedValue(null);
|
||||||
|
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get a person by id', async () => {
|
||||||
|
personMock.getById.mockResolvedValue(personStub.withName);
|
||||||
|
await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto);
|
||||||
|
expect(personMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getThumbnail', () => {
|
||||||
|
it('should throw an error when personId is invalid', async () => {
|
||||||
|
personMock.getById.mockResolvedValue(null);
|
||||||
|
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(storageMock.createReadStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error when person has no thumbnail', async () => {
|
||||||
|
personMock.getById.mockResolvedValue(personStub.noThumbnail);
|
||||||
|
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(storageMock.createReadStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should serve the thumbnail', async () => {
|
||||||
|
personMock.getById.mockResolvedValue(personStub.noName);
|
||||||
|
await sut.getThumbnail(authStub.admin, 'person-1');
|
||||||
|
expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail', 'image/jpeg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAssets', () => {
|
||||||
|
it("should return a person's assets", async () => {
|
||||||
|
personMock.getAssets.mockResolvedValue([assetEntityStub.image, assetEntityStub.video]);
|
||||||
|
await sut.getAssets(authStub.admin, 'person-1');
|
||||||
|
expect(personMock.getAssets).toHaveBeenCalledWith('admin_id', 'person-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('should throw an error when personId is invalid', async () => {
|
||||||
|
personMock.getById.mockResolvedValue(null);
|
||||||
|
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(personMock.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update a person's name", async () => {
|
||||||
|
personMock.getById.mockResolvedValue(personStub.noName);
|
||||||
|
personMock.update.mockResolvedValue(personStub.withName);
|
||||||
|
personMock.getAssets.mockResolvedValue([assetEntityStub.image]);
|
||||||
|
|
||||||
|
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto);
|
||||||
|
|
||||||
|
expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1');
|
||||||
|
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' });
|
||||||
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
|
name: JobName.SEARCH_INDEX_ASSET,
|
||||||
|
data: { ids: [assetEntityStub.image.id] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handlePersonCleanup', () => {
|
||||||
|
it('should delete people without faces', async () => {
|
||||||
|
personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]);
|
||||||
|
|
||||||
|
await sut.handlePersonCleanup();
|
||||||
|
|
||||||
|
expect(personMock.delete).toHaveBeenCalledWith(personStub.noName);
|
||||||
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
|
name: JobName.DELETE_FILES,
|
||||||
|
data: { files: ['/path/to/thumbnail'] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log an error', async () => {
|
||||||
|
personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]);
|
||||||
|
personMock.delete.mockRejectedValue(new Error('database unavailable'));
|
||||||
|
|
||||||
|
await sut.handlePersonCleanup();
|
||||||
|
|
||||||
|
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
82
server/libs/domain/src/person/person.service.ts
Normal file
82
server/libs/domain/src/person/person.service.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { AssetResponseDto, mapAsset } from '../asset';
|
||||||
|
import { AuthUserDto } from '../auth';
|
||||||
|
import { IJobRepository, JobName } from '../job';
|
||||||
|
import { ImmichReadStream, IStorageRepository } from '../storage';
|
||||||
|
import { PersonUpdateDto } from './dto';
|
||||||
|
import { IPersonRepository } from './person.repository';
|
||||||
|
import { mapPerson, PersonResponseDto } from './response-dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PersonService {
|
||||||
|
readonly logger = new Logger(PersonService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(IPersonRepository) private repository: IPersonRepository,
|
||||||
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||||
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getAll(authUser: AuthUserDto): Promise<PersonResponseDto[]> {
|
||||||
|
const people = await this.repository.getAll(authUser.id, { minimumFaceCount: 1 });
|
||||||
|
const named = people.filter((person) => !!person.name);
|
||||||
|
const unnamed = people.filter((person) => !person.name);
|
||||||
|
return (
|
||||||
|
[...named, ...unnamed]
|
||||||
|
// with thumbnails
|
||||||
|
.filter((person) => !!person.thumbnailPath)
|
||||||
|
.map((person) => mapPerson(person))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(authUser: AuthUserDto, personId: string): Promise<PersonResponseDto> {
|
||||||
|
const person = await this.repository.getById(authUser.id, personId);
|
||||||
|
if (!person) {
|
||||||
|
throw new BadRequestException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapPerson(person);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getThumbnail(authUser: AuthUserDto, personId: string): Promise<ImmichReadStream> {
|
||||||
|
const person = await this.repository.getById(authUser.id, personId);
|
||||||
|
if (!person || !person.thumbnailPath) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.storageRepository.createReadStream(person.thumbnailPath, 'image/jpeg');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAssets(authUser: AuthUserDto, personId: string): Promise<AssetResponseDto[]> {
|
||||||
|
const assets = await this.repository.getAssets(authUser.id, personId);
|
||||||
|
return assets.map(mapAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(authUser: AuthUserDto, personId: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
||||||
|
const exists = await this.repository.getById(authUser.id, personId);
|
||||||
|
if (!exists) {
|
||||||
|
throw new BadRequestException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const person = await this.repository.update({ id: personId, name: dto.name });
|
||||||
|
|
||||||
|
const relatedAsset = await this.getAssets(authUser, personId);
|
||||||
|
const assetIds = relatedAsset.map((asset) => asset.id);
|
||||||
|
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: assetIds } });
|
||||||
|
|
||||||
|
return mapPerson(person);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handlePersonCleanup(): Promise<void> {
|
||||||
|
const people = await this.repository.getAllWithoutFaces();
|
||||||
|
for (const person of people) {
|
||||||
|
this.logger.debug(`Person ${person.name || person.id} no longer has any faces, deleting.`);
|
||||||
|
try {
|
||||||
|
await this.repository.delete(person);
|
||||||
|
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [person.thumbnailPath] } });
|
||||||
|
} catch (error: Error | any) {
|
||||||
|
this.logger.error(`Unable to delete person: ${error}`, error?.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
server/libs/domain/src/person/response-dto/index.ts
Normal file
1
server/libs/domain/src/person/response-dto/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './person-response.dto';
|
@ -0,0 +1,19 @@
|
|||||||
|
import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
|
||||||
|
|
||||||
|
export class PersonResponseDto {
|
||||||
|
id!: string;
|
||||||
|
name!: string;
|
||||||
|
thumbnailPath!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapPerson(person: PersonEntity): PersonResponseDto {
|
||||||
|
return {
|
||||||
|
id: person.id,
|
||||||
|
name: person.name,
|
||||||
|
thumbnailPath: person.thumbnailPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapFace(face: AssetFaceEntity): PersonResponseDto {
|
||||||
|
return mapPerson(face.person);
|
||||||
|
}
|
@ -1,8 +1,9 @@
|
|||||||
import { AlbumEntity, AssetEntity, AssetType } from '@app/infra/entities';
|
import { AlbumEntity, AssetEntity, AssetFaceEntity, AssetType } from '@app/infra/entities';
|
||||||
|
|
||||||
export enum SearchCollection {
|
export enum SearchCollection {
|
||||||
ASSETS = 'assets',
|
ASSETS = 'assets',
|
||||||
ALBUMS = 'albums',
|
ALBUMS = 'albums',
|
||||||
|
FACES = 'faces',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SearchStrategy {
|
export enum SearchStrategy {
|
||||||
@ -10,6 +11,10 @@ export enum SearchStrategy {
|
|||||||
TEXT = 'TEXT',
|
TEXT = 'TEXT',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearchFaceFilter {
|
||||||
|
ownerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SearchFilter {
|
export interface SearchFilter {
|
||||||
id?: string;
|
id?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -37,6 +42,8 @@ export interface SearchResult<T> {
|
|||||||
page: number;
|
page: number;
|
||||||
/** items for page */
|
/** items for page */
|
||||||
items: T[];
|
items: T[];
|
||||||
|
/** score */
|
||||||
|
distances: number[];
|
||||||
facets: SearchFacet[];
|
facets: SearchFacet[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,6 +63,13 @@ export interface SearchExploreItem<T> {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OwnedFaceEntity = Pick<AssetFaceEntity, 'assetId' | 'personId' | 'embedding'> & {
|
||||||
|
/** computed as assetId|personId */
|
||||||
|
id: string;
|
||||||
|
/** copied from asset.id */
|
||||||
|
ownerId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type SearchCollectionIndexStatus = Record<SearchCollection, boolean>;
|
export type SearchCollectionIndexStatus = Record<SearchCollection, boolean>;
|
||||||
|
|
||||||
export const ISearchRepository = 'ISearchRepository';
|
export const ISearchRepository = 'ISearchRepository';
|
||||||
@ -66,13 +80,17 @@ export interface ISearchRepository {
|
|||||||
|
|
||||||
importAlbums(items: AlbumEntity[], done: boolean): Promise<void>;
|
importAlbums(items: AlbumEntity[], done: boolean): Promise<void>;
|
||||||
importAssets(items: AssetEntity[], done: boolean): Promise<void>;
|
importAssets(items: AssetEntity[], done: boolean): Promise<void>;
|
||||||
|
importFaces(items: OwnedFaceEntity[], done: boolean): Promise<void>;
|
||||||
|
|
||||||
deleteAlbums(ids: string[]): Promise<void>;
|
deleteAlbums(ids: string[]): Promise<void>;
|
||||||
deleteAssets(ids: string[]): Promise<void>;
|
deleteAssets(ids: string[]): Promise<void>;
|
||||||
|
deleteFaces(ids: string[]): Promise<void>;
|
||||||
|
deleteAllFaces(): Promise<number>;
|
||||||
|
|
||||||
searchAlbums(query: string, filters: SearchFilter): Promise<SearchResult<AlbumEntity>>;
|
searchAlbums(query: string, filters: SearchFilter): Promise<SearchResult<AlbumEntity>>;
|
||||||
searchAssets(query: string, filters: SearchFilter): Promise<SearchResult<AssetEntity>>;
|
searchAssets(query: string, filters: SearchFilter): Promise<SearchResult<AssetEntity>>;
|
||||||
vectorSearch(query: number[], filters: SearchFilter): Promise<SearchResult<AssetEntity>>;
|
vectorSearch(query: number[], filters: SearchFilter): Promise<SearchResult<AssetEntity>>;
|
||||||
|
searchFaces(query: number[], filters: SearchFaceFilter): Promise<SearchResult<AssetFaceEntity>>;
|
||||||
|
|
||||||
explore(userId: string): Promise<SearchExploreItem<AssetEntity>[]>;
|
explore(userId: string): Promise<SearchExploreItem<AssetEntity>[]>;
|
||||||
}
|
}
|
||||||
|
@ -6,8 +6,10 @@ import {
|
|||||||
assetEntityStub,
|
assetEntityStub,
|
||||||
asyncTick,
|
asyncTick,
|
||||||
authStub,
|
authStub,
|
||||||
|
faceStub,
|
||||||
newAlbumRepositoryMock,
|
newAlbumRepositoryMock,
|
||||||
newAssetRepositoryMock,
|
newAssetRepositoryMock,
|
||||||
|
newFaceRepositoryMock,
|
||||||
newJobRepositoryMock,
|
newJobRepositoryMock,
|
||||||
newMachineLearningRepositoryMock,
|
newMachineLearningRepositoryMock,
|
||||||
newSearchRepositoryMock,
|
newSearchRepositoryMock,
|
||||||
@ -15,6 +17,7 @@ import {
|
|||||||
} from '../../test';
|
} from '../../test';
|
||||||
import { IAlbumRepository } from '../album/album.repository';
|
import { IAlbumRepository } from '../album/album.repository';
|
||||||
import { IAssetRepository } from '../asset/asset.repository';
|
import { IAssetRepository } from '../asset/asset.repository';
|
||||||
|
import { IFaceRepository } from '../facial-recognition';
|
||||||
import { JobName } from '../job';
|
import { JobName } from '../job';
|
||||||
import { IJobRepository } from '../job/job.repository';
|
import { IJobRepository } from '../job/job.repository';
|
||||||
import { IMachineLearningRepository } from '../smart-info';
|
import { IMachineLearningRepository } from '../smart-info';
|
||||||
@ -28,20 +31,29 @@ describe(SearchService.name, () => {
|
|||||||
let sut: SearchService;
|
let sut: SearchService;
|
||||||
let albumMock: jest.Mocked<IAlbumRepository>;
|
let albumMock: jest.Mocked<IAlbumRepository>;
|
||||||
let assetMock: jest.Mocked<IAssetRepository>;
|
let assetMock: jest.Mocked<IAssetRepository>;
|
||||||
|
let faceMock: jest.Mocked<IFaceRepository>;
|
||||||
let jobMock: jest.Mocked<IJobRepository>;
|
let jobMock: jest.Mocked<IJobRepository>;
|
||||||
let machineMock: jest.Mocked<IMachineLearningRepository>;
|
let machineMock: jest.Mocked<IMachineLearningRepository>;
|
||||||
let searchMock: jest.Mocked<ISearchRepository>;
|
let searchMock: jest.Mocked<ISearchRepository>;
|
||||||
let configMock: jest.Mocked<ConfigService>;
|
let configMock: jest.Mocked<ConfigService>;
|
||||||
|
|
||||||
|
const makeSut = (value?: string) => {
|
||||||
|
if (value) {
|
||||||
|
configMock.get.mockReturnValue(value);
|
||||||
|
}
|
||||||
|
return new SearchService(albumMock, assetMock, faceMock, jobMock, machineMock, searchMock, configMock);
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
albumMock = newAlbumRepositoryMock();
|
albumMock = newAlbumRepositoryMock();
|
||||||
assetMock = newAssetRepositoryMock();
|
assetMock = newAssetRepositoryMock();
|
||||||
|
faceMock = newFaceRepositoryMock();
|
||||||
jobMock = newJobRepositoryMock();
|
jobMock = newJobRepositoryMock();
|
||||||
machineMock = newMachineLearningRepositoryMock();
|
machineMock = newMachineLearningRepositoryMock();
|
||||||
searchMock = newSearchRepositoryMock();
|
searchMock = newSearchRepositoryMock();
|
||||||
configMock = { get: jest.fn() } as unknown as jest.Mocked<ConfigService>;
|
configMock = { get: jest.fn() } as unknown as jest.Mocked<ConfigService>;
|
||||||
|
|
||||||
sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
sut = makeSut();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -80,8 +92,7 @@ describe(SearchService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should be disabled via an env variable', () => {
|
it('should be disabled via an env variable', () => {
|
||||||
configMock.get.mockReturnValue('false');
|
const sut = makeSut('false');
|
||||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
|
||||||
|
|
||||||
expect(sut.isEnabled()).toBe(false);
|
expect(sut.isEnabled()).toBe(false);
|
||||||
});
|
});
|
||||||
@ -93,8 +104,7 @@ describe(SearchService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return the config when search is disabled', () => {
|
it('should return the config when search is disabled', () => {
|
||||||
configMock.get.mockReturnValue('false');
|
const sut = makeSut('false');
|
||||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
|
||||||
|
|
||||||
expect(sut.getConfig()).toEqual({ enabled: false });
|
expect(sut.getConfig()).toEqual({ enabled: false });
|
||||||
});
|
});
|
||||||
@ -102,8 +112,7 @@ describe(SearchService.name, () => {
|
|||||||
|
|
||||||
describe(`bootstrap`, () => {
|
describe(`bootstrap`, () => {
|
||||||
it('should skip when search is disabled', async () => {
|
it('should skip when search is disabled', async () => {
|
||||||
configMock.get.mockReturnValue('false');
|
const sut = makeSut('false');
|
||||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
|
||||||
|
|
||||||
await sut.bootstrap();
|
await sut.bootstrap();
|
||||||
|
|
||||||
@ -115,7 +124,7 @@ describe(SearchService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should skip schema migration if not needed', async () => {
|
it('should skip schema migration if not needed', async () => {
|
||||||
searchMock.checkMigrationStatus.mockResolvedValue({ assets: false, albums: false });
|
searchMock.checkMigrationStatus.mockResolvedValue({ assets: false, albums: false, faces: false });
|
||||||
await sut.bootstrap();
|
await sut.bootstrap();
|
||||||
|
|
||||||
expect(searchMock.setup).toHaveBeenCalled();
|
expect(searchMock.setup).toHaveBeenCalled();
|
||||||
@ -123,21 +132,21 @@ describe(SearchService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should do schema migration if needed', async () => {
|
it('should do schema migration if needed', async () => {
|
||||||
searchMock.checkMigrationStatus.mockResolvedValue({ assets: true, albums: true });
|
searchMock.checkMigrationStatus.mockResolvedValue({ assets: true, albums: true, faces: true });
|
||||||
await sut.bootstrap();
|
await sut.bootstrap();
|
||||||
|
|
||||||
expect(searchMock.setup).toHaveBeenCalled();
|
expect(searchMock.setup).toHaveBeenCalled();
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
expect(jobMock.queue.mock.calls).toEqual([
|
||||||
[{ name: JobName.SEARCH_INDEX_ASSETS }],
|
[{ name: JobName.SEARCH_INDEX_ASSETS }],
|
||||||
[{ name: JobName.SEARCH_INDEX_ALBUMS }],
|
[{ name: JobName.SEARCH_INDEX_ALBUMS }],
|
||||||
|
[{ name: JobName.SEARCH_INDEX_FACES }],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('search', () => {
|
describe('search', () => {
|
||||||
it('should throw an error is search is disabled', async () => {
|
it('should throw an error is search is disabled', async () => {
|
||||||
configMock.get.mockReturnValue('false');
|
const sut = makeSut('false');
|
||||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
|
||||||
|
|
||||||
await expect(sut.search(authStub.admin, {})).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.search(authStub.admin, {})).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
@ -157,6 +166,7 @@ describe(SearchService.name, () => {
|
|||||||
page: 1,
|
page: 1,
|
||||||
items: [],
|
items: [],
|
||||||
facets: [],
|
facets: [],
|
||||||
|
distances: [],
|
||||||
},
|
},
|
||||||
assets: {
|
assets: {
|
||||||
total: 0,
|
total: 0,
|
||||||
@ -164,6 +174,7 @@ describe(SearchService.name, () => {
|
|||||||
page: 1,
|
page: 1,
|
||||||
items: [],
|
items: [],
|
||||||
facets: [],
|
facets: [],
|
||||||
|
distances: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -202,8 +213,7 @@ describe(SearchService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should skip if search is disabled', async () => {
|
it('should skip if search is disabled', async () => {
|
||||||
configMock.get.mockReturnValue('false');
|
const sut = makeSut('false');
|
||||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
|
||||||
|
|
||||||
await sut.handleIndexAssets();
|
await sut.handleIndexAssets();
|
||||||
|
|
||||||
@ -214,8 +224,7 @@ describe(SearchService.name, () => {
|
|||||||
|
|
||||||
describe('handleIndexAsset', () => {
|
describe('handleIndexAsset', () => {
|
||||||
it('should skip if search is disabled', () => {
|
it('should skip if search is disabled', () => {
|
||||||
configMock.get.mockReturnValue('false');
|
const sut = makeSut('false');
|
||||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
|
||||||
sut.handleIndexAsset({ ids: [assetEntityStub.image.id] });
|
sut.handleIndexAsset({ ids: [assetEntityStub.image.id] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -226,8 +235,7 @@ describe(SearchService.name, () => {
|
|||||||
|
|
||||||
describe('handleIndexAlbums', () => {
|
describe('handleIndexAlbums', () => {
|
||||||
it('should skip if search is disabled', () => {
|
it('should skip if search is disabled', () => {
|
||||||
configMock.get.mockReturnValue('false');
|
const sut = makeSut('false');
|
||||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
|
||||||
sut.handleIndexAlbums();
|
sut.handleIndexAlbums();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -251,8 +259,7 @@ describe(SearchService.name, () => {
|
|||||||
|
|
||||||
describe('handleIndexAlbum', () => {
|
describe('handleIndexAlbum', () => {
|
||||||
it('should skip if search is disabled', () => {
|
it('should skip if search is disabled', () => {
|
||||||
configMock.get.mockReturnValue('false');
|
const sut = makeSut('false');
|
||||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
|
||||||
sut.handleIndexAlbum({ ids: [albumStub.empty.id] });
|
sut.handleIndexAlbum({ ids: [albumStub.empty.id] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -263,8 +270,7 @@ describe(SearchService.name, () => {
|
|||||||
|
|
||||||
describe('handleRemoveAlbum', () => {
|
describe('handleRemoveAlbum', () => {
|
||||||
it('should skip if search is disabled', () => {
|
it('should skip if search is disabled', () => {
|
||||||
configMock.get.mockReturnValue('false');
|
const sut = makeSut('false');
|
||||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
|
||||||
sut.handleRemoveAlbum({ ids: ['album1'] });
|
sut.handleRemoveAlbum({ ids: ['album1'] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -275,8 +281,7 @@ describe(SearchService.name, () => {
|
|||||||
|
|
||||||
describe('handleRemoveAsset', () => {
|
describe('handleRemoveAsset', () => {
|
||||||
it('should skip if search is disabled', () => {
|
it('should skip if search is disabled', () => {
|
||||||
configMock.get.mockReturnValue('false');
|
const sut = makeSut('false');
|
||||||
const sut = new SearchService(albumMock, assetMock, jobMock, machineMock, searchMock, configMock);
|
|
||||||
sut.handleRemoveAsset({ ids: ['asset1'] });
|
sut.handleRemoveAsset({ ids: ['asset1'] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -285,6 +290,84 @@ describe(SearchService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('handleIndexFaces', () => {
|
||||||
|
it('should call done, even when there are no faces', async () => {
|
||||||
|
faceMock.getAll.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await sut.handleIndexFaces();
|
||||||
|
|
||||||
|
expect(searchMock.importFaces).toHaveBeenCalledWith([], true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should index all the faces', async () => {
|
||||||
|
faceMock.getAll.mockResolvedValue([faceStub.face1]);
|
||||||
|
|
||||||
|
await sut.handleIndexFaces();
|
||||||
|
|
||||||
|
expect(searchMock.importFaces.mock.calls).toEqual([
|
||||||
|
[
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'asset-id|person-1',
|
||||||
|
ownerId: 'user-id',
|
||||||
|
assetId: 'asset-id',
|
||||||
|
personId: 'person-1',
|
||||||
|
embedding: [1, 2, 3, 4],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
],
|
||||||
|
[[], true],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log an error', async () => {
|
||||||
|
faceMock.getAll.mockResolvedValue([faceStub.face1]);
|
||||||
|
searchMock.importFaces.mockRejectedValue(new Error('import failed'));
|
||||||
|
|
||||||
|
await sut.handleIndexFaces();
|
||||||
|
|
||||||
|
expect(searchMock.importFaces).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip if search is disabled', async () => {
|
||||||
|
const sut = makeSut('false');
|
||||||
|
|
||||||
|
await sut.handleIndexFaces();
|
||||||
|
|
||||||
|
expect(searchMock.importFaces).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleIndexAsset', () => {
|
||||||
|
it('should skip if search is disabled', () => {
|
||||||
|
const sut = makeSut('false');
|
||||||
|
sut.handleIndexFace({ assetId: 'asset-1', personId: 'person-1' });
|
||||||
|
|
||||||
|
expect(searchMock.importFaces).not.toHaveBeenCalled();
|
||||||
|
expect(faceMock.getByIds).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should index the face', () => {
|
||||||
|
faceMock.getByIds.mockResolvedValue([faceStub.face1]);
|
||||||
|
|
||||||
|
sut.handleIndexFace({ assetId: 'asset-1', personId: 'person-1' });
|
||||||
|
|
||||||
|
expect(faceMock.getByIds).toHaveBeenCalledWith([{ assetId: 'asset-1', personId: 'person-1' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleRemoveFace', () => {
|
||||||
|
it('should skip if search is disabled', () => {
|
||||||
|
const sut = makeSut('false');
|
||||||
|
sut.handleRemoveFace({ assetId: 'asset-1', personId: 'person-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove the face', () => {
|
||||||
|
sut.handleRemoveFace({ assetId: 'asset-1', personId: 'person-1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('flush', () => {
|
describe('flush', () => {
|
||||||
it('should flush queued album updates', async () => {
|
it('should flush queued album updates', async () => {
|
||||||
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
|
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { AlbumEntity, AssetEntity } from '@app/infra/entities';
|
import { AlbumEntity, AssetEntity, AssetFaceEntity } from '@app/infra/entities';
|
||||||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { mapAlbum } from '../album';
|
import { mapAlbum } from '../album';
|
||||||
@ -7,12 +7,14 @@ import { mapAsset } from '../asset';
|
|||||||
import { IAssetRepository } from '../asset/asset.repository';
|
import { IAssetRepository } from '../asset/asset.repository';
|
||||||
import { AuthUserDto } from '../auth';
|
import { AuthUserDto } from '../auth';
|
||||||
import { MACHINE_LEARNING_ENABLED } from '../domain.constant';
|
import { MACHINE_LEARNING_ENABLED } from '../domain.constant';
|
||||||
import { IBulkEntityJob, IJobRepository, JobName } from '../job';
|
import { AssetFaceId, IFaceRepository } from '../facial-recognition';
|
||||||
|
import { IAssetFaceJob, IBulkEntityJob, IJobRepository, JobName } from '../job';
|
||||||
import { IMachineLearningRepository } from '../smart-info';
|
import { IMachineLearningRepository } from '../smart-info';
|
||||||
import { SearchDto } from './dto';
|
import { SearchDto } from './dto';
|
||||||
import { SearchConfigResponseDto, SearchResponseDto } from './response-dto';
|
import { SearchConfigResponseDto, SearchResponseDto } from './response-dto';
|
||||||
import {
|
import {
|
||||||
ISearchRepository,
|
ISearchRepository,
|
||||||
|
OwnedFaceEntity,
|
||||||
SearchCollection,
|
SearchCollection,
|
||||||
SearchExploreItem,
|
SearchExploreItem,
|
||||||
SearchResult,
|
SearchResult,
|
||||||
@ -40,9 +42,15 @@ export class SearchService {
|
|||||||
delete: new Set(),
|
delete: new Set(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private faceQueue: SyncQueue = {
|
||||||
|
upsert: new Set(),
|
||||||
|
delete: new Set(),
|
||||||
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
||||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||||
|
@Inject(IFaceRepository) private faceRepository: IFaceRepository,
|
||||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
|
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
|
||||||
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
|
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
|
||||||
@ -88,6 +96,10 @@ export class SearchService {
|
|||||||
this.logger.debug('Queueing job to re-index all albums');
|
this.logger.debug('Queueing job to re-index all albums');
|
||||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUMS });
|
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUMS });
|
||||||
}
|
}
|
||||||
|
if (migrationStatus[SearchCollection.FACES]) {
|
||||||
|
this.logger.debug('Queueing job to re-index all faces');
|
||||||
|
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACES });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getExploreData(authUser: AuthUserDto): Promise<SearchExploreItem<AssetEntity>[]> {
|
async getExploreData(authUser: AuthUserDto): Promise<SearchExploreItem<AssetEntity>[]> {
|
||||||
@ -159,6 +171,29 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleIndexFaces() {
|
||||||
|
if (!this.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: do this in batches based on searchIndexVersion
|
||||||
|
const faces = this.patchFaces(await this.faceRepository.getAll());
|
||||||
|
this.logger.log(`Indexing ${faces.length} faces`);
|
||||||
|
|
||||||
|
const chunkSize = 1000;
|
||||||
|
for (let i = 0; i < faces.length; i += chunkSize) {
|
||||||
|
await this.searchRepository.importFaces(faces.slice(i, i + chunkSize), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.searchRepository.importFaces([], true);
|
||||||
|
|
||||||
|
this.logger.debug('Finished re-indexing all faces');
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Unable to index all faces`, error?.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleIndexAlbum({ ids }: IBulkEntityJob) {
|
handleIndexAlbum({ ids }: IBulkEntityJob) {
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
return;
|
return;
|
||||||
@ -179,6 +214,15 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleIndexFace({ assetId, personId }: IAssetFaceJob) {
|
||||||
|
if (!this.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// immediately push to typesense
|
||||||
|
await this.searchRepository.importFaces(await this.idsToFaces([{ assetId, personId }]), false);
|
||||||
|
}
|
||||||
|
|
||||||
handleRemoveAlbum({ ids }: IBulkEntityJob) {
|
handleRemoveAlbum({ ids }: IBulkEntityJob) {
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
return;
|
return;
|
||||||
@ -199,6 +243,14 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleRemoveFace({ assetId, personId }: IAssetFaceJob) {
|
||||||
|
if (!this.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.faceQueue.delete.add(this.asKey({ assetId, personId }));
|
||||||
|
}
|
||||||
|
|
||||||
private async flush() {
|
private async flush() {
|
||||||
if (this.albumQueue.upsert.size > 0) {
|
if (this.albumQueue.upsert.size > 0) {
|
||||||
const ids = [...this.albumQueue.upsert.keys()];
|
const ids = [...this.albumQueue.upsert.keys()];
|
||||||
@ -229,6 +281,21 @@ export class SearchService {
|
|||||||
await this.searchRepository.deleteAssets(ids);
|
await this.searchRepository.deleteAssets(ids);
|
||||||
this.assetQueue.delete.clear();
|
this.assetQueue.delete.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.faceQueue.upsert.size > 0) {
|
||||||
|
const ids = [...this.faceQueue.upsert.keys()].map((key) => this.asParts(key));
|
||||||
|
const items = await this.idsToFaces(ids);
|
||||||
|
this.logger.debug(`Flushing ${items.length} face upserts`);
|
||||||
|
await this.searchRepository.importFaces(items, false);
|
||||||
|
this.faceQueue.upsert.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.faceQueue.delete.size > 0) {
|
||||||
|
const ids = [...this.faceQueue.delete.keys()];
|
||||||
|
this.logger.debug(`Flushing ${ids.length} face deletes`);
|
||||||
|
await this.searchRepository.deleteFaces(ids);
|
||||||
|
this.faceQueue.delete.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertEnabled() {
|
private assertEnabled() {
|
||||||
@ -247,6 +314,10 @@ export class SearchService {
|
|||||||
return this.patchAssets(entities.filter((entity) => entity.isVisible));
|
return this.patchAssets(entities.filter((entity) => entity.isVisible));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async idsToFaces(ids: AssetFaceId[]): Promise<OwnedFaceEntity[]> {
|
||||||
|
return this.patchFaces(await this.faceRepository.getByIds(ids));
|
||||||
|
}
|
||||||
|
|
||||||
private patchAssets(assets: AssetEntity[]): AssetEntity[] {
|
private patchAssets(assets: AssetEntity[]): AssetEntity[] {
|
||||||
return assets;
|
return assets;
|
||||||
}
|
}
|
||||||
@ -254,4 +325,23 @@ export class SearchService {
|
|||||||
private patchAlbums(albums: AlbumEntity[]): AlbumEntity[] {
|
private patchAlbums(albums: AlbumEntity[]): AlbumEntity[] {
|
||||||
return albums.map((entity) => ({ ...entity, assets: [] }));
|
return albums.map((entity) => ({ ...entity, assets: [] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private patchFaces(faces: AssetFaceEntity[]): OwnedFaceEntity[] {
|
||||||
|
return faces.map((face) => ({
|
||||||
|
id: this.asKey(face),
|
||||||
|
ownerId: face.asset.ownerId,
|
||||||
|
assetId: face.assetId,
|
||||||
|
personId: face.personId,
|
||||||
|
embedding: face.embedding,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private asKey(face: AssetFaceId): string {
|
||||||
|
return `${face.assetId}|${face.personId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private asParts(key: string): AssetFaceId {
|
||||||
|
const [assetId, personId] = key.split('|');
|
||||||
|
return { assetId, personId };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,9 +4,25 @@ export interface MachineLearningInput {
|
|||||||
thumbnailPath: string;
|
thumbnailPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BoundingBox {
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetectFaceResult {
|
||||||
|
imageWidth: number;
|
||||||
|
imageHeight: number;
|
||||||
|
boundingBox: BoundingBox;
|
||||||
|
score: number;
|
||||||
|
embedding: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface IMachineLearningRepository {
|
export interface IMachineLearningRepository {
|
||||||
classifyImage(input: MachineLearningInput): Promise<string[]>;
|
classifyImage(input: MachineLearningInput): Promise<string[]>;
|
||||||
detectObjects(input: MachineLearningInput): Promise<string[]>;
|
detectObjects(input: MachineLearningInput): Promise<string[]>;
|
||||||
encodeImage(input: MachineLearningInput): Promise<number[]>;
|
encodeImage(input: MachineLearningInput): Promise<number[]>;
|
||||||
encodeText(input: string): Promise<number[]>;
|
encodeText(input: string): Promise<number[]>;
|
||||||
|
detectFaces(input: MachineLearningInput): Promise<DetectFaceResult[]>;
|
||||||
}
|
}
|
||||||
|
@ -435,7 +435,7 @@ describe(UserService.name, () => {
|
|||||||
{ deletedAt: makeDeletedAt(5) },
|
{ deletedAt: makeDeletedAt(5) },
|
||||||
] as UserEntity[]);
|
] as UserEntity[]);
|
||||||
|
|
||||||
await sut.handleQueueUserDelete();
|
await sut.handleUserDeleteCheck();
|
||||||
|
|
||||||
expect(userRepositoryMock.getDeletedUsers).toHaveBeenCalled();
|
expect(userRepositoryMock.getDeletedUsers).toHaveBeenCalled();
|
||||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||||
@ -445,7 +445,7 @@ describe(UserService.name, () => {
|
|||||||
const user = { deletedAt: makeDeletedAt(10) };
|
const user = { deletedAt: makeDeletedAt(10) };
|
||||||
userRepositoryMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]);
|
userRepositoryMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]);
|
||||||
|
|
||||||
await sut.handleQueueUserDelete();
|
await sut.handleUserDeleteCheck();
|
||||||
|
|
||||||
expect(userRepositoryMock.getDeletedUsers).toHaveBeenCalled();
|
expect(userRepositoryMock.getDeletedUsers).toHaveBeenCalled();
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.USER_DELETION, data: { user } });
|
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.USER_DELETION, data: { user } });
|
||||||
|
@ -143,7 +143,7 @@ export class UserService {
|
|||||||
return { admin, password, provided: !!providedPassword };
|
return { admin, password, provided: !!providedPassword };
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleQueueUserDelete() {
|
async handleUserDeleteCheck() {
|
||||||
const users = await this.userRepository.getDeletedUsers();
|
const users = await this.userRepository.getDeletedUsers();
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
if (this.isReadyForDeletion(user)) {
|
if (this.isReadyForDeletion(user)) {
|
||||||
|
9
server/libs/domain/test/face.repository.mock.ts
Normal file
9
server/libs/domain/test/face.repository.mock.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { IFaceRepository } from '../src';
|
||||||
|
|
||||||
|
export const newFaceRepositoryMock = (): jest.Mocked<IFaceRepository> => {
|
||||||
|
return {
|
||||||
|
getAll: jest.fn(),
|
||||||
|
getByIds: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
};
|
||||||
|
};
|
@ -3,6 +3,7 @@ import {
|
|||||||
APIKeyEntity,
|
APIKeyEntity,
|
||||||
AssetEntity,
|
AssetEntity,
|
||||||
AssetType,
|
AssetType,
|
||||||
|
PersonEntity,
|
||||||
PartnerEntity,
|
PartnerEntity,
|
||||||
SharedLinkEntity,
|
SharedLinkEntity,
|
||||||
SharedLinkType,
|
SharedLinkType,
|
||||||
@ -10,6 +11,7 @@ import {
|
|||||||
TranscodePreset,
|
TranscodePreset,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
UserTokenEntity,
|
UserTokenEntity,
|
||||||
|
AssetFaceEntity,
|
||||||
} from '@app/infra/entities';
|
} from '@app/infra/entities';
|
||||||
import {
|
import {
|
||||||
AlbumResponseDto,
|
AlbumResponseDto,
|
||||||
@ -142,6 +144,7 @@ export const assetEntityStub = {
|
|||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
tags: [],
|
tags: [],
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
|
faces: [],
|
||||||
}),
|
}),
|
||||||
image: Object.freeze<AssetEntity>({
|
image: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
@ -168,6 +171,7 @@ export const assetEntityStub = {
|
|||||||
tags: [],
|
tags: [],
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
originalFileName: 'asset-id.ext',
|
originalFileName: 'asset-id.ext',
|
||||||
|
faces: [],
|
||||||
}),
|
}),
|
||||||
video: Object.freeze<AssetEntity>({
|
video: Object.freeze<AssetEntity>({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
@ -194,6 +198,7 @@ export const assetEntityStub = {
|
|||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
tags: [],
|
tags: [],
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
|
faces: [],
|
||||||
}),
|
}),
|
||||||
livePhotoMotionAsset: Object.freeze({
|
livePhotoMotionAsset: Object.freeze({
|
||||||
id: 'live-photo-motion-asset',
|
id: 'live-photo-motion-asset',
|
||||||
@ -372,6 +377,7 @@ const assetResponse: AssetResponseDto = {
|
|||||||
exifInfo: assetInfo,
|
exifInfo: assetInfo,
|
||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
tags: [],
|
tags: [],
|
||||||
|
people: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const albumResponse: AlbumResponseDto = {
|
const albumResponse: AlbumResponseDto = {
|
||||||
@ -655,6 +661,7 @@ export const sharedLinkStub = {
|
|||||||
},
|
},
|
||||||
tags: [],
|
tags: [],
|
||||||
sharedLinks: [],
|
sharedLinks: [],
|
||||||
|
faces: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -729,6 +736,7 @@ export const searchStub = {
|
|||||||
page: 1,
|
page: 1,
|
||||||
items: [],
|
items: [],
|
||||||
facets: [],
|
facets: [],
|
||||||
|
distances: [],
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -826,6 +834,39 @@ export const probeStub = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const personStub = {
|
||||||
|
noName: Object.freeze<PersonEntity>({
|
||||||
|
id: 'person-1',
|
||||||
|
createdAt: new Date('2021-01-01'),
|
||||||
|
updatedAt: new Date('2021-01-01'),
|
||||||
|
ownerId: userEntityStub.admin.id,
|
||||||
|
owner: userEntityStub.admin,
|
||||||
|
name: '',
|
||||||
|
thumbnailPath: '/path/to/thumbnail',
|
||||||
|
faces: [],
|
||||||
|
}),
|
||||||
|
withName: Object.freeze<PersonEntity>({
|
||||||
|
id: 'person-1',
|
||||||
|
createdAt: new Date('2021-01-01'),
|
||||||
|
updatedAt: new Date('2021-01-01'),
|
||||||
|
ownerId: userEntityStub.admin.id,
|
||||||
|
owner: userEntityStub.admin,
|
||||||
|
name: 'Person 1',
|
||||||
|
thumbnailPath: '/path/to/thumbnail',
|
||||||
|
faces: [],
|
||||||
|
}),
|
||||||
|
noThumbnail: Object.freeze<PersonEntity>({
|
||||||
|
id: 'person-1',
|
||||||
|
createdAt: new Date('2021-01-01'),
|
||||||
|
updatedAt: new Date('2021-01-01'),
|
||||||
|
ownerId: userEntityStub.admin.id,
|
||||||
|
owner: userEntityStub.admin,
|
||||||
|
name: '',
|
||||||
|
thumbnailPath: '',
|
||||||
|
faces: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export const partnerStub = {
|
export const partnerStub = {
|
||||||
adminToUser1: Object.freeze<PartnerEntity>({
|
adminToUser1: Object.freeze<PartnerEntity>({
|
||||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
@ -844,3 +885,13 @@ export const partnerStub = {
|
|||||||
sharedWith: userEntityStub.admin,
|
sharedWith: userEntityStub.admin,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const faceStub = {
|
||||||
|
face1: Object.freeze<AssetFaceEntity>({
|
||||||
|
assetId: assetEntityStub.image.id,
|
||||||
|
asset: assetEntityStub.image,
|
||||||
|
personId: personStub.withName.id,
|
||||||
|
person: personStub.withName,
|
||||||
|
embedding: [1, 2, 3, 4],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
@ -3,11 +3,13 @@ export * from './api-key.repository.mock';
|
|||||||
export * from './asset.repository.mock';
|
export * from './asset.repository.mock';
|
||||||
export * from './communication.repository.mock';
|
export * from './communication.repository.mock';
|
||||||
export * from './crypto.repository.mock';
|
export * from './crypto.repository.mock';
|
||||||
|
export * from './face.repository.mock';
|
||||||
export * from './fixtures';
|
export * from './fixtures';
|
||||||
export * from './job.repository.mock';
|
export * from './job.repository.mock';
|
||||||
export * from './machine-learning.repository.mock';
|
export * from './machine-learning.repository.mock';
|
||||||
export * from './media.repository.mock';
|
export * from './media.repository.mock';
|
||||||
export * from './partner.repository.mock';
|
export * from './partner.repository.mock';
|
||||||
|
export * from './person.repository.mock';
|
||||||
export * from './search.repository.mock';
|
export * from './search.repository.mock';
|
||||||
export * from './shared-link.repository.mock';
|
export * from './shared-link.repository.mock';
|
||||||
export * from './smart-info.repository.mock';
|
export * from './smart-info.repository.mock';
|
||||||
|
@ -6,5 +6,6 @@ export const newMachineLearningRepositoryMock = (): jest.Mocked<IMachineLearning
|
|||||||
detectObjects: jest.fn(),
|
detectObjects: jest.fn(),
|
||||||
encodeImage: jest.fn(),
|
encodeImage: jest.fn(),
|
||||||
encodeText: jest.fn(),
|
encodeText: jest.fn(),
|
||||||
|
detectFaces: jest.fn(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -5,6 +5,7 @@ export const newMediaRepositoryMock = (): jest.Mocked<IMediaRepository> => {
|
|||||||
extractThumbnailFromExif: jest.fn(),
|
extractThumbnailFromExif: jest.fn(),
|
||||||
extractVideoThumbnail: jest.fn(),
|
extractVideoThumbnail: jest.fn(),
|
||||||
resize: jest.fn(),
|
resize: jest.fn(),
|
||||||
|
crop: jest.fn(),
|
||||||
probe: jest.fn(),
|
probe: jest.fn(),
|
||||||
transcode: jest.fn(),
|
transcode: jest.fn(),
|
||||||
};
|
};
|
||||||
|
15
server/libs/domain/test/person.repository.mock.ts
Normal file
15
server/libs/domain/test/person.repository.mock.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { IPersonRepository } from '../src';
|
||||||
|
|
||||||
|
export const newPersonRepositoryMock = (): jest.Mocked<IPersonRepository> => {
|
||||||
|
return {
|
||||||
|
getById: jest.fn(),
|
||||||
|
getAll: jest.fn(),
|
||||||
|
getAssets: jest.fn(),
|
||||||
|
getAllWithoutFaces: jest.fn(),
|
||||||
|
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
deleteAll: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
};
|
||||||
|
};
|
@ -6,11 +6,15 @@ export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => {
|
|||||||
checkMigrationStatus: jest.fn(),
|
checkMigrationStatus: jest.fn(),
|
||||||
importAssets: jest.fn(),
|
importAssets: jest.fn(),
|
||||||
importAlbums: jest.fn(),
|
importAlbums: jest.fn(),
|
||||||
|
importFaces: jest.fn(),
|
||||||
deleteAlbums: jest.fn(),
|
deleteAlbums: jest.fn(),
|
||||||
deleteAssets: jest.fn(),
|
deleteAssets: jest.fn(),
|
||||||
|
deleteFaces: jest.fn(),
|
||||||
|
deleteAllFaces: jest.fn(),
|
||||||
searchAssets: jest.fn(),
|
searchAssets: jest.fn(),
|
||||||
searchAlbums: jest.fn(),
|
searchAlbums: jest.fn(),
|
||||||
vectorSearch: jest.fn(),
|
vectorSearch: jest.fn(),
|
||||||
explore: jest.fn(),
|
explore: jest.fn(),
|
||||||
|
searchFaces: jest.fn(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
25
server/libs/infra/src/entities/asset-face.entity.ts
Normal file
25
server/libs/infra/src/entities/asset-face.entity.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm';
|
||||||
|
import { AssetEntity } from './asset.entity';
|
||||||
|
import { PersonEntity } from './person.entity';
|
||||||
|
|
||||||
|
@Entity('asset_faces')
|
||||||
|
export class AssetFaceEntity {
|
||||||
|
@PrimaryColumn()
|
||||||
|
assetId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn()
|
||||||
|
personId!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'float4',
|
||||||
|
array: true,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
embedding!: number[] | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||||
|
asset!: AssetEntity;
|
||||||
|
|
||||||
|
@ManyToOne(() => PersonEntity, (person) => person.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||||
|
person!: PersonEntity;
|
||||||
|
}
|
@ -7,12 +7,14 @@ import {
|
|||||||
JoinTable,
|
JoinTable,
|
||||||
ManyToMany,
|
ManyToMany,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
OneToOne,
|
OneToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
Unique,
|
Unique,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { AlbumEntity } from './album.entity';
|
import { AlbumEntity } from './album.entity';
|
||||||
|
import { AssetFaceEntity } from './asset-face.entity';
|
||||||
import { ExifEntity } from './exif.entity';
|
import { ExifEntity } from './exif.entity';
|
||||||
import { SharedLinkEntity } from './shared-link.entity';
|
import { SharedLinkEntity } from './shared-link.entity';
|
||||||
import { SmartInfoEntity } from './smart-info.entity';
|
import { SmartInfoEntity } from './smart-info.entity';
|
||||||
@ -109,6 +111,9 @@ export class AssetEntity {
|
|||||||
|
|
||||||
@ManyToMany(() => AlbumEntity, (album) => album.assets, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
@ManyToMany(() => AlbumEntity, (album) => album.assets, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||||
albums?: AlbumEntity[];
|
albums?: AlbumEntity[];
|
||||||
|
|
||||||
|
@OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset)
|
||||||
|
faces!: AssetFaceEntity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AssetType {
|
export enum AssetType {
|
||||||
|
@ -1,18 +1,22 @@
|
|||||||
import { AlbumEntity } from './album.entity';
|
import { AlbumEntity } from './album.entity';
|
||||||
import { APIKeyEntity } from './api-key.entity';
|
import { APIKeyEntity } from './api-key.entity';
|
||||||
|
import { AssetFaceEntity } from './asset-face.entity';
|
||||||
import { AssetEntity } from './asset.entity';
|
import { AssetEntity } from './asset.entity';
|
||||||
import { PartnerEntity } from './partner.entity';
|
import { PartnerEntity } from './partner.entity';
|
||||||
|
import { PersonEntity } from './person.entity';
|
||||||
import { SharedLinkEntity } from './shared-link.entity';
|
import { SharedLinkEntity } from './shared-link.entity';
|
||||||
import { SmartInfoEntity } from './smart-info.entity';
|
import { SmartInfoEntity } from './smart-info.entity';
|
||||||
import { SystemConfigEntity } from './system-config.entity';
|
import { SystemConfigEntity } from './system-config.entity';
|
||||||
import { UserEntity } from './user.entity';
|
|
||||||
import { UserTokenEntity } from './user-token.entity';
|
import { UserTokenEntity } from './user-token.entity';
|
||||||
|
import { UserEntity } from './user.entity';
|
||||||
|
|
||||||
export * from './album.entity';
|
export * from './album.entity';
|
||||||
export * from './api-key.entity';
|
export * from './api-key.entity';
|
||||||
|
export * from './asset-face.entity';
|
||||||
export * from './asset.entity';
|
export * from './asset.entity';
|
||||||
export * from './exif.entity';
|
export * from './exif.entity';
|
||||||
export * from './partner.entity';
|
export * from './partner.entity';
|
||||||
|
export * from './person.entity';
|
||||||
export * from './shared-link.entity';
|
export * from './shared-link.entity';
|
||||||
export * from './smart-info.entity';
|
export * from './smart-info.entity';
|
||||||
export * from './system-config.entity';
|
export * from './system-config.entity';
|
||||||
@ -24,7 +28,9 @@ export const databaseEntities = [
|
|||||||
AlbumEntity,
|
AlbumEntity,
|
||||||
APIKeyEntity,
|
APIKeyEntity,
|
||||||
AssetEntity,
|
AssetEntity,
|
||||||
|
AssetFaceEntity,
|
||||||
PartnerEntity,
|
PartnerEntity,
|
||||||
|
PersonEntity,
|
||||||
SharedLinkEntity,
|
SharedLinkEntity,
|
||||||
SmartInfoEntity,
|
SmartInfoEntity,
|
||||||
SystemConfigEntity,
|
SystemConfigEntity,
|
||||||
|
38
server/libs/infra/src/entities/person.entity.ts
Normal file
38
server/libs/infra/src/entities/person.entity.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { AssetFaceEntity } from './asset-face.entity';
|
||||||
|
import { UserEntity } from './user.entity';
|
||||||
|
|
||||||
|
@Entity('person')
|
||||||
|
export class PersonEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
ownerId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
|
||||||
|
owner!: UserEntity;
|
||||||
|
|
||||||
|
@Column({ default: '' })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ default: '' })
|
||||||
|
thumbnailPath!: string;
|
||||||
|
|
||||||
|
@OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.person)
|
||||||
|
faces!: AssetFaceEntity[];
|
||||||
|
}
|
@ -3,6 +3,7 @@ import {
|
|||||||
IAssetRepository,
|
IAssetRepository,
|
||||||
ICommunicationRepository,
|
ICommunicationRepository,
|
||||||
ICryptoRepository,
|
ICryptoRepository,
|
||||||
|
IFaceRepository,
|
||||||
IGeocodingRepository,
|
IGeocodingRepository,
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
IKeyRepository,
|
IKeyRepository,
|
||||||
@ -10,6 +11,7 @@ import {
|
|||||||
IMediaRepository,
|
IMediaRepository,
|
||||||
immichAppConfig,
|
immichAppConfig,
|
||||||
IPartnerRepository,
|
IPartnerRepository,
|
||||||
|
IPersonRepository,
|
||||||
ISearchRepository,
|
ISearchRepository,
|
||||||
ISharedLinkRepository,
|
ISharedLinkRepository,
|
||||||
ISmartInfoRepository,
|
ISmartInfoRepository,
|
||||||
@ -32,12 +34,14 @@ import {
|
|||||||
AssetRepository,
|
AssetRepository,
|
||||||
CommunicationRepository,
|
CommunicationRepository,
|
||||||
CryptoRepository,
|
CryptoRepository,
|
||||||
|
FaceRepository,
|
||||||
FilesystemProvider,
|
FilesystemProvider,
|
||||||
GeocodingRepository,
|
GeocodingRepository,
|
||||||
JobRepository,
|
JobRepository,
|
||||||
MachineLearningRepository,
|
MachineLearningRepository,
|
||||||
MediaRepository,
|
MediaRepository,
|
||||||
PartnerRepository,
|
PartnerRepository,
|
||||||
|
PersonRepository,
|
||||||
SharedLinkRepository,
|
SharedLinkRepository,
|
||||||
SmartInfoRepository,
|
SmartInfoRepository,
|
||||||
SystemConfigRepository,
|
SystemConfigRepository,
|
||||||
@ -51,12 +55,14 @@ const providers: Provider[] = [
|
|||||||
{ provide: IAssetRepository, useClass: AssetRepository },
|
{ provide: IAssetRepository, useClass: AssetRepository },
|
||||||
{ provide: ICommunicationRepository, useClass: CommunicationRepository },
|
{ provide: ICommunicationRepository, useClass: CommunicationRepository },
|
||||||
{ provide: ICryptoRepository, useClass: CryptoRepository },
|
{ provide: ICryptoRepository, useClass: CryptoRepository },
|
||||||
|
{ provide: IFaceRepository, useClass: FaceRepository },
|
||||||
{ provide: IGeocodingRepository, useClass: GeocodingRepository },
|
{ provide: IGeocodingRepository, useClass: GeocodingRepository },
|
||||||
{ provide: IJobRepository, useClass: JobRepository },
|
{ provide: IJobRepository, useClass: JobRepository },
|
||||||
{ provide: IKeyRepository, useClass: APIKeyRepository },
|
{ provide: IKeyRepository, useClass: APIKeyRepository },
|
||||||
{ provide: IMachineLearningRepository, useClass: MachineLearningRepository },
|
{ provide: IMachineLearningRepository, useClass: MachineLearningRepository },
|
||||||
{ provide: IMediaRepository, useClass: MediaRepository },
|
{ provide: IMediaRepository, useClass: MediaRepository },
|
||||||
{ provide: IPartnerRepository, useClass: PartnerRepository },
|
{ provide: IPartnerRepository, useClass: PartnerRepository },
|
||||||
|
{ provide: IPersonRepository, useClass: PersonRepository },
|
||||||
{ provide: ISearchRepository, useClass: TypesenseRepository },
|
{ provide: ISearchRepository, useClass: TypesenseRepository },
|
||||||
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
|
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
|
||||||
{ provide: ISmartInfoRepository, useClass: SmartInfoRepository },
|
{ provide: ISmartInfoRepository, useClass: SmartInfoRepository },
|
||||||
|
@ -0,0 +1,22 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddFacialTables1684255168091 implements MigrationInterface {
|
||||||
|
name = 'AddFacialTables1684255168091'
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`CREATE TABLE "person" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "ownerId" uuid NOT NULL, "name" character varying NOT NULL DEFAULT '', "thumbnailPath" character varying NOT NULL DEFAULT '', CONSTRAINT "PK_5fdaf670315c4b7e70cce85daa3" PRIMARY KEY ("id"))`);
|
||||||
|
await queryRunner.query(`CREATE TABLE "asset_faces" ("assetId" uuid NOT NULL, "personId" uuid NOT NULL, "embedding" real array, CONSTRAINT "PK_bf339a24070dac7e71304ec530a" PRIMARY KEY ("assetId", "personId"))`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "person" ADD CONSTRAINT "FK_5527cc99f530a547093f9e577b6" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_faces" ADD CONSTRAINT "FK_02a43fd0b3c50fb6d7f0cb7282c" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_faces" ADD CONSTRAINT "FK_95ad7106dd7b484275443f580f9" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_faces" DROP CONSTRAINT "FK_95ad7106dd7b484275443f580f9"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_faces" DROP CONSTRAINT "FK_02a43fd0b3c50fb6d7f0cb7282c"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "person" DROP CONSTRAINT "FK_5527cc99f530a547093f9e577b6"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "asset_faces"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "person"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -15,6 +15,9 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
exifInfo: true,
|
exifInfo: true,
|
||||||
smartInfo: true,
|
smartInfo: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
|
faces: {
|
||||||
|
person: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -35,6 +38,9 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
exifInfo: true,
|
exifInfo: true,
|
||||||
smartInfo: true,
|
smartInfo: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
|
faces: {
|
||||||
|
person: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -48,6 +54,7 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
owner: true,
|
owner: true,
|
||||||
smartInfo: true,
|
smartInfo: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
|
faces: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -129,6 +136,20 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case WithoutProperty.FACES:
|
||||||
|
relations = {
|
||||||
|
faces: true,
|
||||||
|
};
|
||||||
|
where = {
|
||||||
|
resizePath: IsNull(),
|
||||||
|
isVisible: true,
|
||||||
|
faces: {
|
||||||
|
assetId: IsNull(),
|
||||||
|
personId: IsNull(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Invalid getWithout property: ${property}`);
|
throw new Error(`Invalid getWithout property: ${property}`);
|
||||||
}
|
}
|
||||||
|
22
server/libs/infra/src/repositories/face.repository.ts
Normal file
22
server/libs/infra/src/repositories/face.repository.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { AssetFaceId, IFaceRepository } from '@app/domain';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AssetFaceEntity } from '../entities/asset-face.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FaceRepository implements IFaceRepository {
|
||||||
|
constructor(@InjectRepository(AssetFaceEntity) private repository: Repository<AssetFaceEntity>) {}
|
||||||
|
|
||||||
|
getAll(): Promise<AssetFaceEntity[]> {
|
||||||
|
return this.repository.find({ relations: { asset: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
getByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> {
|
||||||
|
return this.repository.find({ where: ids, relations: { asset: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
create(entity: Partial<AssetFaceEntity>): Promise<AssetFaceEntity> {
|
||||||
|
return this.repository.save(entity);
|
||||||
|
}
|
||||||
|
}
|
@ -3,12 +3,14 @@ export * from './api-key.repository';
|
|||||||
export * from './asset.repository';
|
export * from './asset.repository';
|
||||||
export * from './communication.repository';
|
export * from './communication.repository';
|
||||||
export * from './crypto.repository';
|
export * from './crypto.repository';
|
||||||
|
export * from './face.repository';
|
||||||
export * from './filesystem.provider';
|
export * from './filesystem.provider';
|
||||||
export * from './geocoding.repository';
|
export * from './geocoding.repository';
|
||||||
export * from './job.repository';
|
export * from './job.repository';
|
||||||
export * from './machine-learning.repository';
|
export * from './machine-learning.repository';
|
||||||
export * from './media.repository';
|
export * from './media.repository';
|
||||||
export * from './partner.repository';
|
export * from './partner.repository';
|
||||||
|
export * from './person.repository';
|
||||||
export * from './shared-link.repository';
|
export * from './shared-link.repository';
|
||||||
export * from './smart-info.repository';
|
export * from './smart-info.repository';
|
||||||
export * from './system-config.repository';
|
export * from './system-config.repository';
|
||||||
|
@ -20,6 +20,7 @@ export class JobRepository implements IJobRepository {
|
|||||||
[QueueName.THUMBNAIL_GENERATION]: this.generateThumbnail,
|
[QueueName.THUMBNAIL_GENERATION]: this.generateThumbnail,
|
||||||
[QueueName.METADATA_EXTRACTION]: this.metadataExtraction,
|
[QueueName.METADATA_EXTRACTION]: this.metadataExtraction,
|
||||||
[QueueName.OBJECT_TAGGING]: this.objectTagging,
|
[QueueName.OBJECT_TAGGING]: this.objectTagging,
|
||||||
|
[QueueName.RECOGNIZE_FACES]: this.recognizeFaces,
|
||||||
[QueueName.CLIP_ENCODING]: this.clipEmbedding,
|
[QueueName.CLIP_ENCODING]: this.clipEmbedding,
|
||||||
[QueueName.VIDEO_CONVERSION]: this.videoTranscode,
|
[QueueName.VIDEO_CONVERSION]: this.videoTranscode,
|
||||||
[QueueName.BACKGROUND_TASK]: this.backgroundTask,
|
[QueueName.BACKGROUND_TASK]: this.backgroundTask,
|
||||||
@ -31,6 +32,7 @@ export class JobRepository implements IJobRepository {
|
|||||||
@InjectQueue(QueueName.OBJECT_TAGGING) private objectTagging: Queue<IAssetJob | IBaseJob>,
|
@InjectQueue(QueueName.OBJECT_TAGGING) private objectTagging: Queue<IAssetJob | IBaseJob>,
|
||||||
@InjectQueue(QueueName.CLIP_ENCODING) private clipEmbedding: Queue<IAssetJob | IBaseJob>,
|
@InjectQueue(QueueName.CLIP_ENCODING) private clipEmbedding: Queue<IAssetJob | IBaseJob>,
|
||||||
@InjectQueue(QueueName.METADATA_EXTRACTION) private metadataExtraction: Queue<IAssetUploadedJob | IBaseJob>,
|
@InjectQueue(QueueName.METADATA_EXTRACTION) private metadataExtraction: Queue<IAssetUploadedJob | IBaseJob>,
|
||||||
|
@InjectQueue(QueueName.RECOGNIZE_FACES) private recognizeFaces: Queue<IAssetJob | IBaseJob>,
|
||||||
@InjectQueue(QueueName.STORAGE_TEMPLATE_MIGRATION) private storageTemplateMigration: Queue,
|
@InjectQueue(QueueName.STORAGE_TEMPLATE_MIGRATION) private storageTemplateMigration: Queue,
|
||||||
@InjectQueue(QueueName.THUMBNAIL_GENERATION) private generateThumbnail: Queue,
|
@InjectQueue(QueueName.THUMBNAIL_GENERATION) private generateThumbnail: Queue,
|
||||||
@InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue<IAssetJob | IBaseJob>,
|
@InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue<IAssetJob | IBaseJob>,
|
||||||
@ -91,6 +93,19 @@ export class JobRepository implements IJobRepository {
|
|||||||
await this.metadataExtraction.add(item.name, item.data);
|
await this.metadataExtraction.add(item.name, item.data);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case JobName.QUEUE_RECOGNIZE_FACES:
|
||||||
|
case JobName.RECOGNIZE_FACES:
|
||||||
|
await this.recognizeFaces.add(item.name, item.data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case JobName.GENERATE_FACE_THUMBNAIL:
|
||||||
|
await this.recognizeFaces.add(item.name, item.data, { priority: 1 });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case JobName.PERSON_CLEANUP:
|
||||||
|
await this.backgroundTask.add(item.name);
|
||||||
|
break;
|
||||||
|
|
||||||
case JobName.QUEUE_GENERATE_THUMBNAILS:
|
case JobName.QUEUE_GENERATE_THUMBNAILS:
|
||||||
case JobName.GENERATE_JPEG_THUMBNAIL:
|
case JobName.GENERATE_JPEG_THUMBNAIL:
|
||||||
case JobName.GENERATE_WEBP_THUMBNAIL:
|
case JobName.GENERATE_WEBP_THUMBNAIL:
|
||||||
@ -120,13 +135,19 @@ export class JobRepository implements IJobRepository {
|
|||||||
|
|
||||||
case JobName.SEARCH_INDEX_ASSETS:
|
case JobName.SEARCH_INDEX_ASSETS:
|
||||||
case JobName.SEARCH_INDEX_ALBUMS:
|
case JobName.SEARCH_INDEX_ALBUMS:
|
||||||
|
case JobName.SEARCH_INDEX_FACES:
|
||||||
await this.searchIndex.add(item.name, {});
|
await this.searchIndex.add(item.name, {});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case JobName.SEARCH_INDEX_ASSET:
|
case JobName.SEARCH_INDEX_ASSET:
|
||||||
case JobName.SEARCH_INDEX_ALBUM:
|
case JobName.SEARCH_INDEX_ALBUM:
|
||||||
|
case JobName.SEARCH_INDEX_FACE:
|
||||||
|
await this.searchIndex.add(item.name, item.data);
|
||||||
|
break;
|
||||||
|
|
||||||
case JobName.SEARCH_REMOVE_ALBUM:
|
case JobName.SEARCH_REMOVE_ALBUM:
|
||||||
case JobName.SEARCH_REMOVE_ASSET:
|
case JobName.SEARCH_REMOVE_ASSET:
|
||||||
|
case JobName.SEARCH_REMOVE_FACE:
|
||||||
await this.searchIndex.add(item.name, item.data);
|
await this.searchIndex.add(item.name, item.data);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { IMachineLearningRepository, MachineLearningInput, MACHINE_LEARNING_URL } from '@app/domain';
|
import { DetectFaceResult, IMachineLearningRepository, MachineLearningInput, MACHINE_LEARNING_URL } from '@app/domain';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
@ -10,6 +10,10 @@ export class MachineLearningRepository implements IMachineLearningRepository {
|
|||||||
return client.post<string[]>('/image-classifier/tag-image', input).then((res) => res.data);
|
return client.post<string[]>('/image-classifier/tag-image', input).then((res) => res.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
detectFaces(input: MachineLearningInput): Promise<DetectFaceResult[]> {
|
||||||
|
return client.post<DetectFaceResult[]>('/facial-recognition/detect-faces', input).then((res) => res.data);
|
||||||
|
}
|
||||||
|
|
||||||
detectObjects(input: MachineLearningInput): Promise<string[]> {
|
detectObjects(input: MachineLearningInput): Promise<string[]> {
|
||||||
return client.post<string[]>('/object-detection/detect-object', input).then((res) => res.data);
|
return client.post<string[]>('/object-detection/detect-object', input).then((res) => res.data);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { IMediaRepository, ResizeOptions, VideoInfo } from '@app/domain';
|
import { CropOptions, IMediaRepository, ResizeOptions, VideoInfo } from '@app/domain';
|
||||||
import { exiftool } from 'exiftool-vendored';
|
import { exiftool } from 'exiftool-vendored';
|
||||||
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
|
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
@ -7,11 +7,22 @@ import { promisify } from 'util';
|
|||||||
const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
|
const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
|
||||||
|
|
||||||
export class MediaRepository implements IMediaRepository {
|
export class MediaRepository implements IMediaRepository {
|
||||||
|
crop(input: string, options: CropOptions): Promise<Buffer> {
|
||||||
|
return sharp(input, { failOnError: false })
|
||||||
|
.extract({
|
||||||
|
left: options.left,
|
||||||
|
top: options.top,
|
||||||
|
width: options.width,
|
||||||
|
height: options.height,
|
||||||
|
})
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
extractThumbnailFromExif(input: string, output: string): Promise<void> {
|
extractThumbnailFromExif(input: string, output: string): Promise<void> {
|
||||||
return exiftool.extractThumbnail(input, output);
|
return exiftool.extractThumbnail(input, output);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resize(input: string, output: string, options: ResizeOptions): Promise<void> {
|
async resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void> {
|
||||||
switch (options.format) {
|
switch (options.format) {
|
||||||
case 'webp':
|
case 'webp':
|
||||||
await sharp(input, { failOnError: false })
|
await sharp(input, { failOnError: false })
|
||||||
|
78
server/libs/infra/src/repositories/person.repository.ts
Normal file
78
server/libs/infra/src/repositories/person.repository.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { IPersonRepository, PersonSearchOptions } from '@app/domain';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities';
|
||||||
|
|
||||||
|
export class PersonRepository implements IPersonRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||||
|
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
|
||||||
|
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
delete(entity: PersonEntity): Promise<PersonEntity | null> {
|
||||||
|
return this.personRepository.remove(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAll(): Promise<number> {
|
||||||
|
const people = await this.personRepository.find();
|
||||||
|
await this.personRepository.remove(people);
|
||||||
|
return people.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(userId: string, options?: PersonSearchOptions): Promise<PersonEntity[]> {
|
||||||
|
return this.personRepository
|
||||||
|
.createQueryBuilder('person')
|
||||||
|
.leftJoin('person.faces', 'face')
|
||||||
|
.where('person.ownerId = :userId', { userId })
|
||||||
|
.orderBy('COUNT(face.assetId)', 'DESC')
|
||||||
|
.having('COUNT(face.assetId) >= :faces', { faces: options?.minimumFaceCount || 1 })
|
||||||
|
.groupBy('person.id')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllWithoutFaces(): Promise<PersonEntity[]> {
|
||||||
|
return this.personRepository
|
||||||
|
.createQueryBuilder('person')
|
||||||
|
.leftJoin('person.faces', 'face')
|
||||||
|
.having('COUNT(face.assetId) = 0')
|
||||||
|
.groupBy('person.id')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(ownerId: string, personId: string): Promise<PersonEntity | null> {
|
||||||
|
return this.personRepository.findOne({ where: { id: personId, ownerId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
getAssets(ownerId: string, personId: string): Promise<AssetEntity[]> {
|
||||||
|
return this.assetRepository.find({
|
||||||
|
where: {
|
||||||
|
ownerId,
|
||||||
|
faces: {
|
||||||
|
personId,
|
||||||
|
},
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
relations: {
|
||||||
|
faces: {
|
||||||
|
person: true,
|
||||||
|
},
|
||||||
|
exifInfo: true,
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
createdAt: 'ASC',
|
||||||
|
},
|
||||||
|
// TODO: remove after either (1) pagination or (2) time bucket is implemented for this query
|
||||||
|
take: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
create(entity: Partial<PersonEntity>): Promise<PersonEntity> {
|
||||||
|
return this.personRepository.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(entity: Partial<PersonEntity>): Promise<PersonEntity> {
|
||||||
|
const { id } = await this.personRepository.save(entity);
|
||||||
|
return this.personRepository.findOneByOrFail({ id });
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
ISearchRepository,
|
ISearchRepository,
|
||||||
|
OwnedFaceEntity,
|
||||||
SearchCollection,
|
SearchCollection,
|
||||||
SearchCollectionIndexStatus,
|
SearchCollectionIndexStatus,
|
||||||
SearchExploreItem,
|
SearchExploreItem,
|
||||||
|
SearchFaceFilter,
|
||||||
SearchFilter,
|
SearchFilter,
|
||||||
SearchResult,
|
SearchResult,
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
@ -12,9 +14,9 @@ import { catchError, filter, firstValueFrom, from, map, mergeMap, of, toArray }
|
|||||||
import { Client } from 'typesense';
|
import { Client } from 'typesense';
|
||||||
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
||||||
import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents';
|
import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents';
|
||||||
import { AlbumEntity, AssetEntity } from '../entities';
|
import { AlbumEntity, AssetEntity, AssetFaceEntity } from '../entities';
|
||||||
import { typesenseConfig } from '../infra.config';
|
import { typesenseConfig } from '../infra.config';
|
||||||
import { albumSchema, assetSchema } from '../typesense-schemas';
|
import { albumSchema, assetSchema, faceSchema } from '../typesense-schemas';
|
||||||
|
|
||||||
function removeNil<T extends Dictionary<any>>(item: T): T {
|
function removeNil<T extends Dictionary<any>>(item: T): T {
|
||||||
_.forOwn(item, (value, key) => {
|
_.forOwn(item, (value, key) => {
|
||||||
@ -26,14 +28,21 @@ function removeNil<T extends Dictionary<any>>(item: T): T {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MultiSearchError {
|
||||||
|
code: number;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface CustomAssetEntity extends AssetEntity {
|
interface CustomAssetEntity extends AssetEntity {
|
||||||
geo?: [number, number];
|
geo?: [number, number];
|
||||||
motion?: boolean;
|
motion?: boolean;
|
||||||
|
people?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const schemaMap: Record<SearchCollection, CollectionCreateSchema> = {
|
const schemaMap: Record<SearchCollection, CollectionCreateSchema> = {
|
||||||
[SearchCollection.ASSETS]: assetSchema,
|
[SearchCollection.ASSETS]: assetSchema,
|
||||||
[SearchCollection.ALBUMS]: albumSchema,
|
[SearchCollection.ALBUMS]: albumSchema,
|
||||||
|
[SearchCollection.FACES]: faceSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
const schemas = Object.entries(schemaMap) as [SearchCollection, CollectionCreateSchema][];
|
const schemas = Object.entries(schemaMap) as [SearchCollection, CollectionCreateSchema][];
|
||||||
@ -61,7 +70,7 @@ export class TypesenseRepository implements ISearchRepository {
|
|||||||
async setup(): Promise<void> {
|
async setup(): Promise<void> {
|
||||||
const collections = await this.client.collections().retrieve();
|
const collections = await this.client.collections().retrieve();
|
||||||
for (const collection of collections) {
|
for (const collection of collections) {
|
||||||
this.logger.debug(`${collection.name} => ${collection.num_documents}`);
|
this.logger.debug(`${collection.name} collection has ${collection.num_documents} documents`);
|
||||||
// await this.client.collections(collection.name).delete();
|
// await this.client.collections(collection.name).delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,6 +93,7 @@ export class TypesenseRepository implements ISearchRepository {
|
|||||||
const migrationMap: SearchCollectionIndexStatus = {
|
const migrationMap: SearchCollectionIndexStatus = {
|
||||||
[SearchCollection.ASSETS]: false,
|
[SearchCollection.ASSETS]: false,
|
||||||
[SearchCollection.ALBUMS]: false,
|
[SearchCollection.ALBUMS]: false,
|
||||||
|
[SearchCollection.FACES]: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// check if alias is using the current schema
|
// check if alias is using the current schema
|
||||||
@ -110,9 +120,13 @@ export class TypesenseRepository implements ISearchRepository {
|
|||||||
await this.import(SearchCollection.ASSETS, items, done);
|
await this.import(SearchCollection.ASSETS, items, done);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async importFaces(items: OwnedFaceEntity[], done: boolean): Promise<void> {
|
||||||
|
await this.import(SearchCollection.FACES, items, done);
|
||||||
|
}
|
||||||
|
|
||||||
private async import(
|
private async import(
|
||||||
collection: SearchCollection,
|
collection: SearchCollection,
|
||||||
items: AlbumEntity[] | AssetEntity[],
|
items: AlbumEntity[] | AssetEntity[] | OwnedFaceEntity[],
|
||||||
done: boolean,
|
done: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@ -198,6 +212,15 @@ export class TypesenseRepository implements ISearchRepository {
|
|||||||
await this.delete(SearchCollection.ASSETS, ids);
|
await this.delete(SearchCollection.ASSETS, ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteFaces(ids: string[]): Promise<void> {
|
||||||
|
await this.delete(SearchCollection.FACES, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAllFaces(): Promise<number> {
|
||||||
|
const records = await this.client.collections(faceSchema.name).documents().delete({ filter_by: 'ownerId:!=null' });
|
||||||
|
return records.num_deleted;
|
||||||
|
}
|
||||||
|
|
||||||
async delete(collection: SearchCollection, ids: string[]): Promise<void> {
|
async delete(collection: SearchCollection, ids: string[]): Promise<void> {
|
||||||
await this.client
|
await this.client
|
||||||
.collections(schemaMap[collection].name)
|
.collections(schemaMap[collection].name)
|
||||||
@ -232,6 +255,7 @@ export class TypesenseRepository implements ISearchRepository {
|
|||||||
'exifInfo.description',
|
'exifInfo.description',
|
||||||
'smartInfo.tags',
|
'smartInfo.tags',
|
||||||
'smartInfo.objects',
|
'smartInfo.objects',
|
||||||
|
'people',
|
||||||
].join(','),
|
].join(','),
|
||||||
per_page: 250,
|
per_page: 250,
|
||||||
facet_by: this.getFacetFieldNames(SearchCollection.ASSETS),
|
facet_by: this.getFacetFieldNames(SearchCollection.ASSETS),
|
||||||
@ -242,6 +266,22 @@ export class TypesenseRepository implements ISearchRepository {
|
|||||||
return this.asResponse(results, filters.debug);
|
return this.asResponse(results, filters.debug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchFaces(input: number[], filters: SearchFaceFilter): Promise<SearchResult<AssetFaceEntity>> {
|
||||||
|
const { results } = await this.client.multiSearch.perform({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
collection: faceSchema.name,
|
||||||
|
q: '*',
|
||||||
|
vector_query: `embedding:([${input.join(',')}], k:5)`,
|
||||||
|
per_page: 250,
|
||||||
|
filter_by: this.buildFilterBy('ownerId', filters.ownerId, true),
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.asResponse(results[0] as SearchResponse<AssetFaceEntity>);
|
||||||
|
}
|
||||||
|
|
||||||
async vectorSearch(input: number[], filters: SearchFilter): Promise<SearchResult<AssetEntity>> {
|
async vectorSearch(input: number[], filters: SearchFilter): Promise<SearchResult<AssetEntity>> {
|
||||||
const { results } = await this.client.multiSearch.perform({
|
const { results } = await this.client.multiSearch.perform({
|
||||||
searches: [
|
searches: [
|
||||||
@ -259,12 +299,23 @@ export class TypesenseRepository implements ISearchRepository {
|
|||||||
return this.asResponse(results[0] as SearchResponse<AssetEntity>, filters.debug);
|
return this.asResponse(results[0] as SearchResponse<AssetEntity>, filters.debug);
|
||||||
}
|
}
|
||||||
|
|
||||||
private asResponse<T extends DocumentSchema>(results: SearchResponse<T>, debug?: boolean): SearchResult<T> {
|
private asResponse<T extends DocumentSchema>(
|
||||||
|
resultsOrError: SearchResponse<T> | MultiSearchError,
|
||||||
|
debug?: boolean,
|
||||||
|
): SearchResult<T> {
|
||||||
|
const { error, code } = resultsOrError as MultiSearchError;
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Typesense multi-search error: ${code} - ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = resultsOrError as SearchResponse<T>;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page: results.page,
|
page: results.page,
|
||||||
total: results.found,
|
total: results.found,
|
||||||
count: results.out_of,
|
count: results.out_of,
|
||||||
items: (results.hits || []).map((hit) => hit.document),
|
items: (results.hits || []).map((hit) => hit.document),
|
||||||
|
distances: (results.hits || []).map((hit: any) => hit.vector_distance),
|
||||||
facets: (results.facet_counts || []).map((facet) => ({
|
facets: (results.facet_counts || []).map((facet) => ({
|
||||||
counts: facet.counts.map((item) => ({ count: item.count, value: item.value })),
|
counts: facet.counts.map((item) => ({ count: item.count, value: item.value })),
|
||||||
fieldName: facet.field_name as string,
|
fieldName: facet.field_name as string,
|
||||||
@ -306,12 +357,17 @@ export class TypesenseRepository implements ISearchRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private patch(collection: SearchCollection, items: AssetEntity[] | AlbumEntity[]) {
|
private patch(collection: SearchCollection, items: AssetEntity[] | AlbumEntity[] | OwnedFaceEntity[]) {
|
||||||
return items.map((item) =>
|
return items.map((item) => {
|
||||||
collection === SearchCollection.ASSETS
|
switch (collection) {
|
||||||
? this.patchAsset(item as AssetEntity)
|
case SearchCollection.ASSETS:
|
||||||
: this.patchAlbum(item as AlbumEntity),
|
return this.patchAsset(item as AssetEntity);
|
||||||
);
|
case SearchCollection.ALBUMS:
|
||||||
|
return this.patchAlbum(item as AlbumEntity);
|
||||||
|
case SearchCollection.FACES:
|
||||||
|
return this.patchFace(item as OwnedFaceEntity);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private patchAlbum(album: AlbumEntity): AlbumEntity {
|
private patchAlbum(album: AlbumEntity): AlbumEntity {
|
||||||
@ -327,9 +383,17 @@ export class TypesenseRepository implements ISearchRepository {
|
|||||||
custom = { ...custom, geo: [lat, lng] };
|
custom = { ...custom, geo: [lat, lng] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const people = asset.faces?.map((face) => face.person.name).filter((name) => name) || [];
|
||||||
|
if (people.length) {
|
||||||
|
custom = { ...custom, people };
|
||||||
|
}
|
||||||
return removeNil({ ...custom, motion: !!asset.livePhotoVideoId });
|
return removeNil({ ...custom, motion: !!asset.livePhotoVideoId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private patchFace(face: OwnedFaceEntity): OwnedFaceEntity {
|
||||||
|
return removeNil(face);
|
||||||
|
}
|
||||||
|
|
||||||
private getFacetFieldNames(collection: SearchCollection) {
|
private getFacetFieldNames(collection: SearchCollection) {
|
||||||
return (schemaMap[collection].fields || [])
|
return (schemaMap[collection].fields || [])
|
||||||
.filter((field) => field.facet)
|
.filter((field) => field.facet)
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
||||||
|
|
||||||
export const assetSchemaVersion = 5;
|
export const assetSchemaVersion = 7;
|
||||||
export const assetSchema: CollectionCreateSchema = {
|
export const assetSchema: CollectionCreateSchema = {
|
||||||
name: `assets-v${assetSchemaVersion}`,
|
name: `assets-v${assetSchemaVersion}`,
|
||||||
fields: [
|
fields: [
|
||||||
@ -32,6 +32,7 @@ export const assetSchema: CollectionCreateSchema = {
|
|||||||
// computed
|
// computed
|
||||||
{ name: 'geo', type: 'geopoint', facet: false, optional: true },
|
{ name: 'geo', type: 'geopoint', facet: false, optional: true },
|
||||||
{ name: 'motion', type: 'bool', facet: true },
|
{ name: 'motion', type: 'bool', facet: true },
|
||||||
|
{ name: 'people', type: 'string[]', facet: true, optional: true },
|
||||||
],
|
],
|
||||||
token_separators: ['.'],
|
token_separators: ['.'],
|
||||||
enable_nested_fields: true,
|
enable_nested_fields: true,
|
||||||
|
12
server/libs/infra/src/typesense-schemas/face.schema.ts
Normal file
12
server/libs/infra/src/typesense-schemas/face.schema.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
||||||
|
|
||||||
|
export const faceSchemaVersion = 1;
|
||||||
|
export const faceSchema: CollectionCreateSchema = {
|
||||||
|
name: `faces-v${faceSchemaVersion}`,
|
||||||
|
fields: [
|
||||||
|
{ name: 'ownerId', type: 'string', facet: false },
|
||||||
|
{ name: 'assetId', type: 'string', facet: false },
|
||||||
|
{ name: 'personId', type: 'string', facet: false },
|
||||||
|
{ name: 'embedding', type: 'float[]', facet: false, num_dim: 512 },
|
||||||
|
],
|
||||||
|
};
|
@ -1,2 +1,3 @@
|
|||||||
export * from './album.schema';
|
export * from './album.schema';
|
||||||
export * from './asset.schema';
|
export * from './asset.schema';
|
||||||
|
export * from './face.schema';
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
ConfigurationParameters,
|
ConfigurationParameters,
|
||||||
JobApi,
|
JobApi,
|
||||||
OAuthApi,
|
OAuthApi,
|
||||||
|
PersonApi,
|
||||||
PartnerApi,
|
PartnerApi,
|
||||||
SearchApi,
|
SearchApi,
|
||||||
ServerInfoApi,
|
ServerInfoApi,
|
||||||
@ -31,6 +32,7 @@ export class ImmichApi {
|
|||||||
public searchApi: SearchApi;
|
public searchApi: SearchApi;
|
||||||
public serverInfoApi: ServerInfoApi;
|
public serverInfoApi: ServerInfoApi;
|
||||||
public shareApi: ShareApi;
|
public shareApi: ShareApi;
|
||||||
|
public personApi: PersonApi;
|
||||||
public systemConfigApi: SystemConfigApi;
|
public systemConfigApi: SystemConfigApi;
|
||||||
public userApi: UserApi;
|
public userApi: UserApi;
|
||||||
|
|
||||||
@ -49,6 +51,7 @@ export class ImmichApi {
|
|||||||
this.searchApi = new SearchApi(this.config);
|
this.searchApi = new SearchApi(this.config);
|
||||||
this.serverInfoApi = new ServerInfoApi(this.config);
|
this.serverInfoApi = new ServerInfoApi(this.config);
|
||||||
this.shareApi = new ShareApi(this.config);
|
this.shareApi = new ShareApi(this.config);
|
||||||
|
this.personApi = new PersonApi(this.config);
|
||||||
this.systemConfigApi = new SystemConfigApi(this.config);
|
this.systemConfigApi = new SystemConfigApi(this.config);
|
||||||
this.userApi = new UserApi(this.config);
|
this.userApi = new UserApi(this.config);
|
||||||
}
|
}
|
||||||
@ -98,6 +101,11 @@ export class ImmichApi {
|
|||||||
const path = `/user/profile-image/${userId}`;
|
const path = `/user/profile-image/${userId}`;
|
||||||
return this.createUrl(path);
|
return this.createUrl(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPeopleThumbnailUrl(personId: string) {
|
||||||
|
const path = `/person/${personId}/thumbnail`;
|
||||||
|
return this.createUrl(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ImmichApi({ basePath: '/api' });
|
export const api = new ImmichApi({ basePath: '/api' });
|
||||||
|
451
web/src/api/open-api/api.ts
generated
451
web/src/api/open-api/api.ts
generated
@ -339,6 +339,12 @@ export interface AllJobStatusResponseDto {
|
|||||||
* @memberof AllJobStatusResponseDto
|
* @memberof AllJobStatusResponseDto
|
||||||
*/
|
*/
|
||||||
'search-queue': JobStatusDto;
|
'search-queue': JobStatusDto;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {JobStatusDto}
|
||||||
|
* @memberof AllJobStatusResponseDto
|
||||||
|
*/
|
||||||
|
'recognize-faces-queue': JobStatusDto;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -566,6 +572,12 @@ export interface AssetResponseDto {
|
|||||||
* @memberof AssetResponseDto
|
* @memberof AssetResponseDto
|
||||||
*/
|
*/
|
||||||
'tags'?: Array<TagResponseDto>;
|
'tags'?: Array<TagResponseDto>;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {Array<PersonResponseDto>}
|
||||||
|
* @memberof AssetResponseDto
|
||||||
|
*/
|
||||||
|
'people'?: Array<PersonResponseDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1329,6 +1341,7 @@ export const JobName = {
|
|||||||
MetadataExtractionQueue: 'metadata-extraction-queue',
|
MetadataExtractionQueue: 'metadata-extraction-queue',
|
||||||
VideoConversionQueue: 'video-conversion-queue',
|
VideoConversionQueue: 'video-conversion-queue',
|
||||||
ObjectTaggingQueue: 'object-tagging-queue',
|
ObjectTaggingQueue: 'object-tagging-queue',
|
||||||
|
RecognizeFacesQueue: 'recognize-faces-queue',
|
||||||
ClipEncodingQueue: 'clip-encoding-queue',
|
ClipEncodingQueue: 'clip-encoding-queue',
|
||||||
BackgroundTaskQueue: 'background-task-queue',
|
BackgroundTaskQueue: 'background-task-queue',
|
||||||
StorageTemplateMigrationQueue: 'storage-template-migration-queue',
|
StorageTemplateMigrationQueue: 'storage-template-migration-queue',
|
||||||
@ -1546,6 +1559,44 @@ export interface OAuthConfigResponseDto {
|
|||||||
*/
|
*/
|
||||||
'autoLaunch'?: boolean;
|
'autoLaunch'?: boolean;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @interface PersonResponseDto
|
||||||
|
*/
|
||||||
|
export interface PersonResponseDto {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof PersonResponseDto
|
||||||
|
*/
|
||||||
|
'id': string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof PersonResponseDto
|
||||||
|
*/
|
||||||
|
'name': string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof PersonResponseDto
|
||||||
|
*/
|
||||||
|
'thumbnailPath': string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @interface PersonUpdateDto
|
||||||
|
*/
|
||||||
|
export interface PersonUpdateDto {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof PersonUpdateDto
|
||||||
|
*/
|
||||||
|
'name': string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @export
|
* @export
|
||||||
@ -7460,6 +7511,406 @@ export class PartnerApi extends BaseAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PersonApi - axios parameter creator
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const PersonApiAxiosParamCreator = function (configuration?: Configuration) {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getAllPeople: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
const localVarPath = `/person`;
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
let baseOptions;
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||||
|
const localVarHeaderParameter = {} as any;
|
||||||
|
const localVarQueryParameter = {} as any;
|
||||||
|
|
||||||
|
// authentication cookie required
|
||||||
|
|
||||||
|
// authentication api_key required
|
||||||
|
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||||
|
|
||||||
|
// authentication bearer required
|
||||||
|
// http bearer authentication required
|
||||||
|
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getPerson: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
// verify required parameter 'id' is not null or undefined
|
||||||
|
assertParamExists('getPerson', 'id', id)
|
||||||
|
const localVarPath = `/person/{id}`
|
||||||
|
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
let baseOptions;
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||||
|
const localVarHeaderParameter = {} as any;
|
||||||
|
const localVarQueryParameter = {} as any;
|
||||||
|
|
||||||
|
// authentication cookie required
|
||||||
|
|
||||||
|
// authentication api_key required
|
||||||
|
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||||
|
|
||||||
|
// authentication bearer required
|
||||||
|
// http bearer authentication required
|
||||||
|
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getPersonAssets: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
// verify required parameter 'id' is not null or undefined
|
||||||
|
assertParamExists('getPersonAssets', 'id', id)
|
||||||
|
const localVarPath = `/person/{id}/assets`
|
||||||
|
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
let baseOptions;
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||||
|
const localVarHeaderParameter = {} as any;
|
||||||
|
const localVarQueryParameter = {} as any;
|
||||||
|
|
||||||
|
// authentication cookie required
|
||||||
|
|
||||||
|
// authentication api_key required
|
||||||
|
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||||
|
|
||||||
|
// authentication bearer required
|
||||||
|
// http bearer authentication required
|
||||||
|
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getPersonThumbnail: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
// verify required parameter 'id' is not null or undefined
|
||||||
|
assertParamExists('getPersonThumbnail', 'id', id)
|
||||||
|
const localVarPath = `/person/{id}/thumbnail`
|
||||||
|
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
let baseOptions;
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||||
|
const localVarHeaderParameter = {} as any;
|
||||||
|
const localVarQueryParameter = {} as any;
|
||||||
|
|
||||||
|
// authentication cookie required
|
||||||
|
|
||||||
|
// authentication api_key required
|
||||||
|
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||||
|
|
||||||
|
// authentication bearer required
|
||||||
|
// http bearer authentication required
|
||||||
|
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {PersonUpdateDto} personUpdateDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
updatePerson: async (id: string, personUpdateDto: PersonUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
// verify required parameter 'id' is not null or undefined
|
||||||
|
assertParamExists('updatePerson', 'id', id)
|
||||||
|
// verify required parameter 'personUpdateDto' is not null or undefined
|
||||||
|
assertParamExists('updatePerson', 'personUpdateDto', personUpdateDto)
|
||||||
|
const localVarPath = `/person/{id}`
|
||||||
|
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
let baseOptions;
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
|
||||||
|
const localVarHeaderParameter = {} as any;
|
||||||
|
const localVarQueryParameter = {} as any;
|
||||||
|
|
||||||
|
// authentication cookie required
|
||||||
|
|
||||||
|
// authentication api_key required
|
||||||
|
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||||
|
|
||||||
|
// authentication bearer required
|
||||||
|
// http bearer authentication required
|
||||||
|
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
|
localVarRequestOptions.data = serializeDataIfNeeded(personUpdateDto, localVarRequestOptions, configuration)
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PersonApi - functional programming interface
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const PersonApiFp = function(configuration?: Configuration) {
|
||||||
|
const localVarAxiosParamCreator = PersonApiAxiosParamCreator(configuration)
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async getAllPeople(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(options);
|
||||||
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async getPerson(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PersonResponseDto>> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.getPerson(id, options);
|
||||||
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async getPersonAssets(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonAssets(id, options);
|
||||||
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async getPersonThumbnail(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonThumbnail(id, options);
|
||||||
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {PersonUpdateDto} personUpdateDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async updatePerson(id: string, personUpdateDto: PersonUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PersonResponseDto>> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.updatePerson(id, personUpdateDto, options);
|
||||||
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PersonApi - factory interface
|
||||||
|
* @export
|
||||||
|
*/
|
||||||
|
export const PersonApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
|
||||||
|
const localVarFp = PersonApiFp(configuration)
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getAllPeople(options?: any): AxiosPromise<Array<PersonResponseDto>> {
|
||||||
|
return localVarFp.getAllPeople(options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getPerson(id: string, options?: any): AxiosPromise<PersonResponseDto> {
|
||||||
|
return localVarFp.getPerson(id, options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getPersonAssets(id: string, options?: any): AxiosPromise<Array<AssetResponseDto>> {
|
||||||
|
return localVarFp.getPersonAssets(id, options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getPersonThumbnail(id: string, options?: any): AxiosPromise<File> {
|
||||||
|
return localVarFp.getPersonThumbnail(id, options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {PersonUpdateDto} personUpdateDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
updatePerson(id: string, personUpdateDto: PersonUpdateDto, options?: any): AxiosPromise<PersonResponseDto> {
|
||||||
|
return localVarFp.updatePerson(id, personUpdateDto, options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PersonApi - object-oriented interface
|
||||||
|
* @export
|
||||||
|
* @class PersonApi
|
||||||
|
* @extends {BaseAPI}
|
||||||
|
*/
|
||||||
|
export class PersonApi extends BaseAPI {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof PersonApi
|
||||||
|
*/
|
||||||
|
public getAllPeople(options?: AxiosRequestConfig) {
|
||||||
|
return PersonApiFp(this.configuration).getAllPeople(options).then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof PersonApi
|
||||||
|
*/
|
||||||
|
public getPerson(id: string, options?: AxiosRequestConfig) {
|
||||||
|
return PersonApiFp(this.configuration).getPerson(id, options).then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof PersonApi
|
||||||
|
*/
|
||||||
|
public getPersonAssets(id: string, options?: AxiosRequestConfig) {
|
||||||
|
return PersonApiFp(this.configuration).getPersonAssets(id, options).then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof PersonApi
|
||||||
|
*/
|
||||||
|
public getPersonThumbnail(id: string, options?: AxiosRequestConfig) {
|
||||||
|
return PersonApiFp(this.configuration).getPersonThumbnail(id, options).then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {PersonUpdateDto} personUpdateDto
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof PersonApi
|
||||||
|
*/
|
||||||
|
public updatePerson(id: string, personUpdateDto: PersonUpdateDto, options?: AxiosRequestConfig) {
|
||||||
|
return PersonApiFp(this.configuration).updatePerson(id, personUpdateDto, options).then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SearchApi - axios parameter creator
|
* SearchApi - axios parameter creator
|
||||||
* @export
|
* @export
|
||||||
|
@ -36,6 +36,10 @@
|
|||||||
title: 'Encode Clip',
|
title: 'Encode Clip',
|
||||||
subtitle: 'Run machine learning to generate clip embeddings'
|
subtitle: 'Run machine learning to generate clip embeddings'
|
||||||
},
|
},
|
||||||
|
[JobName.RecognizeFacesQueue]: {
|
||||||
|
title: 'Recognize Faces',
|
||||||
|
subtitle: 'Run machine learning to recognize faces'
|
||||||
|
},
|
||||||
[JobName.VideoConversionQueue]: {
|
[JobName.VideoConversionQueue]: {
|
||||||
title: 'Transcode Videos',
|
title: 'Transcode Videos',
|
||||||
subtitle: 'Transcode videos not in the desired format'
|
subtitle: 'Transcode videos not in the desired format'
|
||||||
|
@ -120,10 +120,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const clearMultiSelectAssetAssetHandler = () => {
|
|
||||||
multiSelectAsset = new Set();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update Album Name
|
// Update Album Name
|
||||||
$: {
|
$: {
|
||||||
if (!isEditingTitle && currentAlbumName != album.albumName && isOwned) {
|
if (!isEditingTitle && currentAlbumName != album.albumName && isOwned) {
|
||||||
@ -340,7 +336,7 @@
|
|||||||
{#if isMultiSelectionMode}
|
{#if isMultiSelectionMode}
|
||||||
<AssetSelectControlBar
|
<AssetSelectControlBar
|
||||||
assets={multiSelectAsset}
|
assets={multiSelectAsset}
|
||||||
clearSelect={clearMultiSelectAssetAssetHandler}
|
clearSelect={() => (multiSelectAsset = new Set())}
|
||||||
>
|
>
|
||||||
<DownloadFiles filename={album.albumName} sharedLinkKey={sharedLink?.key} />
|
<DownloadFiles filename={album.albumName} sharedLinkKey={sharedLink?.key} />
|
||||||
{#if isOwned}
|
{#if isOwned}
|
||||||
|
@ -91,6 +91,11 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCloseViewer = () => {
|
||||||
|
isShowDetail = false;
|
||||||
|
closeViewer();
|
||||||
|
};
|
||||||
|
|
||||||
const closeViewer = () => {
|
const closeViewer = () => {
|
||||||
dispatch('close');
|
dispatch('close');
|
||||||
};
|
};
|
||||||
@ -398,6 +403,7 @@
|
|||||||
{asset}
|
{asset}
|
||||||
albums={appearsInAlbums}
|
albums={appearsInAlbums}
|
||||||
on:close={() => (isShowDetail = false)}
|
on:close={() => (isShowDetail = false)}
|
||||||
|
on:close-viewer={handleCloseViewer}
|
||||||
on:description-focus-in={disableKeyDownEvent}
|
on:description-focus-in={disableKeyDownEvent}
|
||||||
on:description-focus-out={enableKeyDownEvent}
|
on:description-focus-out={enableKeyDownEvent}
|
||||||
/>
|
/>
|
||||||
|
@ -1,16 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Close from 'svelte-material-icons/Close.svelte';
|
import { page } from '$app/stores';
|
||||||
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
import type { LatLngTuple } from 'leaflet';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import Calendar from 'svelte-material-icons/Calendar.svelte';
|
import Calendar from 'svelte-material-icons/Calendar.svelte';
|
||||||
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
|
|
||||||
import CameraIris from 'svelte-material-icons/CameraIris.svelte';
|
import CameraIris from 'svelte-material-icons/CameraIris.svelte';
|
||||||
|
import Close from 'svelte-material-icons/Close.svelte';
|
||||||
|
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
|
||||||
import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte';
|
import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { AssetResponseDto, AlbumResponseDto, api, ThumbnailFormat } from '@api';
|
import { AssetResponseDto, AlbumResponseDto, api, ThumbnailFormat } from '@api';
|
||||||
import { asByteUnitString } from '../../utils/byte-units';
|
import { asByteUnitString } from '../../utils/byte-units';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import type { LatLngTuple } from 'leaflet';
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
|
|
||||||
export let asset: AssetResponseDto;
|
export let asset: AssetResponseDto;
|
||||||
export let albums: AlbumResponseDto[] = [];
|
export let albums: AlbumResponseDto[] = [];
|
||||||
@ -20,9 +21,10 @@
|
|||||||
$: {
|
$: {
|
||||||
// Get latest description from server
|
// Get latest description from server
|
||||||
if (asset.id) {
|
if (asset.id) {
|
||||||
api.assetApi
|
api.assetApi.getAssetById(asset.id).then((res) => {
|
||||||
.getAssetById(asset.id)
|
people = res.data?.people || [];
|
||||||
.then((res) => (textarea.value = res.data?.exifInfo?.description || ''));
|
textarea.value = res.data?.exifInfo?.description || '';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,6 +37,8 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
$: people = asset.people || [];
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
const getMegapixel = (width: number, height: number): number | undefined => {
|
const getMegapixel = (width: number, height: number): number | undefined => {
|
||||||
@ -81,7 +85,7 @@
|
|||||||
<p class="text-immich-fg dark:text-immich-dark-fg text-lg">Info</p>
|
<p class="text-immich-fg dark:text-immich-dark-fg text-lg">Info</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mx-4 mt-10">
|
<section class="mx-4 mt-10">
|
||||||
<textarea
|
<textarea
|
||||||
bind:this={textarea}
|
bind:this={textarea}
|
||||||
class="max-h-[500px]
|
class="max-h-[500px]
|
||||||
@ -96,13 +100,35 @@
|
|||||||
bind:value={description}
|
bind:value={description}
|
||||||
disabled={$page?.data?.user?.id !== asset.ownerId}
|
disabled={$page?.data?.user?.id !== asset.ownerId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
|
{#if people.length > 0}
|
||||||
|
<section class="px-4 py-4 text-sm">
|
||||||
|
<h2>PEOPLE</h2>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 mt-4">
|
||||||
|
{#each people as person (person.id)}
|
||||||
|
<a href="/people/{person.id}" class="w-[90px]" on:click={() => dispatch('close-viewer')}>
|
||||||
|
<ImageThumbnail
|
||||||
|
curve
|
||||||
|
shadow
|
||||||
|
url={api.getPeopleThumbnailUrl(person.id)}
|
||||||
|
altText={person.name}
|
||||||
|
widthStyle="90px"
|
||||||
|
heightStyle="90px"
|
||||||
|
/>
|
||||||
|
<p class="font-medium mt-1 truncate">{person.name}</p>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="px-4 py-4">
|
<div class="px-4 py-4">
|
||||||
{#if !asset.exifInfo}
|
{#if !asset.exifInfo}
|
||||||
<p class="text-sm pb-4">NO EXIF INFO AVAILABLE</p>
|
<p class="text-sm">NO EXIF INFO AVAILABLE</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-sm pb-4">DETAILS</p>
|
<p class="text-sm">DETAILS</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if asset.exifInfo?.dateTimeOriginal}
|
{#if asset.exifInfo?.dateTimeOriginal}
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { imageLoad } from '$lib/utils/image-load';
|
||||||
|
|
||||||
export let url: string;
|
export let url: string;
|
||||||
export let altText: string;
|
export let altText: string;
|
||||||
export let heightStyle: string;
|
export let heightStyle: string | undefined = undefined;
|
||||||
export let widthStyle: string;
|
export let widthStyle: string;
|
||||||
|
export let curve = false;
|
||||||
|
export let shadow = false;
|
||||||
|
export let circle = false;
|
||||||
let loading = true;
|
let loading = true;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -13,7 +17,11 @@
|
|||||||
src={url}
|
src={url}
|
||||||
alt={altText}
|
alt={altText}
|
||||||
class="object-cover transition-opacity duration-300"
|
class="object-cover transition-opacity duration-300"
|
||||||
|
class:rounded-lg={curve}
|
||||||
|
class:shadow-lg={shadow}
|
||||||
|
class:rounded-full={circle}
|
||||||
class:opacity-0={loading}
|
class:opacity-0={loading}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
on:load|once={() => (loading = false)}
|
use:imageLoad
|
||||||
|
on:image-load|once={() => (loading = false)}
|
||||||
/>
|
/>
|
||||||
|
42
web/src/lib/components/faces-page/edit-name-input.svelte
Normal file
42
web/src/lib/components/faces-page/edit-name-input.svelte
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { PersonResponseDto, api } from '@api';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||||
|
import Button from '../elements/buttons/button.svelte';
|
||||||
|
|
||||||
|
export let person: PersonResponseDto;
|
||||||
|
let name = person.name;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ change: string }>();
|
||||||
|
const handleNameChange = () => dispatch('change', name);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex place-items-center max-w-lg rounded-lg border dark:border-transparent p-2 bg-gray-100 dark:bg-gray-700"
|
||||||
|
>
|
||||||
|
<ImageThumbnail
|
||||||
|
circle
|
||||||
|
shadow
|
||||||
|
url={api.getPeopleThumbnailUrl(person.id)}
|
||||||
|
altText={person.name}
|
||||||
|
widthStyle="2rem"
|
||||||
|
heightStyle="2rem"
|
||||||
|
/>
|
||||||
|
<form
|
||||||
|
class="ml-4 flex justify-between w-full gap-16"
|
||||||
|
autocomplete="off"
|
||||||
|
on:submit|preventDefault={handleNameChange}
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y-autofocus -->
|
||||||
|
<input
|
||||||
|
autofocus
|
||||||
|
class="gap-2 w-full bg-gray-100 dark:bg-gray-700 dark:text-white"
|
||||||
|
type="text"
|
||||||
|
placeholder="New name or nickname"
|
||||||
|
required
|
||||||
|
bind:value={name}
|
||||||
|
on:blur
|
||||||
|
/>
|
||||||
|
<Button size="sm" type="submit">Done</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
@ -10,6 +10,7 @@ export enum AppRoute {
|
|||||||
ALBUMS = '/albums',
|
ALBUMS = '/albums',
|
||||||
ARCHIVE = '/archive',
|
ARCHIVE = '/archive',
|
||||||
FAVORITES = '/favorites',
|
FAVORITES = '/favorites',
|
||||||
|
PEOPLE = '/people',
|
||||||
PHOTOS = '/photos',
|
PHOTOS = '/photos',
|
||||||
EXPLORE = '/explore',
|
EXPLORE = '/explore',
|
||||||
SHARING = '/sharing',
|
SHARING = '/sharing',
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user