Merge branch 'main' into rknn-toolkit-lite2

This commit is contained in:
Yoni Yang 2025-01-22 11:43:59 +08:00 committed by GitHub
commit 794da29411
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
130 changed files with 2154 additions and 2380 deletions

View File

@ -88,7 +88,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }} type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@v6.11.0 uses: docker/build-push-action@v6.12.0
with: with:
file: cli/Dockerfile file: cli/Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View File

@ -177,7 +177,7 @@ jobs:
fi fi
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@v6.11.0 uses: docker/build-push-action@v6.12.0
with: with:
context: ${{ env.context }} context: ${{ env.context }}
file: ${{ env.file }} file: ${{ env.file }}
@ -268,7 +268,7 @@ jobs:
fi fi
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@v6.11.0 uses: docker/build-push-action@v6.12.0
with: with:
context: ${{ env.context }} context: ${{ env.context }}
file: ${{ env.file }} file: ${{ env.file }}

View File

@ -1 +1 @@
22.13.0 22.13.1

10
cli/package-lock.json generated
View File

@ -24,7 +24,7 @@
"@types/cli-progress": "^3.11.0", "@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.10.5", "@types/node": "^22.10.7",
"@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/parser": "^8.15.0",
"@vitest/coverage-v8": "^2.0.5", "@vitest/coverage-v8": "^2.0.5",
@ -59,7 +59,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.5", "@types/node": "^22.10.7",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@ -1397,9 +1397,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.10.5", "version": "22.10.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
"integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -20,7 +20,7 @@
"@types/cli-progress": "^3.11.0", "@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.10.5", "@types/node": "^22.10.7",
"@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/parser": "^8.15.0",
"@vitest/coverage-v8": "^2.0.5", "@vitest/coverage-v8": "^2.0.5",
@ -67,6 +67,6 @@
"lodash-es": "^4.17.21" "lodash-es": "^4.17.21"
}, },
"volta": { "volta": {
"node": "22.13.0" "node": "22.13.1"
} }
} }

View File

@ -1 +1 @@
22.13.0 22.13.1

View File

@ -160,6 +160,35 @@ For example, say you have existing transcodes with the policy "Videos higher tha
No. Our design principle is that the original assets should always be untouched. No. Our design principle is that the original assets should always be untouched.
### How can I mount a CIFS/Samba volume within Docker?
If you aren't able to or prefer not to mount Samba on the host (such as Windows environment), you can mount the volume within Docker.
Below is an example in the `docker-compose.yml`.
Change your username, password, local IP, and share name, and see below where the line `- originals:/usr/src/app/originals`,
corrolates to the section where the volume `originals` was created. You can call this whatever you like, and map it to the docker container as you like.
For example you could change `originals:` to `Photos:`, and change `- originals:/usr/src/app/originals` to `Photos:/usr/src/app/photos`.
```diff
...
services:
immich-server:
...
volumes:
# Do not edit the next line. If you want to change the media storage location on your system, edit the value of UPLOAD_LOCATION in the .env file
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
+ - originals:/usr/src/app/originals
...
volumes:
model-cache:
+ originals:
+ driver_opts:
+ type: cifs
+ o: 'iocharset=utf8,username=USERNAMEHERE,password=PASSWORDHERE,rw' # change to `ro` if read only desired
+ device: '//localipaddress/sharename'
```
--- ---
## Albums ## Albums

View File

@ -68,7 +68,7 @@ After bringing down the containers with `docker compose down` and back up with `
:::note :::note
To see exactly what metrics are made available, you can additionally add `8081:8081` to the server container's ports and `8082:8082` to the microservices container's ports. To see exactly what metrics are made available, you can additionally add `8081:8081` to the server container's ports and `8082:8082` to the microservices container's ports.
Visiting the `/metrics` endpoint for these services will show the same raw data that Prometheus collects. Visiting the `/metrics` endpoint for these services will show the same raw data that Prometheus collects.
To configure these ports see [`IMMICH_API_METRICS_PORT` & `IMMICH_MICROSERVICES_METRICS_PORT`](../install/environment-variables/#general). To configure these ports see [`IMMICH_API_METRICS_PORT` & `IMMICH_MICROSERVICES_METRICS_PORT`](/docs/install/environment-variables/#general).
::: :::
### Usage ### Usage

View File

@ -49,5 +49,3 @@ The `thumbs/` folder contains both the small thumbnails displayed in the timelin
The storage metrics of the Immich server will track available storage at `UPLOAD_LOCATION`, so the administrator must set up some sort of monitoring to ensure the storage does not run out of space. The `profile/` folder is much smaller, usually less than 1 MB. The storage metrics of the Immich server will track available storage at `UPLOAD_LOCATION`, so the administrator must set up some sort of monitoring to ensure the storage does not run out of space. The `profile/` folder is much smaller, usually less than 1 MB.
::: :::
Thanks to [Jrasm91](https://github.com/immich-app/immich/discussions/2110#discussioncomment-5477767) for writing the guide.

View File

@ -159,10 +159,10 @@ Redis (Sentinel) URL example JSON before encoding:
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning | | `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`<sup>\*3</sup> | HTTP Keep-alive time in seconds | `2` | machine learning | | `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`<sup>\*3</sup> | HTTP Keep-alive time in seconds | `2` | machine learning |
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO) | machine learning | | `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO) | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Name of the textual CLIP model to be preloaded and kept in cache | | machine learning | | `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Name of the visual CLIP model to be preloaded and kept in cache | | machine learning | | `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Name of the recognition portion of the facial recognition model to be preloaded and kept in cache | | machine learning | | `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Name of the detection portion of the facial recognition model to be preloaded and kept in cache | | machine learning | | `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning | | `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning | | `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | | `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |

View File

@ -55,6 +55,6 @@
"node": ">=20" "node": ">=20"
}, },
"volta": { "volta": {
"node": "22.13.0" "node": "22.13.1"
} }
} }

View File

@ -73,9 +73,9 @@ function HomepageHeader() {
/> />
<div> <div>
<p className="font-bold text-2xl md:text-5xl ">Download mobile app</p> <p className="font-bold text-2xl md:text-5xl ">Download the mobile app</p>
<p className="text-lg"> <p className="text-lg">
Download Immich app and start backing up your photos and videos securely to your own server Download the Immich app and start backing up your photos and videos securely to your own server
</p> </p>
</div> </div>
<div className="flex flex-col sm:flex-row place-items-center place-content-center mt-4 gap-1"> <div className="flex flex-col sm:flex-row place-items-center place-content-center mt-4 gap-1">

View File

@ -1 +1 @@
22.13.0 22.13.1

12
e2e/package-lock.json generated
View File

@ -15,7 +15,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.10.5", "@types/node": "^22.10.7",
"@types/oidc-provider": "^8.5.1", "@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
@ -64,7 +64,7 @@
"@types/cli-progress": "^3.11.0", "@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.10.5", "@types/node": "^22.10.7",
"@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/parser": "^8.15.0",
"@vitest/coverage-v8": "^2.0.5", "@vitest/coverage-v8": "^2.0.5",
@ -99,7 +99,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.5", "@types/node": "^22.10.7",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@ -1658,9 +1658,9 @@
"dev": true "dev": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.10.5", "version": "22.10.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
"integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -25,7 +25,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.10.5", "@types/node": "^22.10.7",
"@types/oidc-provider": "^8.5.1", "@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
@ -53,6 +53,6 @@
"vitest": "^2.0.5" "vitest": "^2.0.5"
}, },
"volta": { "volta": {
"node": "22.13.0" "node": "22.13.1"
} }
} }

View File

@ -142,6 +142,10 @@ describe('/albums', () => {
...user1Albums[0], ...user1Albums[0],
assets: [expect.objectContaining({ isFavorite: false })], assets: [expect.objectContaining({ isFavorite: false })],
lastModifiedAssetTimestamp: expect.any(String), lastModifiedAssetTimestamp: expect.any(String),
startDate: expect.any(String),
endDate: expect.any(String),
shared: true,
albumUsers: expect.any(Array),
}); });
}); });
@ -299,6 +303,10 @@ describe('/albums', () => {
...user1Albums[0], ...user1Albums[0],
assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })], assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
lastModifiedAssetTimestamp: expect.any(String), lastModifiedAssetTimestamp: expect.any(String),
startDate: expect.any(String),
endDate: expect.any(String),
albumUsers: expect.any(Array),
shared: true,
}); });
}); });
@ -330,6 +338,10 @@ describe('/albums', () => {
...user1Albums[0], ...user1Albums[0],
assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })], assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })],
lastModifiedAssetTimestamp: expect.any(String), lastModifiedAssetTimestamp: expect.any(String),
startDate: expect.any(String),
endDate: expect.any(String),
albumUsers: expect.any(Array),
shared: true,
}); });
}); });
@ -344,6 +356,10 @@ describe('/albums', () => {
assets: [], assets: [],
assetCount: 1, assetCount: 1,
lastModifiedAssetTimestamp: expect.any(String), lastModifiedAssetTimestamp: expect.any(String),
endDate: expect.any(String),
startDate: expect.any(String),
albumUsers: expect.any(Array),
shared: true,
}); });
}); });
}); });

View File

@ -200,7 +200,7 @@ describe('/people', () => {
expect(body).toMatchObject({ expect(body).toMatchObject({
id: expect.any(String), id: expect.any(String),
name: 'New Person', name: 'New Person',
birthDate: '1990-01-01', birthDate: '1990-01-01T00:00:00.000Z',
}); });
}); });
}); });
@ -244,7 +244,7 @@ describe('/people', () => {
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ birthDate: '1990-01-01' }); .send({ birthDate: '1990-01-01' });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toMatchObject({ birthDate: '1990-01-01' }); expect(body).toMatchObject({ birthDate: '1990-01-01T00:00:00.000Z' });
}); });
it('should clear a date of birth', async () => { it('should clear a date of birth', async () => {

View File

@ -1,6 +1,6 @@
ARG DEVICE=cpu ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:f997d3f71b7dcff3f937703c02861437f2b41a94e1ddbd1b5fa357ee99f5cce4 AS builder-cpu FROM python:3.11-bookworm@sha256:adb581d8ed80edd03efd4dcad66db115b9ce8de8522b01720b9f3e6146f0884c AS builder-cpu
FROM builder-cpu AS builder-openvino FROM builder-cpu AS builder-openvino

View File

@ -77,29 +77,31 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
async def preload_models(preload: PreloadModelData) -> None: async def preload_models(preload: PreloadModelData) -> None:
log.info(f"Preloading models: clip:{preload.clip} facial_recognition:{preload.facial_recognition}") log.info(f"Preloading models: clip:{preload.clip} facial_recognition:{preload.facial_recognition}")
async def load_models(model_string: str, model_type: ModelType, model_task: ModelTask) -> None:
for model_name in model_string.split(","):
model_name = model_name.strip()
model = await model_cache.get(model_name, model_type, model_task)
await load(model)
if preload.clip.textual is not None: if preload.clip.textual is not None:
model = await model_cache.get(preload.clip.textual, ModelType.TEXTUAL, ModelTask.SEARCH) await load_models(preload.clip.textual, ModelType.TEXTUAL, ModelTask.SEARCH)
await load(model)
if preload.clip.visual is not None: if preload.clip.visual is not None:
model = await model_cache.get(preload.clip.visual, ModelType.VISUAL, ModelTask.SEARCH) await load_models(preload.clip.visual, ModelType.VISUAL, ModelTask.SEARCH)
await load(model)
if preload.facial_recognition.detection is not None: if preload.facial_recognition.detection is not None:
model = await model_cache.get( await load_models(
preload.facial_recognition.detection, preload.facial_recognition.detection,
ModelType.DETECTION, ModelType.DETECTION,
ModelTask.FACIAL_RECOGNITION, ModelTask.FACIAL_RECOGNITION,
) )
await load(model)
if preload.facial_recognition.recognition is not None: if preload.facial_recognition.recognition is not None:
model = await model_cache.get( await load_models(
preload.facial_recognition.recognition, preload.facial_recognition.recognition,
ModelType.RECOGNITION, ModelType.RECOGNITION,
ModelTask.FACIAL_RECOGNITION, ModelTask.FACIAL_RECOGNITION,
) )
await load(model)
if preload.clip_fallback is not None: if preload.clip_fallback is not None:
log.warning( log.warning(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1625,13 +1625,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"]
[[package]] [[package]]
name = "locust" name = "locust"
version = "2.32.5" version = "2.32.6"
description = "Developer-friendly load testing framework" description = "Developer-friendly load testing framework"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "locust-2.32.5-py3-none-any.whl", hash = "sha256:2f49509868ffc2e368be40921c6825f92147c84e997206760a85dab3058f5efb"}, {file = "locust-2.32.6-py3-none-any.whl", hash = "sha256:d5c0e4f73134415d250087034431cf3ea42ca695d3dee7f10812287cacb6c4ef"},
{file = "locust-2.32.5.tar.gz", hash = "sha256:ea7bc1e8ce2520e8893c471b4b0a56a4f53b01b4b618adfe8d2c8ab2728b5821"}, {file = "locust-2.32.6.tar.gz", hash = "sha256:6600cc308398e724764aacc56ccddf6cfcd0127c4c92dedd5c4979dd37ef5b15"},
] ]
[package.dependencies] [package.dependencies]
@ -1649,8 +1649,8 @@ psutil = ">=5.9.1"
pywin32 = {version = "*", markers = "sys_platform == \"win32\""} pywin32 = {version = "*", markers = "sys_platform == \"win32\""}
pyzmq = ">=25.0.0" pyzmq = ">=25.0.0"
requests = [ requests = [
{version = ">=2.26.0", markers = "python_full_version <= \"3.11.0\""},
{version = ">=2.32.2", markers = "python_full_version > \"3.11.0\""}, {version = ">=2.32.2", markers = "python_full_version > \"3.11.0\""},
{version = ">=2.26.0", markers = "python_full_version <= \"3.11.0\""},
] ]
setuptools = ">=70.0.0" setuptools = ">=70.0.0"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
@ -2165,26 +2165,26 @@ sympy = "*"
[[package]] [[package]]
name = "opencv-python-headless" name = "opencv-python-headless"
version = "4.10.0.84" version = "4.11.0.86"
description = "Wrapper package for OpenCV python bindings." description = "Wrapper package for OpenCV python bindings."
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
files = [ files = [
{file = "opencv-python-headless-4.10.0.84.tar.gz", hash = "sha256:f2017c6101d7c2ef8d7bc3b414c37ff7f54d64413a1847d89970b6b7069b4e1a"}, {file = "opencv-python-headless-4.11.0.86.tar.gz", hash = "sha256:996eb282ca4b43ec6a3972414de0e2331f5d9cda2b41091a49739c19fb843798"},
{file = "opencv_python_headless-4.10.0.84-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:a4f4bcb07d8f8a7704d9c8564c224c8b064c63f430e95b61ac0bffaa374d330e"}, {file = "opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:48128188ade4a7e517237c8e1e11a9cdf5c282761473383e77beb875bb1e61ca"},
{file = "opencv_python_headless-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", hash = "sha256:5ae454ebac0eb0a0b932e3406370aaf4212e6a3fdb5038cc86c7aea15a6851da"}, {file = "opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:a66c1b286a9de872c343ee7c3553b084244299714ebb50fbdcd76f07ebbe6c81"},
{file = "opencv_python_headless-4.10.0.84-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46071015ff9ab40fccd8a163da0ee14ce9846349f06c6c8c0f2870856ffa45db"}, {file = "opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6efabcaa9df731f29e5ea9051776715b1bdd1845d7c9530065c7951d2a2899eb"},
{file = "opencv_python_headless-4.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:377d08a7e48a1405b5e84afcbe4798464ce7ee17081c1c23619c8b398ff18295"}, {file = "opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e0a27c19dd1f40ddff94976cfe43066fbbe9dfbb2ec1907d66c19caef42a57b"},
{file = "opencv_python_headless-4.10.0.84-cp37-abi3-win32.whl", hash = "sha256:9092404b65458ed87ce932f613ffbb1106ed2c843577501e5768912360fc50ec"}, {file = "opencv_python_headless-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:f447d8acbb0b6f2808da71fddd29c1cdd448d2bc98f72d9bb78a7a898fc9621b"},
{file = "opencv_python_headless-4.10.0.84-cp37-abi3-win_amd64.whl", hash = "sha256:afcf28bd1209dd58810d33defb622b325d3cbe49dcd7a43a902982c33e5fad05"}, {file = "opencv_python_headless-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:6c304df9caa7a6a5710b91709dd4786bf20a74d57672b3c31f7033cc638174ca"},
] ]
[package.dependencies] [package.dependencies]
numpy = [ numpy = [
{version = ">=1.26.0", markers = "python_version >= \"3.12\""},
{version = ">=1.23.5", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, {version = ">=1.23.5", markers = "python_version >= \"3.11\" and python_version < \"3.12\""},
{version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\" and python_version < \"3.11\""}, {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\" and python_version < \"3.11\""},
{version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\" and python_version < \"3.11\""}, {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\" and python_version < \"3.11\""},
{version = ">=1.26.0", markers = "python_version >= \"3.12\""},
] ]
[[package]] [[package]]
@ -3137,29 +3137,29 @@ files = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.9.1" version = "0.9.2"
description = "An extremely fast Python linter and code formatter, written in Rust." description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "ruff-0.9.1-py3-none-linux_armv6l.whl", hash = "sha256:84330dda7abcc270e6055551aca93fdde1b0685fc4fd358f26410f9349cf1743"}, {file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"},
{file = "ruff-0.9.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3cae39ba5d137054b0e5b472aee3b78a7c884e61591b100aeb544bcd1fc38d4f"}, {file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"},
{file = "ruff-0.9.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:50c647ff96f4ba288db0ad87048257753733763b409b2faf2ea78b45c8bb7fcb"}, {file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"},
{file = "ruff-0.9.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0c8b149e9c7353cace7d698e1656ffcf1e36e50f8ea3b5d5f7f87ff9986a7ca"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"},
{file = "ruff-0.9.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:beb3298604540c884d8b282fe7625651378e1986c25df51dec5b2f60cafc31ce"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"},
{file = "ruff-0.9.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39d0174ccc45c439093971cc06ed3ac4dc545f5e8bdacf9f067adf879544d969"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"},
{file = "ruff-0.9.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:69572926c0f0c9912288915214ca9b2809525ea263603370b9e00bed2ba56dbd"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"},
{file = "ruff-0.9.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:937267afce0c9170d6d29f01fcd1f4378172dec6760a9f4dface48cdabf9610a"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"},
{file = "ruff-0.9.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:186c2313de946f2c22bdf5954b8dd083e124bcfb685732cfb0beae0c47233d9b"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"},
{file = "ruff-0.9.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f94942a3bb767675d9a051867c036655fe9f6c8a491539156a6f7e6b5f31831"}, {file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"},
{file = "ruff-0.9.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:728d791b769cc28c05f12c280f99e8896932e9833fef1dd8756a6af2261fd1ab"}, {file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"},
{file = "ruff-0.9.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2f312c86fb40c5c02b44a29a750ee3b21002bd813b5233facdaf63a51d9a85e1"}, {file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"},
{file = "ruff-0.9.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ae017c3a29bee341ba584f3823f805abbe5fe9cd97f87ed07ecbf533c4c88366"}, {file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"},
{file = "ruff-0.9.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5dc40a378a0e21b4cfe2b8a0f1812a6572fc7b230ef12cd9fac9161aa91d807f"}, {file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"},
{file = "ruff-0.9.1-py3-none-win32.whl", hash = "sha256:46ebf5cc106cf7e7378ca3c28ce4293b61b449cd121b98699be727d40b79ba72"}, {file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"},
{file = "ruff-0.9.1-py3-none-win_amd64.whl", hash = "sha256:342a824b46ddbcdddd3abfbb332fa7fcaac5488bf18073e841236aadf4ad5c19"}, {file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"},
{file = "ruff-0.9.1-py3-none-win_arm64.whl", hash = "sha256:1cd76c7f9c679e6e8f2af8f778367dca82b95009bc7b1a85a47f1521ae524fa7"}, {file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"},
{file = "ruff-0.9.1.tar.gz", hash = "sha256:fd2b25ecaf907d6458fa842675382c8597b3c746a2dde6717fe3415425df0c17"}, {file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"},
] ]
[[package]] [[package]]

View File

@ -14,6 +14,7 @@ class AlbumMediaRepository implements IAlbumMediaRepository {
final List<AssetPathEntity> assetPathEntities = final List<AssetPathEntity> assetPathEntities =
await PhotoManager.getAssetPathList( await PhotoManager.getAssetPathList(
hasAll: true, hasAll: true,
filterOption: FilterOptionGroup(containsPathModified: true),
); );
return assetPathEntities.map(_toAlbum).toList(); return assetPathEntities.map(_toAlbum).toList();
} }

View File

@ -1 +1 @@
22.13.0 22.13.1

View File

@ -12,7 +12,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.5", "@types/node": "^22.10.7",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@ -22,9 +22,9 @@
"integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g==" "integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g=="
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.10.5", "version": "22.10.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
"integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.5", "@types/node": "^22.10.7",
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"repository": { "repository": {
@ -28,6 +28,6 @@
"directory": "open-api/typescript-sdk" "directory": "open-api/typescript-sdk"
}, },
"volta": { "volta": {
"node": "22.13.0" "node": "22.13.1"
} }
} }

View File

@ -1 +1 @@
22.13.0 22.13.1

View File

@ -86,7 +86,7 @@
"@types/lodash": "^4.14.197", "@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^22.10.5", "@types/node": "^22.10.7",
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",
"@types/picomatch": "^3.0.0", "@types/picomatch": "^3.0.0",
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",
@ -104,6 +104,7 @@
"globals": "^15.9.0", "globals": "^15.9.0",
"kysely-codegen": "^0.16.3", "kysely-codegen": "^0.16.3",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
"node-addon-api": "^8.3.0",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"prettier": "^3.0.2", "prettier": "^3.0.2",
"prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-organize-imports": "^4.0.0",
@ -5128,9 +5129,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.10.5", "version": "22.10.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
"integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.20.0" "undici-types": "~6.20.0"
@ -6436,6 +6437,12 @@
"tweetnacl": "^0.14.3" "tweetnacl": "^0.14.3"
} }
}, },
"node_modules/bcrypt/node_modules/node-addon-api": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
"license": "MIT"
},
"node_modules/bignumber.js": { "node_modules/bignumber.js": {
"version": "9.1.2", "version": "9.1.2",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz",
@ -11128,10 +11135,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-addon-api": { "node_modules/node-addon-api": {
"version": "5.1.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.0.tgz",
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", "integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==",
"license": "MIT" "dev": true,
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
}, },
"node_modules/node-emoji": { "node_modules/node-emoji": {
"version": "1.11.0", "version": "1.11.0",
@ -16205,44 +16216,6 @@
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
} }
},
"node_modules/zip-stream/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/zip-stream/node_modules/readable-stream": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz",
"integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
} }
} }
} }

View File

@ -112,7 +112,7 @@
"@types/lodash": "^4.14.197", "@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^22.10.5", "@types/node": "^22.10.7",
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",
"@types/picomatch": "^3.0.0", "@types/picomatch": "^3.0.0",
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",
@ -130,6 +130,7 @@
"globals": "^15.9.0", "globals": "^15.9.0",
"kysely-codegen": "^0.16.3", "kysely-codegen": "^0.16.3",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
"node-addon-api": "^8.3.0",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"prettier": "^3.0.2", "prettier": "^3.0.2",
"prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-organize-imports": "^4.0.0",
@ -144,6 +145,6 @@
"vitest": "^2.0.5" "vitest": "^2.0.5"
}, },
"volta": { "volta": {
"node": "22.13.0" "node": "22.13.1"
} }
} }

View File

@ -5,13 +5,13 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { PersonEntity } from 'src/entities/person.entity'; import { PersonEntity } from 'src/entities/person.entity';
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum'; import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMoveRepository } from 'src/interfaces/move.interface'; import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IConfigRepository } from 'src/types';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFiles } from 'src/utils/asset.util';
import { getConfig } from 'src/utils/config'; import { getConfig } from 'src/utils/config';

View File

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

View File

@ -29,7 +29,7 @@ export class AddUsersDto {
albumUsers!: AlbumUserAddDto[]; albumUsers!: AlbumUserAddDto[];
} }
class AlbumUserCreateDto { export class AlbumUserCreateDto {
@ValidateUUID() @ValidateUUID()
userId!: string; userId!: string;

View File

@ -248,7 +248,7 @@ export function hasPeopleCte(db: Kysely<DB>, personIds: string[]) {
.select('assetId') .select('assetId')
.where('personId', '=', anyUuid(personIds!)) .where('personId', '=', anyUuid(personIds!))
.groupBy('assetId') .groupBy('assetId')
.having((eb) => eb.fn.count('personId'), '>=', personIds.length), .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length),
); );
} }

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { StorageFolder, SystemMetadataKey } from 'src/enum'; import { StorageFolder, SystemMetadataKey } from 'src/enum';
import { Column, DeepPartial, Entity, PrimaryColumn } from 'typeorm'; import { DeepPartial } from 'src/types';
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('system_metadata') @Entity('system_metadata')
export class SystemMetadataEntity<T extends keyof SystemMetadata = SystemMetadataKey> { export class SystemMetadataEntity<T extends keyof SystemMetadata = SystemMetadataKey> {

View File

@ -1,7 +1,8 @@
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { UserAvatarColor, UserMetadataKey } from 'src/enum'; import { UserAvatarColor, UserMetadataKey } from 'src/enum';
import { DeepPartial } from 'src/types';
import { HumanReadableSize } from 'src/utils/bytes'; import { HumanReadableSize } from 'src/utils/bytes';
import { Column, DeepPartial, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm';
@Entity('user_metadata') @Entity('user_metadata')
export class UserMetadataEntity<T extends keyof UserMetadata = UserMetadataKey> { export class UserMetadataEntity<T extends keyof UserMetadata = UserMetadataKey> {

View File

@ -1,53 +0,0 @@
import { AlbumUserRole } from 'src/enum';
export const IAccessRepository = 'IAccessRepository';
export interface IAccessRepository {
activity: {
checkOwnerAccess(userId: string, activityIds: Set<string>): Promise<Set<string>>;
checkAlbumOwnerAccess(userId: string, activityIds: Set<string>): Promise<Set<string>>;
checkCreateAccess(userId: string, albumIds: Set<string>): Promise<Set<string>>;
};
asset: {
checkOwnerAccess(userId: string, assetIds: Set<string>): Promise<Set<string>>;
checkAlbumAccess(userId: string, assetIds: Set<string>): Promise<Set<string>>;
checkPartnerAccess(userId: string, assetIds: Set<string>): Promise<Set<string>>;
checkSharedLinkAccess(sharedLinkId: string, assetIds: Set<string>): Promise<Set<string>>;
};
authDevice: {
checkOwnerAccess(userId: string, deviceIds: Set<string>): Promise<Set<string>>;
};
album: {
checkOwnerAccess(userId: string, albumIds: Set<string>): Promise<Set<string>>;
checkSharedAlbumAccess(userId: string, albumIds: Set<string>, access: AlbumUserRole): Promise<Set<string>>;
checkSharedLinkAccess(sharedLinkId: string, albumIds: Set<string>): Promise<Set<string>>;
};
timeline: {
checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>>;
};
memory: {
checkOwnerAccess(userId: string, memoryIds: Set<string>): Promise<Set<string>>;
};
person: {
checkFaceOwnerAccess(userId: string, assetFaceId: Set<string>): Promise<Set<string>>;
checkOwnerAccess(userId: string, personIds: Set<string>): Promise<Set<string>>;
};
partner: {
checkUpdateAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>>;
};
stack: {
checkOwnerAccess(userId: string, stackIds: Set<string>): Promise<Set<string>>;
};
tag: {
checkOwnerAccess(userId: string, tagIds: Set<string>): Promise<Set<string>>;
};
}

View File

@ -1,3 +1,6 @@
import { Insertable, Updateable } from 'kysely';
import { Albums } from 'src/db';
import { AlbumUserCreateDto } from 'src/dtos/album.dto';
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { IBulkAsset } from 'src/utils/asset.util'; import { IBulkAsset } from 'src/utils/asset.util';
@ -15,7 +18,7 @@ export interface AlbumInfoOptions {
} }
export interface IAlbumRepository extends IBulkAsset { export interface IAlbumRepository extends IBulkAsset {
getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null>; getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | undefined>;
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>; getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>;
removeAsset(assetId: string): Promise<void>; removeAsset(assetId: string): Promise<void>;
getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]>; getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]>;
@ -25,8 +28,8 @@ export interface IAlbumRepository extends IBulkAsset {
restoreAll(userId: string): Promise<void>; restoreAll(userId: string): Promise<void>;
softDeleteAll(userId: string): Promise<void>; softDeleteAll(userId: string): Promise<void>;
deleteAll(userId: string): Promise<void>; deleteAll(userId: string): Promise<void>;
create(album: Partial<AlbumEntity>): Promise<AlbumEntity>; create(album: Insertable<Albums>, assetIds: string[], albumUsers: AlbumUserCreateDto[]): Promise<AlbumEntity>;
update(album: Partial<AlbumEntity>): Promise<AlbumEntity>; update(id: string, album: Updateable<Albums>): Promise<AlbumEntity>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
updateThumbnails(): Promise<number | undefined>; updateThumbnails(): Promise<number | undefined>;
} }

View File

@ -1,19 +0,0 @@
import { Insertable } from 'kysely';
import { ApiKeys } from 'src/db';
import { APIKeyEntity } from 'src/entities/api-key.entity';
import { AuthApiKey } from 'src/types';
export const IKeyRepository = 'IKeyRepository';
export interface IKeyRepository {
create(dto: Insertable<ApiKeys>): Promise<APIKeyEntity>;
update(userId: string, id: string, dto: Partial<APIKeyEntity>): Promise<APIKeyEntity>;
delete(userId: string, id: string): Promise<void>;
/**
* Includes the hashed `key` for verification
* @param id
*/
getKey(hashedToken: string): Promise<AuthApiKey | undefined>;
getById(userId: string, id: string): Promise<APIKeyEntity | null>;
getByUserId(userId: string): Promise<APIKeyEntity[]>;
}

View File

@ -1,14 +0,0 @@
import { DatabaseAction, EntityType } from 'src/enum';
export const IAuditRepository = 'IAuditRepository';
export interface AuditSearch {
action?: DatabaseAction;
entityType?: EntityType;
userIds: string[];
}
export interface IAuditRepository {
getAfter(since: Date, options: AuditSearch): Promise<string[]>;
removeBefore(before: Date): Promise<void>;
}

View File

@ -1,98 +0,0 @@
import { RegisterQueueOptions } from '@nestjs/bullmq';
import { QueueOptions } from 'bullmq';
import { RedisOptions } from 'ioredis';
import { KyselyConfig } from 'kysely';
import { ClsModuleOptions } from 'nestjs-cls';
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
import { ImmichEnvironment, ImmichTelemetry, ImmichWorker, LogLevel } from 'src/enum';
import { DatabaseConnectionParams, VectorExtension } from 'src/interfaces/database.interface';
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
export const IConfigRepository = 'IConfigRepository';
export interface EnvData {
host?: string;
port: number;
environment: ImmichEnvironment;
configFile?: string;
logLevel?: LogLevel;
buildMetadata: {
build?: string;
buildUrl?: string;
buildImage?: string;
buildImageUrl?: string;
repository?: string;
repositoryUrl?: string;
sourceRef?: string;
sourceCommit?: string;
sourceUrl?: string;
thirdPartySourceUrl?: string;
thirdPartyBugFeatureUrl?: string;
thirdPartyDocumentationUrl?: string;
thirdPartySupportUrl?: string;
};
bull: {
config: QueueOptions;
queues: RegisterQueueOptions[];
};
cls: {
config: ClsModuleOptions;
};
database: {
config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: KyselyConfig };
skipMigrations: boolean;
vectorExtension: VectorExtension;
};
licensePublicKey: {
client: string;
server: string;
};
network: {
trustedProxies: string[];
};
otel: OpenTelemetryModuleOptions;
resourcePaths: {
lockFile: string;
geodata: {
dateFile: string;
admin1: string;
admin2: string;
cities500: string;
naturalEarthCountriesPath: string;
};
web: {
root: string;
indexHtml: string;
};
};
redis: RedisOptions;
telemetry: {
apiPort: number;
microservicesPort: number;
metrics: Set<ImmichTelemetry>;
};
storage: {
ignoreMountCheckErrors: boolean;
};
workers: ImmichWorker[];
noColor: boolean;
nodeVersion?: string;
}
export interface IConfigRepository {
getEnv(): EnvData;
getWorker(): ImmichWorker | undefined;
}

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { Updateable } from 'kysely';
import { StackEntity } from 'src/entities/stack.entity'; import { StackEntity } from 'src/entities/stack.entity';
export const IStackRepository = 'IStackRepository'; export const IStackRepository = 'IStackRepository';
@ -10,8 +11,8 @@ export interface StackSearch {
export interface IStackRepository { export interface IStackRepository {
search(query: StackSearch): Promise<StackEntity[]>; search(query: StackSearch): Promise<StackEntity[]>;
create(stack: { ownerId: string; assetIds: string[] }): Promise<StackEntity>; create(stack: { ownerId: string; assetIds: string[] }): Promise<StackEntity>;
update(stack: Pick<StackEntity, 'id'> & Partial<StackEntity>): Promise<StackEntity>; update(id: string, entity: Updateable<StackEntity>): Promise<StackEntity>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
deleteAll(ids: string[]): Promise<void>; deleteAll(ids: string[]): Promise<void>;
getById(id: string): Promise<StackEntity | null>; getById(id: string): Promise<StackEntity | undefined>;
} }

View File

@ -1,8 +0,0 @@
import { AssetEntity } from 'src/entities/asset.entity';
export const IViewRepository = 'IViewRepository';
export interface IViewRepository {
getAssetsByOriginalPath(userId: string, partialPath: string): Promise<AssetEntity[]>;
getUniqueOriginalPaths(userId: string): Promise<string[]>;
}

View File

@ -3,7 +3,7 @@ import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter'; import { createAdapter } from '@socket.io/redis-adapter';
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
import { ServerOptions } from 'socket.io'; import { ServerOptions } from 'socket.io';
import { IConfigRepository } from 'src/interfaces/config.interface'; import { ConfigRepository } from 'src/repositories/config.repository';
export class WebSocketAdapter extends IoAdapter { export class WebSocketAdapter extends IoAdapter {
constructor(private app: INestApplicationContext) { constructor(private app: INestApplicationContext) {
@ -11,7 +11,7 @@ export class WebSocketAdapter extends IoAdapter {
} }
createIOServer(port: number, options?: ServerOptions): any { createIOServer(port: number, options?: ServerOptions): any {
const { redis } = this.app.get<IConfigRepository>(IConfigRepository).getEnv(); const { redis } = this.app.get(ConfigRepository).getEnv();
const server = super.createIOServer(port, options); const server = super.createIOServer(port, options);
const pubClient = new Redis(redis); const pubClient = new Redis(redis);
const subClient = pubClient.duplicate(); const subClient = pubClient.duplicate();

View File

@ -1,460 +1,490 @@
-- NOTE: This file is auto generated by ./sql-generator -- NOTE: This file is auto generated by ./sql-generator
-- AlbumRepository.getById -- AlbumRepository.getById
SELECT DISTINCT select
"distinctAlias"."AlbumEntity_id" AS "ids_AlbumEntity_id" "albums".*,
FROM
( (
SELECT select
"AlbumEntity"."id" AS "AlbumEntity_id", to_json(obj)
"AlbumEntity"."ownerId" AS "AlbumEntity_ownerId", from
"AlbumEntity"."albumName" AS "AlbumEntity_albumName", (
"AlbumEntity"."description" AS "AlbumEntity_description", select
"AlbumEntity"."createdAt" AS "AlbumEntity_createdAt", "id",
"AlbumEntity"."updatedAt" AS "AlbumEntity_updatedAt", "email",
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", "createdAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "profileImagePath",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "isAdmin",
"AlbumEntity"."order" AS "AlbumEntity_order", "shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "deletedAt",
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", "oauthId",
"AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin", "updatedAt",
"AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email", "storageLabel",
"AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel", "name",
"AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId", "quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath", "quotaUsageInBytes",
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", "status",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "profileChangedAt"
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", from
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "users"
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", where
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "users"."id" = "albums"."ownerId"
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", ) as obj
"AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt", ) as "owner",
"AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", (
"AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId", select
"AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", coalesce(json_agg(agg), '[]')
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id", from
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name", (
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin", select
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email", "album_users".*,
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel", (
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId", select
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath", to_json(obj)
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword", from
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt", (
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt", select
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status", "id",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", "email",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", "createdAt",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes", "profileImagePath",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt", "isAdmin",
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", "shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "deletedAt",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", "oauthId",
"AlbumEntity__AlbumEntity_sharedLinks"."userId" AS "AlbumEntity__AlbumEntity_sharedLinks_userId", "updatedAt",
"AlbumEntity__AlbumEntity_sharedLinks"."key" AS "AlbumEntity__AlbumEntity_sharedLinks_key", "storageLabel",
"AlbumEntity__AlbumEntity_sharedLinks"."type" AS "AlbumEntity__AlbumEntity_sharedLinks_type", "name",
"AlbumEntity__AlbumEntity_sharedLinks"."createdAt" AS "AlbumEntity__AlbumEntity_sharedLinks_createdAt", "quotaSizeInBytes",
"AlbumEntity__AlbumEntity_sharedLinks"."expiresAt" AS "AlbumEntity__AlbumEntity_sharedLinks_expiresAt", "quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedLinks"."allowUpload" AS "AlbumEntity__AlbumEntity_sharedLinks_allowUpload", "status",
"AlbumEntity__AlbumEntity_sharedLinks"."allowDownload" AS "AlbumEntity__AlbumEntity_sharedLinks_allowDownload", "profileChangedAt"
"AlbumEntity__AlbumEntity_sharedLinks"."showExif" AS "AlbumEntity__AlbumEntity_sharedLinks_showExif", from
"AlbumEntity__AlbumEntity_sharedLinks"."albumId" AS "AlbumEntity__AlbumEntity_sharedLinks_albumId" "users"
FROM where
"albums" "AlbumEntity" "users"."id" = "album_users"."usersId"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" ) as obj
AND ( ) as "user"
"AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL from
) "albums_shared_users_users" as "album_users"
LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" where
LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId" "album_users"."albumsId" = "albums"."id"
AND ( ) as agg
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" IS NULL ) as "albumUsers",
) (
LEFT JOIN "shared_links" "AlbumEntity__AlbumEntity_sharedLinks" ON "AlbumEntity__AlbumEntity_sharedLinks"."albumId" = "AlbumEntity"."id" select
WHERE coalesce(json_agg(agg), '[]')
((("AlbumEntity"."id" = $1))) from
AND ("AlbumEntity"."deletedAt" IS NULL) (
) "distinctAlias" select
ORDER BY *
"AlbumEntity_id" ASC from
LIMIT "shared_links"
1 where
"shared_links"."albumId" = "albums"."id"
) as agg
) as "sharedLinks"
from
"albums"
where
"albums"."id" = $1
and "albums"."deletedAt" is null
-- AlbumRepository.getByAssetId -- AlbumRepository.getByAssetId
SELECT select
"AlbumEntity"."id" AS "AlbumEntity_id", "albums".*,
"AlbumEntity"."ownerId" AS "AlbumEntity_ownerId", (
"AlbumEntity"."albumName" AS "AlbumEntity_albumName", select
"AlbumEntity"."description" AS "AlbumEntity_description", to_json(obj)
"AlbumEntity"."createdAt" AS "AlbumEntity_createdAt", from
"AlbumEntity"."updatedAt" AS "AlbumEntity_updatedAt", (
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", select
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "id",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "email",
"AlbumEntity"."order" AS "AlbumEntity_order", "createdAt",
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "profileImagePath",
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", "isAdmin",
"AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin", "shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email", "deletedAt",
"AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel", "oauthId",
"AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId", "updatedAt",
"AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath", "storageLabel",
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", "name",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", "quotaUsageInBytes",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "profileChangedAt"
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", from
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", "users"
"AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt", where
"AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", "users"."id" = "albums"."ownerId"
"AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId", ) as obj
"AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", ) as "owner",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id", (
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name", select
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin", coalesce(json_agg(agg), '[]')
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email", from
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel", (
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId", select
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath", "album_users".*,
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword", (
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt", select
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt", to_json(obj)
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status", from
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", (
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", select
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes", "id",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt" "email",
FROM "createdAt",
"albums" "AlbumEntity" "profileImagePath",
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" "isAdmin",
AND ( "shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL "deletedAt",
) "oauthId",
LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" "updatedAt",
LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId" "storageLabel",
AND ( "name",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" IS NULL "quotaSizeInBytes",
) "quotaUsageInBytes",
LEFT JOIN "albums_assets_assets" "AlbumEntity_AlbumEntity__AlbumEntity_assets" ON "AlbumEntity_AlbumEntity__AlbumEntity_assets"."albumsId" = "AlbumEntity"."id" "status",
LEFT JOIN "assets" "AlbumEntity__AlbumEntity_assets" ON "AlbumEntity__AlbumEntity_assets"."id" = "AlbumEntity_AlbumEntity__AlbumEntity_assets"."assetsId" "profileChangedAt"
AND ( from
"AlbumEntity__AlbumEntity_assets"."deletedAt" IS NULL "users"
) where
WHERE "users"."id" = "album_users"."usersId"
) as obj
) as "user"
from
"albums_shared_users_users" as "album_users"
where
"album_users"."albumsId" = "albums"."id"
) as agg
) as "albumUsers"
from
"albums"
left join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id"
left join "albums_shared_users_users" as "album_users" on "album_users"."albumsId" = "albums"."id"
where
( (
( (
( "albums"."ownerId" = $1
( and "album_assets"."assetsId" = $2
("AlbumEntity"."ownerId" = $1) )
AND ((("AlbumEntity__AlbumEntity_assets"."id" = $2))) or (
) "album_users"."usersId" = $3
) and "album_assets"."assetsId" = $4
OR (
(
(
(
(
"AlbumEntity__AlbumEntity_albumUsers"."usersId" = $3
)
)
)
AND ((("AlbumEntity__AlbumEntity_assets"."id" = $4)))
)
)
) )
) )
AND ("AlbumEntity"."deletedAt" IS NULL) and "albums"."deletedAt" is null
ORDER BY order by
"AlbumEntity"."createdAt" DESC "albums"."createdAt" desc,
"albums"."createdAt" desc
-- AlbumRepository.getMetadataForIds -- AlbumRepository.getMetadataForIds
SELECT select
"album"."id" AS "album_id", "albums"."id",
MIN("assets"."fileCreatedAt") AS "start_date", min("assets"."fileCreatedAt") as "startDate",
MAX("assets"."fileCreatedAt") AS "end_date", max("assets"."fileCreatedAt") as "endDate",
COUNT("assets"."id") AS "asset_count" count("assets"."id") as "assetCount"
FROM from
"albums" "album" "albums"
LEFT JOIN "albums_assets_assets" "album_assets" ON "album_assets"."albumsId" = "album"."id" left join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id"
LEFT JOIN "assets" "assets" ON "assets"."id" = "album_assets"."assetsId" left join "assets" on "assets"."id" = "album_assets"."assetsId"
AND "assets"."deletedAt" IS NULL where
WHERE "albums"."id" in ($1)
("album"."id" IN ($1)) group by
AND ("album"."deletedAt" IS NULL) "albums"."id"
GROUP BY
"album"."id"
-- AlbumRepository.getOwned -- AlbumRepository.getOwned
SELECT select
"AlbumEntity"."id" AS "AlbumEntity_id", "albums".*,
"AlbumEntity"."ownerId" AS "AlbumEntity_ownerId", (
"AlbumEntity"."albumName" AS "AlbumEntity_albumName", select
"AlbumEntity"."description" AS "AlbumEntity_description", to_json(obj)
"AlbumEntity"."createdAt" AS "AlbumEntity_createdAt", from
"AlbumEntity"."updatedAt" AS "AlbumEntity_updatedAt", (
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", select
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", "id",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", "email",
"AlbumEntity"."order" AS "AlbumEntity_order", "createdAt",
"AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", "profileImagePath",
"AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId", "isAdmin",
"AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", "shouldChangePassword",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id", "deletedAt",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name", "oauthId",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin", "updatedAt",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email", "storageLabel",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel", "name",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId", "quotaSizeInBytes",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath", "quotaUsageInBytes",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword", "status",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt", "profileChangedAt"
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt", from
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status", "users"
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", where
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", "users"."id" = "albums"."ownerId"
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes", ) as obj
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt", ) as "owner",
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", (
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", select
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", coalesce(json_agg(agg), '[]')
"AlbumEntity__AlbumEntity_sharedLinks"."userId" AS "AlbumEntity__AlbumEntity_sharedLinks_userId", from
"AlbumEntity__AlbumEntity_sharedLinks"."key" AS "AlbumEntity__AlbumEntity_sharedLinks_key", (
"AlbumEntity__AlbumEntity_sharedLinks"."type" AS "AlbumEntity__AlbumEntity_sharedLinks_type", select
"AlbumEntity__AlbumEntity_sharedLinks"."createdAt" AS "AlbumEntity__AlbumEntity_sharedLinks_createdAt", "album_users".*,
"AlbumEntity__AlbumEntity_sharedLinks"."expiresAt" AS "AlbumEntity__AlbumEntity_sharedLinks_expiresAt", (
"AlbumEntity__AlbumEntity_sharedLinks"."allowUpload" AS "AlbumEntity__AlbumEntity_sharedLinks_allowUpload", select
"AlbumEntity__AlbumEntity_sharedLinks"."allowDownload" AS "AlbumEntity__AlbumEntity_sharedLinks_allowDownload", to_json(obj)
"AlbumEntity__AlbumEntity_sharedLinks"."showExif" AS "AlbumEntity__AlbumEntity_sharedLinks_showExif", from
"AlbumEntity__AlbumEntity_sharedLinks"."albumId" AS "AlbumEntity__AlbumEntity_sharedLinks_albumId", (
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", select
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", "id",
"AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin", "email",
"AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email", "createdAt",
"AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel", "profileImagePath",
"AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId", "isAdmin",
"AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath", "shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", "deletedAt",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "oauthId",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", "updatedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "storageLabel",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "name",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", "quotaUsageInBytes",
"AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt" "status",
FROM "profileChangedAt"
"albums" "AlbumEntity" from
LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" "users"
LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId" where
AND ( "users"."id" = "album_users"."usersId"
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" IS NULL ) as obj
) ) as "user"
LEFT JOIN "shared_links" "AlbumEntity__AlbumEntity_sharedLinks" ON "AlbumEntity__AlbumEntity_sharedLinks"."albumId" = "AlbumEntity"."id" from
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" "albums_shared_users_users" as "album_users"
AND ( where
"AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL "album_users"."albumsId" = "albums"."id"
) ) as agg
WHERE ) as "albumUsers",
((("AlbumEntity"."ownerId" = $1))) (
AND ("AlbumEntity"."deletedAt" IS NULL) select
ORDER BY coalesce(json_agg(agg), '[]')
"AlbumEntity"."createdAt" DESC from
(
select
*
from
"shared_links"
where
"shared_links"."albumId" = "albums"."id"
) as agg
) as "sharedLinks"
from
"albums"
where
"albums"."ownerId" = $1
and "albums"."deletedAt" is null
order by
"albums"."createdAt" desc
-- AlbumRepository.getShared -- AlbumRepository.getShared
SELECT select distinct
"AlbumEntity"."id" AS "AlbumEntity_id", on ("albums"."createdAt") "albums".*,
"AlbumEntity"."ownerId" AS "AlbumEntity_ownerId",
"AlbumEntity"."albumName" AS "AlbumEntity_albumName",
"AlbumEntity"."description" AS "AlbumEntity_description",
"AlbumEntity"."createdAt" AS "AlbumEntity_createdAt",
"AlbumEntity"."updatedAt" AS "AlbumEntity_updatedAt",
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId",
"AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId",
"AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt",
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
"AlbumEntity__AlbumEntity_sharedLinks"."userId" AS "AlbumEntity__AlbumEntity_sharedLinks_userId",
"AlbumEntity__AlbumEntity_sharedLinks"."key" AS "AlbumEntity__AlbumEntity_sharedLinks_key",
"AlbumEntity__AlbumEntity_sharedLinks"."type" AS "AlbumEntity__AlbumEntity_sharedLinks_type",
"AlbumEntity__AlbumEntity_sharedLinks"."createdAt" AS "AlbumEntity__AlbumEntity_sharedLinks_createdAt",
"AlbumEntity__AlbumEntity_sharedLinks"."expiresAt" AS "AlbumEntity__AlbumEntity_sharedLinks_expiresAt",
"AlbumEntity__AlbumEntity_sharedLinks"."allowUpload" AS "AlbumEntity__AlbumEntity_sharedLinks_allowUpload",
"AlbumEntity__AlbumEntity_sharedLinks"."allowDownload" AS "AlbumEntity__AlbumEntity_sharedLinks_allowDownload",
"AlbumEntity__AlbumEntity_sharedLinks"."showExif" AS "AlbumEntity__AlbumEntity_sharedLinks_showExif",
"AlbumEntity__AlbumEntity_sharedLinks"."albumId" AS "AlbumEntity__AlbumEntity_sharedLinks_albumId",
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
"AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
"AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
"AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
"AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
"AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt"
FROM
"albums" "AlbumEntity"
LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id"
LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId"
AND (
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" IS NULL
)
LEFT JOIN "shared_links" "AlbumEntity__AlbumEntity_sharedLinks" ON "AlbumEntity__AlbumEntity_sharedLinks"."albumId" = "AlbumEntity"."id"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"
AND (
"AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL
)
WHERE
( (
( select
coalesce(json_agg(agg), '[]')
from
( (
( select
"album_users".*,
( (
( select
to_json(obj)
from
( (
"AlbumEntity__AlbumEntity_albumUsers"."usersId" = $1 select
) "id",
) "email",
) "createdAt",
) "profileImagePath",
) "isAdmin",
OR ( "shouldChangePassword",
( "deletedAt",
( "oauthId",
( "updatedAt",
( "storageLabel",
"AlbumEntity__AlbumEntity_sharedLinks"."userId" = $2 "name",
) "quotaSizeInBytes",
) "quotaUsageInBytes",
) "status",
) "profileChangedAt"
) from
OR ( "users"
( where
("AlbumEntity"."ownerId" = $3) "users"."id" = "album_users"."usersId"
AND ( ) as obj
( ) as "user"
( from
NOT ( "albums_shared_users_users" as "album_users"
"AlbumEntity__AlbumEntity_albumUsers"."usersId" IS NULL where
) "album_users"."albumsId" = "albums"."id"
) ) as agg
) ) as "albumUsers",
) (
) select
) to_json(obj)
from
(
select
"id",
"email",
"createdAt",
"profileImagePath",
"isAdmin",
"shouldChangePassword",
"deletedAt",
"oauthId",
"updatedAt",
"storageLabel",
"name",
"quotaSizeInBytes",
"quotaUsageInBytes",
"status",
"profileChangedAt"
from
"users"
where
"users"."id" = "albums"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"shared_links"
where
"shared_links"."albumId" = "albums"."id"
) as agg
) as "sharedLinks"
from
"albums"
left join "albums_shared_users_users" as "shared_albums" on "shared_albums"."albumsId" = "albums"."id"
left join "shared_links" on "shared_links"."albumId" = "albums"."id"
where
(
"shared_albums"."usersId" = $1
or "shared_links"."userId" = $2
or (
"albums"."ownerId" = $3
and "shared_albums"."usersId" is not null
) )
) )
AND ("AlbumEntity"."deletedAt" IS NULL) and "albums"."deletedAt" is null
ORDER BY order by
"AlbumEntity"."createdAt" DESC "albums"."createdAt" desc
-- AlbumRepository.getNotShared -- AlbumRepository.getNotShared
SELECT select distinct
"AlbumEntity"."id" AS "AlbumEntity_id", on ("albums"."createdAt") "albums".*,
"AlbumEntity"."ownerId" AS "AlbumEntity_ownerId",
"AlbumEntity"."albumName" AS "AlbumEntity_albumName",
"AlbumEntity"."description" AS "AlbumEntity_description",
"AlbumEntity"."createdAt" AS "AlbumEntity_createdAt",
"AlbumEntity"."updatedAt" AS "AlbumEntity_updatedAt",
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId",
"AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId",
"AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role",
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
"AlbumEntity__AlbumEntity_sharedLinks"."userId" AS "AlbumEntity__AlbumEntity_sharedLinks_userId",
"AlbumEntity__AlbumEntity_sharedLinks"."key" AS "AlbumEntity__AlbumEntity_sharedLinks_key",
"AlbumEntity__AlbumEntity_sharedLinks"."type" AS "AlbumEntity__AlbumEntity_sharedLinks_type",
"AlbumEntity__AlbumEntity_sharedLinks"."createdAt" AS "AlbumEntity__AlbumEntity_sharedLinks_createdAt",
"AlbumEntity__AlbumEntity_sharedLinks"."expiresAt" AS "AlbumEntity__AlbumEntity_sharedLinks_expiresAt",
"AlbumEntity__AlbumEntity_sharedLinks"."allowUpload" AS "AlbumEntity__AlbumEntity_sharedLinks_allowUpload",
"AlbumEntity__AlbumEntity_sharedLinks"."allowDownload" AS "AlbumEntity__AlbumEntity_sharedLinks_allowDownload",
"AlbumEntity__AlbumEntity_sharedLinks"."showExif" AS "AlbumEntity__AlbumEntity_sharedLinks_showExif",
"AlbumEntity__AlbumEntity_sharedLinks"."albumId" AS "AlbumEntity__AlbumEntity_sharedLinks_albumId",
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
"AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
"AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
"AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
"AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
"AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt"
FROM
"albums" "AlbumEntity"
LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id"
LEFT JOIN "shared_links" "AlbumEntity__AlbumEntity_sharedLinks" ON "AlbumEntity__AlbumEntity_sharedLinks"."albumId" = "AlbumEntity"."id"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"
AND (
"AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL
)
WHERE
( (
( select
("AlbumEntity"."ownerId" = $1) coalesce(json_agg(agg), '[]')
AND ( from
( (
select
"album_users".*,
( (
"AlbumEntity__AlbumEntity_albumUsers"."usersId" IS NULL select
) to_json(obj)
) from
) (
AND ( select
( "id",
( "email",
"AlbumEntity__AlbumEntity_sharedLinks"."id" IS NULL "createdAt",
) "profileImagePath",
) "isAdmin",
) "shouldChangePassword",
) "deletedAt",
) "oauthId",
AND ("AlbumEntity"."deletedAt" IS NULL) "updatedAt",
ORDER BY "storageLabel",
"AlbumEntity"."createdAt" DESC "name",
"quotaSizeInBytes",
"quotaUsageInBytes",
"status",
"profileChangedAt"
from
"users"
where
"users"."id" = "album_users"."usersId"
) as obj
) as "user"
from
"albums_shared_users_users" as "album_users"
where
"album_users"."albumsId" = "albums"."id"
) as agg
) as "albumUsers",
(
select
to_json(obj)
from
(
select
"id",
"email",
"createdAt",
"profileImagePath",
"isAdmin",
"shouldChangePassword",
"deletedAt",
"oauthId",
"updatedAt",
"storageLabel",
"name",
"quotaSizeInBytes",
"quotaUsageInBytes",
"status",
"profileChangedAt"
from
"users"
where
"users"."id" = "albums"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"shared_links"
where
"shared_links"."albumId" = "albums"."id"
) as agg
) as "sharedLinks"
from
"albums"
left join "albums_shared_users_users" as "shared_albums" on "shared_albums"."albumsId" = "albums"."id"
left join "shared_links" on "shared_links"."albumId" = "albums"."id"
where
"albums"."ownerId" = $1
and "shared_albums"."usersId" is null
and "shared_links"."userId" is null
and "albums"."deletedAt" is null
order by
"albums"."createdAt" desc
-- AlbumRepository.getAssetIds -- AlbumRepository.getAssetIds
SELECT select
"albums_assets"."assetsId" AS "assetId" *
FROM from
"albums_assets_assets" "albums_assets" "albums_assets_assets"
WHERE where
"albums_assets"."albumsId" = $1 "albums_assets_assets"."albumsId" = $1
AND "albums_assets"."assetsId" IN ($2) and "albums_assets_assets"."assetsId" in ($2)

View File

@ -1,342 +1,252 @@
-- NOTE: This file is auto generated by ./sql-generator -- NOTE: This file is auto generated by ./sql-generator
-- PersonRepository.reassignFaces -- PersonRepository.reassignFaces
UPDATE "asset_faces" update "asset_faces"
SET set
"personId" = $1 "personId" = $1
WHERE where
"personId" = $2 "asset_faces"."personId" = $2
-- PersonRepository.getAllForUser -- PersonRepository.unassignFaces
SELECT update "asset_faces"
"person"."id" AS "person_id", set
"person"."createdAt" AS "person_createdAt", "personId" = $1
"person"."updatedAt" AS "person_updatedAt", where
"person"."ownerId" AS "person_ownerId", "asset_faces"."sourceType" = $2
"person"."name" AS "person_name", VACUUM
"person"."birthDate" AS "person_birthDate", ANALYZE asset_faces,
"person"."thumbnailPath" AS "person_thumbnailPath", face_search,
"person"."faceAssetId" AS "person_faceAssetId", person
"person"."isHidden" AS "person_isHidden" REINDEX TABLE asset_faces
FROM REINDEX TABLE person
"person" "person"
INNER JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" -- PersonRepository.delete
INNER JOIN "assets" "asset" ON "asset"."id" = "face"."assetId" delete from "person"
AND ("asset"."deletedAt" IS NULL) where
WHERE "person"."id" in ($1)
"person"."ownerId" = $1
AND "asset"."isArchived" = false -- PersonRepository.deleteFaces
AND "person"."isHidden" = false delete from "asset_faces"
GROUP BY where
"person"."id" "asset_faces"."sourceType" = $1
HAVING VACUUM
"person"."name" != '' ANALYZE asset_faces,
OR COUNT("face"."assetId") >= $2 face_search,
ORDER BY person
"person"."isHidden" ASC, REINDEX TABLE asset_faces
NULLIF("person"."name", '') IS NULL ASC, REINDEX TABLE person
COUNT("face"."assetId") DESC,
NULLIF("person"."name", '') ASC NULLS LAST,
"person"."createdAt" ASC
LIMIT
11
OFFSET
10
-- PersonRepository.getAllWithoutFaces -- PersonRepository.getAllWithoutFaces
SELECT select
"person"."id" AS "person_id", "person".*
"person"."createdAt" AS "person_createdAt", from
"person"."updatedAt" AS "person_updatedAt", "person"
"person"."ownerId" AS "person_ownerId", left join "asset_faces" on "asset_faces"."personId" = "person"."id"
"person"."name" AS "person_name", group by
"person"."birthDate" AS "person_birthDate",
"person"."thumbnailPath" AS "person_thumbnailPath",
"person"."faceAssetId" AS "person_faceAssetId",
"person"."isHidden" AS "person_isHidden"
FROM
"person" "person"
LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id"
GROUP BY
"person"."id" "person"."id"
HAVING having
COUNT("face"."assetId") = 0 count("asset_faces"."assetId") = $1
-- PersonRepository.getFaces -- PersonRepository.getFaces
SELECT select
"AssetFaceEntity"."id" AS "AssetFaceEntity_id", "asset_faces".*,
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", (
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", select
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", to_json(obj)
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", from
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", (
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", select
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", "person".*
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", from
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", "person"
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", where
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", "person"."id" = "asset_faces"."personId"
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", ) as obj
"AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId", ) as "person"
"AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name", from
"AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate", "asset_faces"
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath", where
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId", "asset_faces"."assetId" = $1
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden" order by
FROM "asset_faces"."boundingBoxX1" asc
"asset_faces" "AssetFaceEntity"
LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
WHERE
(("AssetFaceEntity"."assetId" = $1))
ORDER BY
"AssetFaceEntity"."boundingBoxX1" ASC
-- PersonRepository.getFaceById -- PersonRepository.getFaceById
SELECT DISTINCT select
"distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id" "asset_faces".*,
FROM
( (
SELECT select
"AssetFaceEntity"."id" AS "AssetFaceEntity_id", to_json(obj)
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", from
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", (
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", select
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", "person".*
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", from
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "person"
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", where
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", "person"."id" = "asset_faces"."personId"
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", ) as obj
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", ) as "person"
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", from
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", "asset_faces"
"AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId", where
"AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name", "asset_faces"."id" = $1
"AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate",
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden"
FROM
"asset_faces" "AssetFaceEntity"
LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
WHERE
(("AssetFaceEntity"."id" = $1))
) "distinctAlias"
ORDER BY
"AssetFaceEntity_id" ASC
LIMIT
1
-- PersonRepository.getFaceByIdWithAssets -- PersonRepository.getFaceByIdWithAssets
SELECT DISTINCT select
"distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id" "asset_faces".*,
FROM
( (
SELECT select
"AssetFaceEntity"."id" AS "AssetFaceEntity_id", to_json(obj)
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", from
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", (
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", select
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", "person".*
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", from
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "person"
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", where
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", "person"."id" = "asset_faces"."personId"
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", ) as obj
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", ) as "person",
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", (
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", select
"AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId", to_json(obj)
"AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name", from
"AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate", (
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath", select
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId", "assets".*
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden", from
"AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id", "assets"
"AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId", where
"AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId", "assets"."id" = "asset_faces"."assetId"
"AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId", ) as obj
"AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId", ) as "asset"
"AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type", from
"AssetFaceEntity__AssetFaceEntity_asset"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status", "asset_faces"
"AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath", where
"AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash", "asset_faces"."id" = $1
"AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath",
"AssetFaceEntity__AssetFaceEntity_asset"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_asset_createdAt",
"AssetFaceEntity__AssetFaceEntity_asset"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_updatedAt",
"AssetFaceEntity__AssetFaceEntity_asset"."deletedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_deletedAt",
"AssetFaceEntity__AssetFaceEntity_asset"."fileCreatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileCreatedAt",
"AssetFaceEntity__AssetFaceEntity_asset"."localDateTime" AS "AssetFaceEntity__AssetFaceEntity_asset_localDateTime",
"AssetFaceEntity__AssetFaceEntity_asset"."fileModifiedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileModifiedAt",
"AssetFaceEntity__AssetFaceEntity_asset"."isFavorite" AS "AssetFaceEntity__AssetFaceEntity_asset_isFavorite",
"AssetFaceEntity__AssetFaceEntity_asset"."isArchived" AS "AssetFaceEntity__AssetFaceEntity_asset_isArchived",
"AssetFaceEntity__AssetFaceEntity_asset"."isExternal" AS "AssetFaceEntity__AssetFaceEntity_asset_isExternal",
"AssetFaceEntity__AssetFaceEntity_asset"."isOffline" AS "AssetFaceEntity__AssetFaceEntity_asset_isOffline",
"AssetFaceEntity__AssetFaceEntity_asset"."checksum" AS "AssetFaceEntity__AssetFaceEntity_asset_checksum",
"AssetFaceEntity__AssetFaceEntity_asset"."duration" AS "AssetFaceEntity__AssetFaceEntity_asset_duration",
"AssetFaceEntity__AssetFaceEntity_asset"."isVisible" AS "AssetFaceEntity__AssetFaceEntity_asset_isVisible",
"AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId",
"AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName",
"AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath",
"AssetFaceEntity__AssetFaceEntity_asset"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId",
"AssetFaceEntity__AssetFaceEntity_asset"."duplicateId" AS "AssetFaceEntity__AssetFaceEntity_asset_duplicateId"
FROM
"asset_faces" "AssetFaceEntity"
LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
LEFT JOIN "assets" "AssetFaceEntity__AssetFaceEntity_asset" ON "AssetFaceEntity__AssetFaceEntity_asset"."id" = "AssetFaceEntity"."assetId"
AND (
"AssetFaceEntity__AssetFaceEntity_asset"."deletedAt" IS NULL
)
WHERE
(("AssetFaceEntity"."id" = $1))
) "distinctAlias"
ORDER BY
"AssetFaceEntity_id" ASC
LIMIT
1
-- PersonRepository.reassignFace -- PersonRepository.reassignFace
UPDATE "asset_faces" update "asset_faces"
SET set
"personId" = $1 "personId" = $1
WHERE where
"id" = $2 "asset_faces"."id" = $2
-- PersonRepository.getByName -- PersonRepository.getByName
SELECT select
"person"."id" AS "person_id", "person".*
"person"."createdAt" AS "person_createdAt", from
"person"."updatedAt" AS "person_updatedAt", "person"
"person"."ownerId" AS "person_ownerId", where
"person"."name" AS "person_name",
"person"."birthDate" AS "person_birthDate",
"person"."thumbnailPath" AS "person_thumbnailPath",
"person"."faceAssetId" AS "person_faceAssetId",
"person"."isHidden" AS "person_isHidden"
FROM
"person" "person"
WHERE
"person"."ownerId" = $1
AND (
LOWER("person"."name") LIKE $2
OR LOWER("person"."name") LIKE $3
)
LIMIT
1000
-- PersonRepository.getDistinctNames
SELECT DISTINCT
ON (lower("person"."name")) "person"."id" AS "person_id",
"person"."name" AS "person_name"
FROM
"person" "person"
WHERE
"person"."ownerId" = $1
AND "person"."name" != ''
-- PersonRepository.getStatistics
SELECT
COUNT(DISTINCT ("asset"."id")) AS "count"
FROM
"asset_faces" "face"
LEFT JOIN "assets" "asset" ON "asset"."id" = "face"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"face"."personId" = $1
AND "asset"."isArchived" = false
AND "asset"."deletedAt" IS NULL
AND "asset"."livePhotoVideoId" IS NULL
-- PersonRepository.getNumberOfPeople
SELECT
COUNT(DISTINCT ("person"."id")) AS "total",
COUNT(DISTINCT ("person"."id")) FILTER (
WHERE
"person"."isHidden" = true
) AS "hidden"
FROM
"person" "person"
INNER JOIN "asset_faces" "face" ON "face"."personId" = "person"."id"
INNER JOIN "assets" "asset" ON "asset"."id" = "face"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"person"."ownerId" = $1
AND "asset"."isArchived" = false
-- PersonRepository.getFacesByIds
SELECT
"AssetFaceEntity"."id" AS "AssetFaceEntity_id",
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType",
"AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id",
"AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId",
"AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId",
"AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId",
"AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId",
"AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type",
"AssetFaceEntity__AssetFaceEntity_asset"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status",
"AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath",
"AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash",
"AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath",
"AssetFaceEntity__AssetFaceEntity_asset"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_asset_createdAt",
"AssetFaceEntity__AssetFaceEntity_asset"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_updatedAt",
"AssetFaceEntity__AssetFaceEntity_asset"."deletedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_deletedAt",
"AssetFaceEntity__AssetFaceEntity_asset"."fileCreatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileCreatedAt",
"AssetFaceEntity__AssetFaceEntity_asset"."localDateTime" AS "AssetFaceEntity__AssetFaceEntity_asset_localDateTime",
"AssetFaceEntity__AssetFaceEntity_asset"."fileModifiedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileModifiedAt",
"AssetFaceEntity__AssetFaceEntity_asset"."isFavorite" AS "AssetFaceEntity__AssetFaceEntity_asset_isFavorite",
"AssetFaceEntity__AssetFaceEntity_asset"."isArchived" AS "AssetFaceEntity__AssetFaceEntity_asset_isArchived",
"AssetFaceEntity__AssetFaceEntity_asset"."isExternal" AS "AssetFaceEntity__AssetFaceEntity_asset_isExternal",
"AssetFaceEntity__AssetFaceEntity_asset"."isOffline" AS "AssetFaceEntity__AssetFaceEntity_asset_isOffline",
"AssetFaceEntity__AssetFaceEntity_asset"."checksum" AS "AssetFaceEntity__AssetFaceEntity_asset_checksum",
"AssetFaceEntity__AssetFaceEntity_asset"."duration" AS "AssetFaceEntity__AssetFaceEntity_asset_duration",
"AssetFaceEntity__AssetFaceEntity_asset"."isVisible" AS "AssetFaceEntity__AssetFaceEntity_asset_isVisible",
"AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId",
"AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName",
"AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath",
"AssetFaceEntity__AssetFaceEntity_asset"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId",
"AssetFaceEntity__AssetFaceEntity_asset"."duplicateId" AS "AssetFaceEntity__AssetFaceEntity_asset_duplicateId"
FROM
"asset_faces" "AssetFaceEntity"
LEFT JOIN "assets" "AssetFaceEntity__AssetFaceEntity_asset" ON "AssetFaceEntity__AssetFaceEntity_asset"."id" = "AssetFaceEntity"."assetId"
WHERE
( (
( "person"."ownerId" = $1
( and (
("AssetFaceEntity"."assetId" = $1) lower("person"."name") like $2
AND ("AssetFaceEntity"."personId" = $2) or lower("person"."name") like $3
)
) )
) )
limit
$4
-- PersonRepository.getDistinctNames
select distinct
on (lower("person"."name")) "person"."id",
"person"."name"
from
"person"
where
(
"person"."ownerId" = $1
and "person"."name" != $2
)
-- PersonRepository.getStatistics
select
count(distinct ("assets"."id")) as "count"
from
"asset_faces"
left join "assets" on "assets"."id" = "asset_faces"."assetId"
and "asset_faces"."personId" = $1
and "assets"."isArchived" = $2
and "assets"."deletedAt" is null
and "assets"."livePhotoVideoId" is null
-- PersonRepository.getNumberOfPeople
select
count(distinct ("person"."id")) as "total",
count(distinct ("person"."id")) filter (
where
"person"."isHidden" = $1
) as "hidden"
from
"person"
inner join "asset_faces" on "asset_faces"."personId" = "person"."id"
inner join "assets" on "assets"."id" = "asset_faces"."assetId"
and "assets"."deletedAt" is null
and "assets"."isArchived" = $2
where
"person"."ownerId" = $3
-- PersonRepository.refreshFaces
with
"added_embeddings" as (
insert into
"face_search" ("faceId", "embedding")
values
($1, $2)
)
select
from
(
select
1
) as "dummy"
-- PersonRepository.getFacesByIds
select
"asset_faces".*,
(
select
to_json(obj)
from
(
select
"assets".*
from
"assets"
where
"assets"."id" = "asset_faces"."assetId"
) as obj
) as "asset",
(
select
to_json(obj)
from
(
select
"person".*
from
"person"
where
"person"."id" = "asset_faces"."personId"
) as obj
) as "person"
from
"asset_faces"
where
"asset_faces"."assetId" in ($1)
and "asset_faces"."personId" in ($2)
-- PersonRepository.getRandomFace -- PersonRepository.getRandomFace
SELECT select
"AssetFaceEntity"."id" AS "AssetFaceEntity_id", "asset_faces".*
"AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", from
"AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", "asset_faces"
"AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", where
"AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", "asset_faces"."personId" = $1
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType"
FROM
"asset_faces" "AssetFaceEntity"
WHERE
(("AssetFaceEntity"."personId" = $1))
LIMIT
1
-- PersonRepository.getLatestFaceDate -- PersonRepository.getLatestFaceDate
SELECT select
MAX("jobStatus"."facesRecognizedAt")::text AS "latestDate" max("asset_job_status"."facesRecognizedAt")::text as "latestDate"
FROM from
"asset_job_status" "jobStatus" "asset_job_status"

View File

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

View File

@ -1,257 +1,95 @@
-- NOTE: This file is auto generated by ./sql-generator -- NOTE: This file is auto generated by ./sql-generator
-- StackRepository.search -- StackRepository.search
SELECT select
"StackEntity"."id" AS "StackEntity_id", "asset_stack".*,
"StackEntity"."ownerId" AS "StackEntity_ownerId", (
"StackEntity"."primaryAssetId" AS "StackEntity_primaryAssetId", select
"StackEntity__StackEntity_assets"."id" AS "StackEntity__StackEntity_assets_id", coalesce(json_agg(agg), '[]')
"StackEntity__StackEntity_assets"."deviceAssetId" AS "StackEntity__StackEntity_assets_deviceAssetId", from
"StackEntity__StackEntity_assets"."ownerId" AS "StackEntity__StackEntity_assets_ownerId", (
"StackEntity__StackEntity_assets"."libraryId" AS "StackEntity__StackEntity_assets_libraryId", select
"StackEntity__StackEntity_assets"."deviceId" AS "StackEntity__StackEntity_assets_deviceId", *
"StackEntity__StackEntity_assets"."type" AS "StackEntity__StackEntity_assets_type", from
"StackEntity__StackEntity_assets"."status" AS "StackEntity__StackEntity_assets_status", "assets"
"StackEntity__StackEntity_assets"."originalPath" AS "StackEntity__StackEntity_assets_originalPath", where
"StackEntity__StackEntity_assets"."thumbhash" AS "StackEntity__StackEntity_assets_thumbhash", "assets"."deletedAt" is null
"StackEntity__StackEntity_assets"."encodedVideoPath" AS "StackEntity__StackEntity_assets_encodedVideoPath", and "assets"."stackId" = "asset_stack"."id"
"StackEntity__StackEntity_assets"."createdAt" AS "StackEntity__StackEntity_assets_createdAt", ) as agg
"StackEntity__StackEntity_assets"."updatedAt" AS "StackEntity__StackEntity_assets_updatedAt", ) as "assets"
"StackEntity__StackEntity_assets"."deletedAt" AS "StackEntity__StackEntity_assets_deletedAt", from
"StackEntity__StackEntity_assets"."fileCreatedAt" AS "StackEntity__StackEntity_assets_fileCreatedAt", "asset_stack"
"StackEntity__StackEntity_assets"."localDateTime" AS "StackEntity__StackEntity_assets_localDateTime", where
"StackEntity__StackEntity_assets"."fileModifiedAt" AS "StackEntity__StackEntity_assets_fileModifiedAt", "asset_stack"."ownerId" = $1
"StackEntity__StackEntity_assets"."isFavorite" AS "StackEntity__StackEntity_assets_isFavorite",
"StackEntity__StackEntity_assets"."isArchived" AS "StackEntity__StackEntity_assets_isArchived",
"StackEntity__StackEntity_assets"."isExternal" AS "StackEntity__StackEntity_assets_isExternal",
"StackEntity__StackEntity_assets"."isOffline" AS "StackEntity__StackEntity_assets_isOffline",
"StackEntity__StackEntity_assets"."checksum" AS "StackEntity__StackEntity_assets_checksum",
"StackEntity__StackEntity_assets"."duration" AS "StackEntity__StackEntity_assets_duration",
"StackEntity__StackEntity_assets"."isVisible" AS "StackEntity__StackEntity_assets_isVisible",
"StackEntity__StackEntity_assets"."livePhotoVideoId" AS "StackEntity__StackEntity_assets_livePhotoVideoId",
"StackEntity__StackEntity_assets"."originalFileName" AS "StackEntity__StackEntity_assets_originalFileName",
"StackEntity__StackEntity_assets"."sidecarPath" AS "StackEntity__StackEntity_assets_sidecarPath",
"StackEntity__StackEntity_assets"."stackId" AS "StackEntity__StackEntity_assets_stackId",
"StackEntity__StackEntity_assets"."duplicateId" AS "StackEntity__StackEntity_assets_duplicateId",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."assetId" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_assetId",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."description" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_description",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."exifImageWidth" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_exifImageWidth",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."exifImageHeight" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_exifImageHeight",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."fileSizeInByte" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_fileSizeInByte",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."orientation" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_orientation",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."dateTimeOriginal" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_dateTimeOriginal",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."modifyDate" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_modifyDate",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."timeZone" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_timeZone",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."latitude" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_latitude",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."longitude" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_longitude",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."projectionType" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_projectionType",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."city" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_city",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."livePhotoCID" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_livePhotoCID",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."autoStackId" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_autoStackId",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."state" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_state",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."country" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_country",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."make" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_make",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."model" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_model",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."lensModel" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_lensModel",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."fNumber" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_fNumber",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."focalLength" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_focalLength",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."iso" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_iso",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."exposureTime" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_exposureTime",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."profileDescription" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_profileDescription",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."colorspace" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_colorspace",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."bitsPerSample" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_bitsPerSample",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."rating" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_rating",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."fps" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_fps"
FROM
"asset_stack" "StackEntity"
LEFT JOIN "assets" "StackEntity__StackEntity_assets" ON "StackEntity__StackEntity_assets"."stackId" = "StackEntity"."id"
AND (
"StackEntity__StackEntity_assets"."deletedAt" IS NULL
)
LEFT JOIN "exif" "01db479afeb88793eed8e0d1dde6ccfccf1698b9" ON "01db479afeb88793eed8e0d1dde6ccfccf1698b9"."assetId" = "StackEntity__StackEntity_assets"."id"
WHERE
(("StackEntity"."ownerId" = $1))
-- StackRepository.delete -- StackRepository.delete
SELECT DISTINCT select
"distinctAlias"."StackEntity_id" AS "ids_StackEntity_id", *,
"distinctAlias"."StackEntity__StackEntity_assets_fileCreatedAt"
FROM
( (
SELECT select
"StackEntity"."id" AS "StackEntity_id", coalesce(json_agg(agg), '[]')
"StackEntity"."ownerId" AS "StackEntity_ownerId", from
"StackEntity"."primaryAssetId" AS "StackEntity_primaryAssetId", (
"StackEntity__StackEntity_assets"."id" AS "StackEntity__StackEntity_assets_id", select
"StackEntity__StackEntity_assets"."deviceAssetId" AS "StackEntity__StackEntity_assets_deviceAssetId", *,
"StackEntity__StackEntity_assets"."ownerId" AS "StackEntity__StackEntity_assets_ownerId", (
"StackEntity__StackEntity_assets"."libraryId" AS "StackEntity__StackEntity_assets_libraryId", select
"StackEntity__StackEntity_assets"."deviceId" AS "StackEntity__StackEntity_assets_deviceId", coalesce(json_agg(agg), '[]')
"StackEntity__StackEntity_assets"."type" AS "StackEntity__StackEntity_assets_type", from
"StackEntity__StackEntity_assets"."status" AS "StackEntity__StackEntity_assets_status", (
"StackEntity__StackEntity_assets"."originalPath" AS "StackEntity__StackEntity_assets_originalPath", select
"StackEntity__StackEntity_assets"."thumbhash" AS "StackEntity__StackEntity_assets_thumbhash", "tags".*
"StackEntity__StackEntity_assets"."encodedVideoPath" AS "StackEntity__StackEntity_assets_encodedVideoPath", from
"StackEntity__StackEntity_assets"."createdAt" AS "StackEntity__StackEntity_assets_createdAt", "tags"
"StackEntity__StackEntity_assets"."updatedAt" AS "StackEntity__StackEntity_assets_updatedAt", inner join "tag_asset" on "tags"."id" = "tag_asset"."tagsId"
"StackEntity__StackEntity_assets"."deletedAt" AS "StackEntity__StackEntity_assets_deletedAt", where
"StackEntity__StackEntity_assets"."fileCreatedAt" AS "StackEntity__StackEntity_assets_fileCreatedAt", "tag_asset"."assetsId" = "assets"."id"
"StackEntity__StackEntity_assets"."localDateTime" AS "StackEntity__StackEntity_assets_localDateTime", ) as agg
"StackEntity__StackEntity_assets"."fileModifiedAt" AS "StackEntity__StackEntity_assets_fileModifiedAt", ) as "tags"
"StackEntity__StackEntity_assets"."isFavorite" AS "StackEntity__StackEntity_assets_isFavorite", from
"StackEntity__StackEntity_assets"."isArchived" AS "StackEntity__StackEntity_assets_isArchived", "assets"
"StackEntity__StackEntity_assets"."isExternal" AS "StackEntity__StackEntity_assets_isExternal", where
"StackEntity__StackEntity_assets"."isOffline" AS "StackEntity__StackEntity_assets_isOffline", "assets"."deletedAt" is null
"StackEntity__StackEntity_assets"."checksum" AS "StackEntity__StackEntity_assets_checksum", and "assets"."stackId" = "asset_stack"."id"
"StackEntity__StackEntity_assets"."duration" AS "StackEntity__StackEntity_assets_duration", ) as agg
"StackEntity__StackEntity_assets"."isVisible" AS "StackEntity__StackEntity_assets_isVisible", ) as "assets"
"StackEntity__StackEntity_assets"."livePhotoVideoId" AS "StackEntity__StackEntity_assets_livePhotoVideoId", from
"StackEntity__StackEntity_assets"."originalFileName" AS "StackEntity__StackEntity_assets_originalFileName", "asset_stack"
"StackEntity__StackEntity_assets"."sidecarPath" AS "StackEntity__StackEntity_assets_sidecarPath", where
"StackEntity__StackEntity_assets"."stackId" AS "StackEntity__StackEntity_assets_stackId", "id" = $1::uuid
"StackEntity__StackEntity_assets"."duplicateId" AS "StackEntity__StackEntity_assets_duplicateId",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."assetId" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_assetId",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."description" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_description",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."exifImageWidth" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_exifImageWidth",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."exifImageHeight" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_exifImageHeight",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."fileSizeInByte" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_fileSizeInByte",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."orientation" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_orientation",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."dateTimeOriginal" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_dateTimeOriginal",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."modifyDate" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_modifyDate",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."timeZone" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_timeZone",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."latitude" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_latitude",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."longitude" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_longitude",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."projectionType" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_projectionType",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."city" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_city",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."livePhotoCID" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_livePhotoCID",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."autoStackId" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_autoStackId",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."state" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_state",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."country" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_country",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."make" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_make",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."model" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_model",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."lensModel" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_lensModel",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."fNumber" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_fNumber",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."focalLength" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_focalLength",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."iso" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_iso",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."exposureTime" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_exposureTime",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."profileDescription" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_profileDescription",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."colorspace" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_colorspace",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."bitsPerSample" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_bitsPerSample",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."rating" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_rating",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."fps" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_fps",
"3e3064f11b97177a1e1ce3c77ecf32850343aba1"."id" AS "3e3064f11b97177a1e1ce3c77ecf32850343aba1_id",
"3e3064f11b97177a1e1ce3c77ecf32850343aba1"."value" AS "3e3064f11b97177a1e1ce3c77ecf32850343aba1_value",
"3e3064f11b97177a1e1ce3c77ecf32850343aba1"."createdAt" AS "3e3064f11b97177a1e1ce3c77ecf32850343aba1_createdAt",
"3e3064f11b97177a1e1ce3c77ecf32850343aba1"."updatedAt" AS "3e3064f11b97177a1e1ce3c77ecf32850343aba1_updatedAt",
"3e3064f11b97177a1e1ce3c77ecf32850343aba1"."color" AS "3e3064f11b97177a1e1ce3c77ecf32850343aba1_color",
"3e3064f11b97177a1e1ce3c77ecf32850343aba1"."parentId" AS "3e3064f11b97177a1e1ce3c77ecf32850343aba1_parentId",
"3e3064f11b97177a1e1ce3c77ecf32850343aba1"."userId" AS "3e3064f11b97177a1e1ce3c77ecf32850343aba1_userId"
FROM
"asset_stack" "StackEntity"
LEFT JOIN "assets" "StackEntity__StackEntity_assets" ON "StackEntity__StackEntity_assets"."stackId" = "StackEntity"."id"
AND (
"StackEntity__StackEntity_assets"."deletedAt" IS NULL
)
LEFT JOIN "exif" "01db479afeb88793eed8e0d1dde6ccfccf1698b9" ON "01db479afeb88793eed8e0d1dde6ccfccf1698b9"."assetId" = "StackEntity__StackEntity_assets"."id"
LEFT JOIN "tag_asset" "4f1c9474d4596aede2814ee2eb938eecf7a93b95" ON "4f1c9474d4596aede2814ee2eb938eecf7a93b95"."assetsId" = "StackEntity__StackEntity_assets"."id"
LEFT JOIN "tags" "3e3064f11b97177a1e1ce3c77ecf32850343aba1" ON "3e3064f11b97177a1e1ce3c77ecf32850343aba1"."id" = "4f1c9474d4596aede2814ee2eb938eecf7a93b95"."tagsId"
WHERE
(("StackEntity"."id" = $1))
) "distinctAlias"
ORDER BY
"distinctAlias"."StackEntity__StackEntity_assets_fileCreatedAt" ASC,
"StackEntity_id" ASC
LIMIT
1
-- StackRepository.getById -- StackRepository.getById
SELECT DISTINCT select
"distinctAlias"."StackEntity_id" AS "ids_StackEntity_id", *,
"distinctAlias"."StackEntity__StackEntity_assets_fileCreatedAt"
FROM
( (
SELECT select
"StackEntity"."id" AS "StackEntity_id", coalesce(json_agg(agg), '[]')
"StackEntity"."ownerId" AS "StackEntity_ownerId", from
"StackEntity"."primaryAssetId" AS "StackEntity_primaryAssetId", (
"StackEntity__StackEntity_assets"."id" AS "StackEntity__StackEntity_assets_id", select
"StackEntity__StackEntity_assets"."deviceAssetId" AS "StackEntity__StackEntity_assets_deviceAssetId", *,
"StackEntity__StackEntity_assets"."ownerId" AS "StackEntity__StackEntity_assets_ownerId", (
"StackEntity__StackEntity_assets"."libraryId" AS "StackEntity__StackEntity_assets_libraryId", select
"StackEntity__StackEntity_assets"."deviceId" AS "StackEntity__StackEntity_assets_deviceId", coalesce(json_agg(agg), '[]')
"StackEntity__StackEntity_assets"."type" AS "StackEntity__StackEntity_assets_type", from
"StackEntity__StackEntity_assets"."status" AS "StackEntity__StackEntity_assets_status", (
"StackEntity__StackEntity_assets"."originalPath" AS "StackEntity__StackEntity_assets_originalPath", select
"StackEntity__StackEntity_assets"."thumbhash" AS "StackEntity__StackEntity_assets_thumbhash", "tags".*
"StackEntity__StackEntity_assets"."encodedVideoPath" AS "StackEntity__StackEntity_assets_encodedVideoPath", from
"StackEntity__StackEntity_assets"."createdAt" AS "StackEntity__StackEntity_assets_createdAt", "tags"
"StackEntity__StackEntity_assets"."updatedAt" AS "StackEntity__StackEntity_assets_updatedAt", inner join "tag_asset" on "tags"."id" = "tag_asset"."tagsId"
"StackEntity__StackEntity_assets"."deletedAt" AS "StackEntity__StackEntity_assets_deletedAt", where
"StackEntity__StackEntity_assets"."fileCreatedAt" AS "StackEntity__StackEntity_assets_fileCreatedAt", "tag_asset"."assetsId" = "assets"."id"
"StackEntity__StackEntity_assets"."localDateTime" AS "StackEntity__StackEntity_assets_localDateTime", ) as agg
"StackEntity__StackEntity_assets"."fileModifiedAt" AS "StackEntity__StackEntity_assets_fileModifiedAt", ) as "tags"
"StackEntity__StackEntity_assets"."isFavorite" AS "StackEntity__StackEntity_assets_isFavorite", from
"StackEntity__StackEntity_assets"."isArchived" AS "StackEntity__StackEntity_assets_isArchived", "assets"
"StackEntity__StackEntity_assets"."isExternal" AS "StackEntity__StackEntity_assets_isExternal", where
"StackEntity__StackEntity_assets"."isOffline" AS "StackEntity__StackEntity_assets_isOffline", "assets"."deletedAt" is null
"StackEntity__StackEntity_assets"."checksum" AS "StackEntity__StackEntity_assets_checksum", and "assets"."stackId" = "asset_stack"."id"
"StackEntity__StackEntity_assets"."duration" AS "StackEntity__StackEntity_assets_duration", ) as agg
"StackEntity__StackEntity_assets"."isVisible" AS "StackEntity__StackEntity_assets_isVisible", ) as "assets"
"StackEntity__StackEntity_assets"."livePhotoVideoId" AS "StackEntity__StackEntity_assets_livePhotoVideoId", from
"StackEntity__StackEntity_assets"."originalFileName" AS "StackEntity__StackEntity_assets_originalFileName", "asset_stack"
"StackEntity__StackEntity_assets"."sidecarPath" AS "StackEntity__StackEntity_assets_sidecarPath", where
"StackEntity__StackEntity_assets"."stackId" AS "StackEntity__StackEntity_assets_stackId", "id" = $1::uuid
"StackEntity__StackEntity_assets"."duplicateId" AS "StackEntity__StackEntity_assets_duplicateId",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."assetId" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_assetId",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."description" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_description",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."exifImageWidth" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_exifImageWidth",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."exifImageHeight" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_exifImageHeight",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."fileSizeInByte" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_fileSizeInByte",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."orientation" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_orientation",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."dateTimeOriginal" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_dateTimeOriginal",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."modifyDate" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_modifyDate",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."timeZone" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_timeZone",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."latitude" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_latitude",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."longitude" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_longitude",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."projectionType" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_projectionType",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."city" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_city",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."livePhotoCID" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_livePhotoCID",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."autoStackId" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_autoStackId",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."state" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_state",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."country" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_country",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."make" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_make",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."model" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_model",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."lensModel" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_lensModel",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."fNumber" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_fNumber",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."focalLength" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_focalLength",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."iso" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_iso",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."exposureTime" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_exposureTime",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."profileDescription" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_profileDescription",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."colorspace" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_colorspace",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."bitsPerSample" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_bitsPerSample",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."rating" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_rating",
"01db479afeb88793eed8e0d1dde6ccfccf1698b9"."fps" AS "01db479afeb88793eed8e0d1dde6ccfccf1698b9_fps",
"3e3064f11b97177a1e1ce3c77ecf32850343aba1"."id" AS "3e3064f11b97177a1e1ce3c77ecf32850343aba1_id",
"3e3064f11b97177a1e1ce3c77ecf32850343aba1"."value" AS "3e3064f11b97177a1e1ce3c77ecf32850343aba1_value",
"3e3064f11b97177a1e1ce3c77ecf32850343aba1"."createdAt" AS "3e3064f11b97177a1e1ce3c77ecf32850343aba1_createdAt",
"3e3064f11b97177a1e1ce3c77ecf32850343aba1"."updatedAt" AS "3e3064f11b97177a1e1ce3c77ecf32850343aba1_updatedAt",
"3e3064f11b97177a1e1ce3c77ecf32850343aba1"."color" AS "3e3064f11b97177a1e1ce3c77ecf32850343aba1_color",
"3e3064f11b97177a1e1ce3c77ecf32850343aba1"."parentId" AS "3e3064f11b97177a1e1ce3c77ecf32850343aba1_parentId",
"3e3064f11b97177a1e1ce3c77ecf32850343aba1"."userId" AS "3e3064f11b97177a1e1ce3c77ecf32850343aba1_userId"
FROM
"asset_stack" "StackEntity"
LEFT JOIN "assets" "StackEntity__StackEntity_assets" ON "StackEntity__StackEntity_assets"."stackId" = "StackEntity"."id"
AND (
"StackEntity__StackEntity_assets"."deletedAt" IS NULL
)
LEFT JOIN "exif" "01db479afeb88793eed8e0d1dde6ccfccf1698b9" ON "01db479afeb88793eed8e0d1dde6ccfccf1698b9"."assetId" = "StackEntity__StackEntity_assets"."id"
LEFT JOIN "tag_asset" "4f1c9474d4596aede2814ee2eb938eecf7a93b95" ON "4f1c9474d4596aede2814ee2eb938eecf7a93b95"."assetsId" = "StackEntity__StackEntity_assets"."id"
LEFT JOIN "tags" "3e3064f11b97177a1e1ce3c77ecf32850343aba1" ON "3e3064f11b97177a1e1ce3c77ecf32850343aba1"."id" = "4f1c9474d4596aede2814ee2eb938eecf7a93b95"."tagsId"
WHERE
(("StackEntity"."id" = $1))
) "distinctAlias"
ORDER BY
"distinctAlias"."StackEntity__StackEntity_assets_fileCreatedAt" ASC,
"StackEntity_id" ASC
LIMIT
1

View File

@ -1,33 +1,18 @@
import { Injectable } from '@nestjs/common';
import { Kysely, sql } from 'kysely'; import { Kysely, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { DB } from 'src/db'; import { DB } from 'src/db';
import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserRole } from 'src/enum'; import { AlbumUserRole } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { asUuid } from 'src/utils/database'; import { asUuid } from 'src/utils/database';
type IActivityAccess = IAccessRepository['activity']; class ActivityAccess {
type IAlbumAccess = IAccessRepository['album'];
type IAssetAccess = IAccessRepository['asset'];
type IAuthDeviceAccess = IAccessRepository['authDevice'];
type IMemoryAccess = IAccessRepository['memory'];
type IPersonAccess = IAccessRepository['person'];
type IPartnerAccess = IAccessRepository['partner'];
type IStackAccess = IAccessRepository['stack'];
type ITagAccess = IAccessRepository['tag'];
type ITimelineAccess = IAccessRepository['timeline'];
@Injectable()
class ActivityAccess implements IActivityAccess {
constructor(private db: Kysely<DB>) {} constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, activityIds: Set<string>): Promise<Set<string>> { async checkOwnerAccess(userId: string, activityIds: Set<string>) {
if (activityIds.size === 0) { if (activityIds.size === 0) {
return new Set(); return new Set<string>();
} }
return this.db return this.db
@ -36,17 +21,14 @@ class ActivityAccess implements IActivityAccess {
.where('activity.id', 'in', [...activityIds]) .where('activity.id', 'in', [...activityIds])
.where('activity.userId', '=', userId) .where('activity.userId', '=', userId)
.execute() .execute()
.then((activities) => { .then((activities) => new Set(activities.map((activity) => activity.id)));
console.log('activities', activities);
return new Set(activities.map((activity) => activity.id));
});
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkAlbumOwnerAccess(userId: string, activityIds: Set<string>): Promise<Set<string>> { async checkAlbumOwnerAccess(userId: string, activityIds: Set<string>) {
if (activityIds.size === 0) { if (activityIds.size === 0) {
return new Set(); return new Set<string>();
} }
return this.db return this.db
@ -61,9 +43,9 @@ class ActivityAccess implements IActivityAccess {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkCreateAccess(userId: string, albumIds: Set<string>): Promise<Set<string>> { async checkCreateAccess(userId: string, albumIds: Set<string>) {
if (albumIds.size === 0) { if (albumIds.size === 0) {
return new Set(); return new Set<string>();
} }
return this.db return this.db
@ -80,14 +62,14 @@ class ActivityAccess implements IActivityAccess {
} }
} }
class AlbumAccess implements IAlbumAccess { class AlbumAccess {
constructor(private db: Kysely<DB>) {} constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, albumIds: Set<string>): Promise<Set<string>> { async checkOwnerAccess(userId: string, albumIds: Set<string>) {
if (albumIds.size === 0) { if (albumIds.size === 0) {
return new Set(); return new Set<string>();
} }
return this.db return this.db
@ -102,9 +84,9 @@ class AlbumAccess implements IAlbumAccess {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkSharedAlbumAccess(userId: string, albumIds: Set<string>, access: AlbumUserRole): Promise<Set<string>> { async checkSharedAlbumAccess(userId: string, albumIds: Set<string>, access: AlbumUserRole) {
if (albumIds.size === 0) { if (albumIds.size === 0) {
return new Set(); return new Set<string>();
} }
const accessRole = const accessRole =
@ -125,9 +107,9 @@ class AlbumAccess implements IAlbumAccess {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkSharedLinkAccess(sharedLinkId: string, albumIds: Set<string>): Promise<Set<string>> { async checkSharedLinkAccess(sharedLinkId: string, albumIds: Set<string>) {
if (albumIds.size === 0) { if (albumIds.size === 0) {
return new Set(); return new Set<string>();
} }
return this.db return this.db
@ -142,14 +124,14 @@ class AlbumAccess implements IAlbumAccess {
} }
} }
class AssetAccess implements IAssetAccess { class AssetAccess {
constructor(private db: Kysely<DB>) {} constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkAlbumAccess(userId: string, assetIds: Set<string>): Promise<Set<string>> { async checkAlbumAccess(userId: string, assetIds: Set<string>) {
if (assetIds.size === 0) { if (assetIds.size === 0) {
return new Set(); return new Set<string>();
} }
return this.db return this.db
@ -185,9 +167,9 @@ class AssetAccess implements IAssetAccess {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, assetIds: Set<string>): Promise<Set<string>> { async checkOwnerAccess(userId: string, assetIds: Set<string>) {
if (assetIds.size === 0) { if (assetIds.size === 0) {
return new Set(); return new Set<string>();
} }
return this.db return this.db
@ -201,9 +183,9 @@ class AssetAccess implements IAssetAccess {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkPartnerAccess(userId: string, assetIds: Set<string>): Promise<Set<string>> { async checkPartnerAccess(userId: string, assetIds: Set<string>) {
if (assetIds.size === 0) { if (assetIds.size === 0) {
return new Set(); return new Set<string>();
} }
return this.db return this.db
@ -224,9 +206,9 @@ class AssetAccess implements IAssetAccess {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkSharedLinkAccess(sharedLinkId: string, assetIds: Set<string>): Promise<Set<string>> { async checkSharedLinkAccess(sharedLinkId: string, assetIds: Set<string>) {
if (assetIds.size === 0) { if (assetIds.size === 0) {
return new Set(); return new Set<string>();
} }
return this.db return this.db
@ -276,14 +258,14 @@ class AssetAccess implements IAssetAccess {
} }
} }
class AuthDeviceAccess implements IAuthDeviceAccess { class AuthDeviceAccess {
constructor(private db: Kysely<DB>) {} constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, deviceIds: Set<string>): Promise<Set<string>> { async checkOwnerAccess(userId: string, deviceIds: Set<string>) {
if (deviceIds.size === 0) { if (deviceIds.size === 0) {
return new Set(); return new Set<string>();
} }
return this.db return this.db
@ -296,14 +278,14 @@ class AuthDeviceAccess implements IAuthDeviceAccess {
} }
} }
class StackAccess implements IStackAccess { class StackAccess {
constructor(private db: Kysely<DB>) {} constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, stackIds: Set<string>): Promise<Set<string>> { async checkOwnerAccess(userId: string, stackIds: Set<string>) {
if (stackIds.size === 0) { if (stackIds.size === 0) {
return new Set(); return new Set<string>();
} }
return this.db return this.db
@ -316,14 +298,14 @@ class StackAccess implements IStackAccess {
} }
} }
class TimelineAccess implements ITimelineAccess { class TimelineAccess {
constructor(private db: Kysely<DB>) {} constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>> { async checkPartnerAccess(userId: string, partnerIds: Set<string>) {
if (partnerIds.size === 0) { if (partnerIds.size === 0) {
return new Set(); return new Set<string>();
} }
return this.db return this.db
@ -336,14 +318,14 @@ class TimelineAccess implements ITimelineAccess {
} }
} }
class MemoryAccess implements IMemoryAccess { class MemoryAccess {
constructor(private db: Kysely<DB>) {} constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, memoryIds: Set<string>): Promise<Set<string>> { async checkOwnerAccess(userId: string, memoryIds: Set<string>) {
if (memoryIds.size === 0) { if (memoryIds.size === 0) {
return new Set(); return new Set<string>();
} }
return this.db return this.db
@ -357,14 +339,14 @@ class MemoryAccess implements IMemoryAccess {
} }
} }
class PersonAccess implements IPersonAccess { class PersonAccess {
constructor(private db: Kysely<DB>) {} constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, personIds: Set<string>): Promise<Set<string>> { async checkOwnerAccess(userId: string, personIds: Set<string>) {
if (personIds.size === 0) { if (personIds.size === 0) {
return new Set(); return new Set<string>();
} }
return this.db return this.db
@ -378,9 +360,9 @@ class PersonAccess implements IPersonAccess {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkFaceOwnerAccess(userId: string, assetFaceIds: Set<string>): Promise<Set<string>> { async checkFaceOwnerAccess(userId: string, assetFaceIds: Set<string>) {
if (assetFaceIds.size === 0) { if (assetFaceIds.size === 0) {
return new Set(); return new Set<string>();
} }
return this.db return this.db
@ -396,14 +378,14 @@ class PersonAccess implements IPersonAccess {
} }
} }
class PartnerAccess implements IPartnerAccess { class PartnerAccess {
constructor(private db: Kysely<DB>) {} constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkUpdateAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>> { async checkUpdateAccess(userId: string, partnerIds: Set<string>) {
if (partnerIds.size === 0) { if (partnerIds.size === 0) {
return new Set(); return new Set<string>();
} }
return this.db return this.db
@ -416,14 +398,14 @@ class PartnerAccess implements IPartnerAccess {
} }
} }
class TagAccess implements ITagAccess { class TagAccess {
constructor(private db: Kysely<DB>) {} constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, tagIds: Set<string>): Promise<Set<string>> { async checkOwnerAccess(userId: string, tagIds: Set<string>) {
if (tagIds.size === 0) { if (tagIds.size === 0) {
return new Set(); return new Set<string>();
} }
return this.db return this.db
@ -436,17 +418,17 @@ class TagAccess implements ITagAccess {
} }
} }
export class AccessRepository implements IAccessRepository { export class AccessRepository {
activity: IActivityAccess; activity: ActivityAccess;
album: IAlbumAccess; album: AlbumAccess;
asset: IAssetAccess; asset: AssetAccess;
authDevice: IAuthDeviceAccess; authDevice: AuthDeviceAccess;
memory: IMemoryAccess; memory: MemoryAccess;
person: IPersonAccess; person: PersonAccess;
partner: IPartnerAccess; partner: PartnerAccess;
stack: IStackAccess; stack: StackAccess;
tag: ITagAccess; tag: TagAccess;
timeline: ITimelineAccess; timeline: TimelineAccess;
constructor(@InjectKysely() db: Kysely<DB>) { constructor(@InjectKysely() db: Kysely<DB>) {
this.activity = new ActivityAccess(db); this.activity = new ActivityAccess(db);

View File

@ -1,72 +1,116 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { Albums, DB } from 'src/db';
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserCreateDto } from 'src/dtos/album.dto';
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface'; import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface';
import { import { Repository } from 'typeorm';
DataSource,
EntityManager,
FindOptionsOrder,
FindOptionsRelations,
In,
IsNull,
Not,
Repository,
} from 'typeorm';
const withoutDeletedUsers = <T extends AlbumEntity | null>(album: T) => { const userColumns = [
if (album) { 'id',
album.albumUsers = album.albumUsers.filter((albumUser) => albumUser.user && !albumUser.user.deletedAt); 'email',
} 'createdAt',
return album; 'profileImagePath',
'isAdmin',
'shouldChangePassword',
'deletedAt',
'oauthId',
'updatedAt',
'storageLabel',
'name',
'quotaSizeInBytes',
'quotaUsageInBytes',
'status',
'profileChangedAt',
] as const;
const withOwner = (eb: ExpressionBuilder<DB, 'albums'>) => {
return jsonObjectFrom(eb.selectFrom('users').select(userColumns).whereRef('users.id', '=', 'albums.ownerId')).as(
'owner',
);
};
const withAlbumUsers = (eb: ExpressionBuilder<DB, 'albums'>) => {
return jsonArrayFrom(
eb
.selectFrom('albums_shared_users_users as album_users')
.selectAll('album_users')
.select((eb) =>
jsonObjectFrom(eb.selectFrom('users').select(userColumns).whereRef('users.id', '=', 'album_users.usersId')).as(
'user',
),
)
.whereRef('album_users.albumsId', '=', 'albums.id'),
).as('albumUsers');
};
const withSharedLink = (eb: ExpressionBuilder<DB, 'albums'>) => {
return jsonArrayFrom(eb.selectFrom('shared_links').selectAll().whereRef('shared_links.albumId', '=', 'albums.id')).as(
'sharedLinks',
);
};
const withAssets = (eb: ExpressionBuilder<DB, 'albums'>) => {
return eb
.selectFrom((eb) =>
eb
.selectFrom('assets')
.selectAll('assets')
.innerJoin('exif', 'assets.id', 'exif.assetId')
.select((eb) => eb.fn.toJson('exif').as('exifInfo'))
.innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
.whereRef('albums_assets_assets.albumsId', '=', 'albums.id')
.orderBy('assets.fileCreatedAt', 'desc')
.as('asset'),
)
.select((eb) => eb.fn.jsonAgg('asset').as('assets'))
.as('assets');
}; };
@Injectable() @Injectable()
export class AlbumRepository implements IAlbumRepository { export class AlbumRepository implements IAlbumRepository {
constructor( constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(AlbumEntity) private repository: Repository<AlbumEntity>, @InjectRepository(AlbumEntity) private repository: Repository<AlbumEntity>,
@InjectDataSource() private dataSource: DataSource, @InjectKysely() private db: Kysely<DB>,
) {} ) {}
@GenerateSql({ params: [DummyValue.UUID, {}] }) @GenerateSql({ params: [DummyValue.UUID, {}] })
async getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null> { async getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | undefined> {
const relations: FindOptionsRelations<AlbumEntity> = { return this.db
owner: true, .selectFrom('albums')
albumUsers: { user: true }, .selectAll('albums')
assets: false, .where('albums.id', '=', id)
sharedLinks: true, .where('albums.deletedAt', 'is', null)
}; .select(withOwner)
.select(withAlbumUsers)
const order: FindOptionsOrder<AlbumEntity> = {}; .select(withSharedLink)
.$if(options.withAssets, (eb) => eb.select(withAssets))
if (options.withAssets) { .executeTakeFirst() as Promise<AlbumEntity | undefined>;
relations.assets = {
exifInfo: true,
};
order.assets = {
fileCreatedAt: 'DESC',
};
}
const album = await this.repository.findOne({ where: { id }, relations, order });
return withoutDeletedUsers(album);
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]> { async getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]> {
const albums = await this.repository.find({ return this.db
where: [ .selectFrom('albums')
{ ownerId, assets: { id: assetId } }, .selectAll('albums')
{ albumUsers: { userId: ownerId }, assets: { id: assetId } }, .leftJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
], .leftJoin('albums_shared_users_users as album_users', 'album_users.albumsId', 'albums.id')
relations: { owner: true, albumUsers: { user: true } }, .where((eb) =>
order: { createdAt: 'DESC' }, eb.or([
}); eb.and([eb('albums.ownerId', '=', ownerId), eb('album_assets.assetsId', '=', assetId)]),
eb.and([eb('album_users.usersId', '=', ownerId), eb('album_assets.assetsId', '=', assetId)]),
return albums.map((album) => withoutDeletedUsers(album)); ]),
)
.where('albums.deletedAt', 'is', null)
.orderBy('albums.createdAt', 'desc')
.select(withOwner)
.select(withAlbumUsers)
.orderBy('albums.createdAt', 'desc')
.execute() as unknown as Promise<AlbumEntity[]>;
} }
@GenerateSql({ params: [[DummyValue.UUID]] }) @GenerateSql({ params: [[DummyValue.UUID]] })
@ -77,36 +121,38 @@ export class AlbumRepository implements IAlbumRepository {
return []; return [];
} }
// Only possible with query builder because of GROUP BY. const metadatas = await this.db
const albumMetadatas = await this.repository .selectFrom('albums')
.createQueryBuilder('album') .leftJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
.select('album.id') .leftJoin('assets', 'assets.id', 'album_assets.assetsId')
.addSelect('MIN(assets.fileCreatedAt)', 'start_date') .select('albums.id')
.addSelect('MAX(assets.fileCreatedAt)', 'end_date') .select((eb) => eb.fn.min('assets.fileCreatedAt').as('startDate'))
.addSelect('COUNT(assets.id)', 'asset_count') .select((eb) => eb.fn.max('assets.fileCreatedAt').as('endDate'))
.leftJoin('albums_assets_assets', 'album_assets', 'album_assets.albumsId = album.id') .select((eb) => eb.fn.count('assets.id').as('assetCount'))
.leftJoin('assets', 'assets', 'assets.id = album_assets.assetsId') .where('albums.id', 'in', ids)
.where('album.id IN (:...ids)', { ids }) .groupBy('albums.id')
.groupBy('album.id') .execute();
.getRawMany();
return albumMetadatas.map<AlbumAssetCount>((metadatas) => ({ return metadatas.map((metadatas) => ({
albumId: metadatas['album_id'], albumId: metadatas.id,
assetCount: Number(metadatas['asset_count']), assetCount: Number(metadatas.assetCount),
startDate: metadatas['end_date'] ? new Date(metadatas['start_date']) : undefined, startDate: metadatas.startDate ? new Date(metadatas.startDate) : undefined,
endDate: metadatas['end_date'] ? new Date(metadatas['end_date']) : undefined, endDate: metadatas.endDate ? new Date(metadatas.endDate) : undefined,
})); }));
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getOwned(ownerId: string): Promise<AlbumEntity[]> { async getOwned(ownerId: string): Promise<AlbumEntity[]> {
const albums = await this.repository.find({ return this.db
relations: { albumUsers: { user: true }, sharedLinks: true, owner: true }, .selectFrom('albums')
where: { ownerId }, .selectAll('albums')
order: { createdAt: 'DESC' }, .select(withOwner)
}); .select(withAlbumUsers)
.select(withSharedLink)
return albums.map((album) => withoutDeletedUsers(album)); .where('albums.ownerId', '=', ownerId)
.where('albums.deletedAt', 'is', null)
.orderBy('albums.createdAt', 'desc')
.execute() as unknown as Promise<AlbumEntity[]>;
} }
/** /**
@ -114,17 +160,25 @@ export class AlbumRepository implements IAlbumRepository {
*/ */
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getShared(ownerId: string): Promise<AlbumEntity[]> { async getShared(ownerId: string): Promise<AlbumEntity[]> {
const albums = await this.repository.find({ return this.db
relations: { albumUsers: { user: true }, sharedLinks: true, owner: true }, .selectFrom('albums')
where: [ .selectAll('albums')
{ albumUsers: { userId: ownerId } }, .distinctOn('albums.createdAt')
{ sharedLinks: { userId: ownerId } }, .leftJoin('albums_shared_users_users as shared_albums', 'shared_albums.albumsId', 'albums.id')
{ ownerId, albumUsers: { user: Not(IsNull()) } }, .leftJoin('shared_links', 'shared_links.albumId', 'albums.id')
], .where((eb) =>
order: { createdAt: 'DESC' }, eb.or([
}); eb('shared_albums.usersId', '=', ownerId),
eb('shared_links.userId', '=', ownerId),
return albums.map((album) => withoutDeletedUsers(album)); eb.and([eb('albums.ownerId', '=', ownerId), eb('shared_albums.usersId', 'is not', null)]),
]),
)
.where('albums.deletedAt', 'is', null)
.select(withAlbumUsers)
.select(withOwner)
.select(withSharedLink)
.orderBy('albums.createdAt', 'desc')
.execute() as unknown as Promise<AlbumEntity[]>;
} }
/** /**
@ -132,35 +186,37 @@ export class AlbumRepository implements IAlbumRepository {
*/ */
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getNotShared(ownerId: string): Promise<AlbumEntity[]> { async getNotShared(ownerId: string): Promise<AlbumEntity[]> {
const albums = await this.repository.find({ return this.db
relations: { albumUsers: true, sharedLinks: true, owner: true }, .selectFrom('albums')
where: { ownerId, albumUsers: { user: IsNull() }, sharedLinks: { id: IsNull() } }, .selectAll('albums')
order: { createdAt: 'DESC' }, .distinctOn('albums.createdAt')
}); .leftJoin('albums_shared_users_users as shared_albums', 'shared_albums.albumsId', 'albums.id')
.leftJoin('shared_links', 'shared_links.albumId', 'albums.id')
return albums.map((album) => withoutDeletedUsers(album)); .where('albums.ownerId', '=', ownerId)
.where('shared_albums.usersId', 'is', null)
.where('shared_links.userId', 'is', null)
.where('albums.deletedAt', 'is', null)
.select(withAlbumUsers)
.select(withOwner)
.select(withSharedLink)
.orderBy('albums.createdAt', 'desc')
.execute() as unknown as Promise<AlbumEntity[]>;
} }
async restoreAll(userId: string): Promise<void> { async restoreAll(userId: string): Promise<void> {
await this.repository.restore({ ownerId: userId }); await this.db.updateTable('albums').set({ deletedAt: null }).where('ownerId', '=', userId).execute();
} }
async softDeleteAll(userId: string): Promise<void> { async softDeleteAll(userId: string): Promise<void> {
await this.repository.softDelete({ ownerId: userId }); await this.db.updateTable('albums').set({ deletedAt: new Date() }).where('ownerId', '=', userId).execute();
} }
async deleteAll(userId: string): Promise<void> { async deleteAll(userId: string): Promise<void> {
await this.repository.delete({ ownerId: userId }); await this.db.deleteFrom('albums').where('ownerId', '=', userId).execute();
} }
async removeAsset(assetId: string): Promise<void> { async removeAsset(assetId: string): Promise<void> {
// Using dataSource, because there is no direct access to albums_assets_assets. await this.db.deleteFrom('albums_assets_assets').where('albums_assets_assets.assetsId', '=', assetId).execute();
await this.dataSource
.createQueryBuilder()
.delete()
.from('albums_assets_assets')
.where('"albums_assets_assets"."assetsId" = :assetId', { assetId })
.execute();
} }
@Chunked({ paramIndex: 1 }) @Chunked({ paramIndex: 1 })
@ -169,14 +225,10 @@ export class AlbumRepository implements IAlbumRepository {
return; return;
} }
await this.dataSource await this.db
.createQueryBuilder() .deleteFrom('albums_assets_assets')
.delete() .where('albums_assets_assets.albumsId', '=', albumId)
.from('albums_assets_assets') .where('albums_assets_assets.assetsId', 'in', assetIds)
.where({
albumsId: albumId,
assetsId: In(assetIds),
})
.execute(); .execute();
} }
@ -194,73 +246,80 @@ export class AlbumRepository implements IAlbumRepository {
return new Set(); return new Set();
} }
const results = await this.dataSource return this.db
.createQueryBuilder() .selectFrom('albums_assets_assets')
.select('albums_assets.assetsId', 'assetId') .selectAll()
.from('albums_assets_assets', 'albums_assets') .where('albums_assets_assets.albumsId', '=', albumId)
.where('"albums_assets"."albumsId" = :albumId', { albumId }) .where('albums_assets_assets.assetsId', 'in', assetIds)
.andWhere('"albums_assets"."assetsId" IN (:...assetIds)', { assetIds }) .execute()
.getRawMany<{ assetId: string }>(); .then((results) => new Set(results.map(({ assetsId }) => assetsId)));
return new Set(results.map(({ assetId }) => assetId));
} }
async addAssetIds(albumId: string, assetIds: string[]): Promise<void> { async addAssetIds(albumId: string, assetIds: string[]): Promise<void> {
await this.addAssets(this.dataSource.manager, albumId, assetIds); await this.addAssets(this.db, albumId, assetIds);
} }
create(album: Partial<AlbumEntity>): Promise<AlbumEntity> { create(album: Insertable<Albums>, assetIds: string[], albumUsers: AlbumUserCreateDto[]): Promise<AlbumEntity> {
return this.dataSource.transaction<AlbumEntity>(async (manager) => { return this.db.transaction().execute(async (tx) => {
const { id } = await manager.save(AlbumEntity, { ...album, assets: [] }); const newAlbum = await tx.insertInto('albums').values(album).returning('albums.id').executeTakeFirst();
const assetIds = (album.assets || []).map((asset) => asset.id);
await this.addAssets(manager, id, assetIds); if (!newAlbum) {
return manager.findOneOrFail(AlbumEntity, { throw new Error('Failed to create album');
where: { id }, }
relations: {
owner: true, if (assetIds.length > 0) {
albumUsers: { user: true }, await this.addAssets(tx, newAlbum.id, assetIds);
sharedLinks: true, }
assets: true,
}, if (albumUsers.length > 0) {
}); await tx
.insertInto('albums_shared_users_users')
.values(
albumUsers.map((albumUser) => ({ albumsId: newAlbum.id, usersId: albumUser.userId, role: albumUser.role })),
)
.execute();
}
return tx
.selectFrom('albums')
.selectAll()
.where('id', '=', newAlbum.id)
.select(withOwner)
.select(withSharedLink)
.select(withAssets)
.select(withAlbumUsers)
.executeTakeFirst() as unknown as Promise<AlbumEntity>;
}); });
} }
update(album: Partial<AlbumEntity>): Promise<AlbumEntity> { update(id: string, album: Updateable<Albums>): Promise<AlbumEntity> {
return this.save(album); return this.db
.updateTable('albums')
.set({ ...album, updatedAt: new Date() })
.where('id', '=', id)
.returningAll('albums')
.returning(withOwner)
.returning(withSharedLink)
.returning(withAlbumUsers)
.executeTakeFirst() as unknown as Promise<AlbumEntity>;
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
await this.repository.delete({ id }); await this.db.deleteFrom('albums').where('id', '=', id).execute();
} }
@Chunked({ paramIndex: 2, chunkSize: 30_000 }) @Chunked({ paramIndex: 2, chunkSize: 30_000 })
private async addAssets(manager: EntityManager, albumId: string, assetIds: string[]): Promise<void> { private async addAssets(db: Kysely<DB>, albumId: string, assetIds: string[]): Promise<void> {
if (assetIds.length === 0) { if (assetIds.length === 0) {
return; return;
} }
await manager await db
.createQueryBuilder() .insertInto('albums_assets_assets')
.insert()
.into('albums_assets_assets', ['albumsId', 'assetsId'])
.values(assetIds.map((assetId) => ({ albumsId: albumId, assetsId: assetId }))) .values(assetIds.map((assetId) => ({ albumsId: albumId, assetsId: assetId })))
.execute(); .execute();
} }
private async save(album: Partial<AlbumEntity>) {
const { id } = await this.repository.save(album);
return this.repository.findOneOrFail({
where: { id },
relations: {
owner: true,
albumUsers: { user: true },
sharedLinks: true,
assets: true,
},
});
}
/** /**
* Makes sure all thumbnails for albums are updated by: * Makes sure all thumbnails for albums are updated by:
* - Removing thumbnails from albums without assets * - Removing thumbnails from albums without assets
@ -272,28 +331,44 @@ export class AlbumRepository implements IAlbumRepository {
async updateThumbnails(): Promise<number | undefined> { async updateThumbnails(): Promise<number | undefined> {
// Subquery for getting a new thumbnail. // Subquery for getting a new thumbnail.
const builder = this.dataSource const result = await this.db
.createQueryBuilder('albums_assets_assets', 'album_assets') .updateTable('albums')
.innerJoin('assets', 'assets', '"album_assets"."assetsId" = "assets"."id"') .set((eb) => ({
.where('"album_assets"."albumsId" = "albums"."id"'); albumThumbnailAssetId: this.updateThumbnailBuilder(eb)
.select('album_assets.assetsId')
.orderBy('assets.fileCreatedAt', 'desc')
.limit(1),
updatedAt: new Date(),
}))
.where((eb) =>
eb.or([
eb.and([
eb('albumThumbnailAssetId', 'is', null),
eb.exists(this.updateThumbnailBuilder(eb).select(sql`1`.as('1'))), // Has assets
]),
eb.and([
eb('albumThumbnailAssetId', 'is not', null),
eb.not(
eb.exists(
this.updateThumbnailBuilder(eb)
.select(sql`1`.as('1'))
.whereRef('albums.albumThumbnailAssetId', '=', 'album_assets.assetsId'), // Has invalid assets
),
),
]),
]),
)
.execute();
const newThumbnail = builder return Number(result[0].numUpdatedRows);
.clone() }
.select('"album_assets"."assetsId"')
.orderBy('"assets"."fileCreatedAt"', 'DESC')
.limit(1);
const hasAssets = builder.clone().select('1');
const hasInvalidAsset = hasAssets.clone().andWhere('"albums"."albumThumbnailAssetId" = "album_assets"."assetsId"');
const updateAlbums = this.repository private updateThumbnailBuilder(eb: ExpressionBuilder<DB, 'albums'>) {
.createQueryBuilder('albums') return eb
.update(AlbumEntity) .selectFrom('albums_assets_assets as album_assets')
.set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` }) .innerJoin('assets', (join) =>
.where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${hasAssets.getQuery()})`) join.onRef('album_assets.assetsId', '=', 'assets.id').on('assets.deletedAt', 'is', null),
.orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${hasInvalidAsset.getQuery()})`); )
.whereRef('album_assets.albumsId', '=', 'albums.id');
const result = await updateAlbums.execute();
return result.affected;
} }
} }

View File

@ -1,50 +1,36 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Insertable, Kysely, Updateable } from 'kysely'; import { Insertable, Kysely, Updateable } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { ApiKeys, DB } from 'src/db'; import { ApiKeys, DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { APIKeyEntity } from 'src/entities/api-key.entity';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { AuthApiKey } from 'src/types';
import { asUuid } from 'src/utils/database'; import { asUuid } from 'src/utils/database';
import { Repository } from 'typeorm';
const columns = ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'] as const; const columns = ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'] as const;
@Injectable() @Injectable()
export class ApiKeyRepository implements IKeyRepository { export class ApiKeyRepository {
constructor( constructor(@InjectKysely() private db: Kysely<DB>) {}
@InjectRepository(APIKeyEntity) private repository: Repository<APIKeyEntity>,
@InjectKysely() private db: Kysely<DB>,
) {}
async create(dto: Insertable<ApiKeys>): Promise<APIKeyEntity> { create(dto: Insertable<ApiKeys>) {
const { id, name, createdAt, updatedAt, permissions } = await this.db return this.db.insertInto('api_keys').values(dto).returningAll().executeTakeFirstOrThrow();
.insertInto('api_keys')
.values(dto)
.returningAll()
.executeTakeFirstOrThrow();
return { id, name, createdAt, updatedAt, permissions } as APIKeyEntity;
} }
async update(userId: string, id: string, dto: Updateable<ApiKeys>): Promise<APIKeyEntity> { async update(userId: string, id: string, dto: Updateable<ApiKeys>) {
return this.db return this.db
.updateTable('api_keys') .updateTable('api_keys')
.set(dto) .set(dto)
.where('api_keys.userId', '=', userId) .where('api_keys.userId', '=', userId)
.where('id', '=', asUuid(id)) .where('id', '=', asUuid(id))
.returningAll() .returningAll()
.executeTakeFirstOrThrow() as unknown as Promise<APIKeyEntity>; .executeTakeFirstOrThrow();
} }
async delete(userId: string, id: string): Promise<void> { async delete(userId: string, id: string) {
await this.db.deleteFrom('api_keys').where('userId', '=', userId).where('id', '=', asUuid(id)).execute(); await this.db.deleteFrom('api_keys').where('userId', '=', userId).where('id', '=', asUuid(id)).execute();
} }
@GenerateSql({ params: [DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.STRING] })
getKey(hashedToken: string): Promise<AuthApiKey | undefined> { getKey(hashedToken: string) {
return this.db return this.db
.selectFrom('api_keys') .selectFrom('api_keys')
.innerJoinLateral( .innerJoinLateral(
@ -72,26 +58,26 @@ export class ApiKeyRepository implements IKeyRepository {
eb.fn.toJson('user').as('user'), eb.fn.toJson('user').as('user'),
]) ])
.where('api_keys.key', '=', hashedToken) .where('api_keys.key', '=', hashedToken)
.executeTakeFirst() as Promise<AuthApiKey | undefined>; .executeTakeFirst();
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getById(userId: string, id: string): Promise<APIKeyEntity | null> { getById(userId: string, id: string) {
return this.db return this.db
.selectFrom('api_keys') .selectFrom('api_keys')
.select(columns) .select(columns)
.where('id', '=', asUuid(id)) .where('id', '=', asUuid(id))
.where('userId', '=', userId) .where('userId', '=', userId)
.executeTakeFirst() as unknown as Promise<APIKeyEntity | null>; .executeTakeFirst();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getByUserId(userId: string): Promise<APIKeyEntity[]> { getByUserId(userId: string) {
return this.db return this.db
.selectFrom('api_keys') .selectFrom('api_keys')
.select(columns) .select(columns)
.where('userId', '=', userId) .where('userId', '=', userId)
.orderBy('createdAt', 'desc') .orderBy('createdAt', 'desc')
.execute() as unknown as Promise<APIKeyEntity[]>; .execute();
} }
} }

View File

@ -4,10 +4,15 @@ import { InjectKysely } from 'nestjs-kysely';
import { DB } from 'src/db'; import { DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { DatabaseAction, EntityType } from 'src/enum'; import { DatabaseAction, EntityType } from 'src/enum';
import { AuditSearch, IAuditRepository } from 'src/interfaces/audit.interface';
export interface AuditSearch {
action?: DatabaseAction;
entityType?: EntityType;
userIds: string[];
}
@Injectable() @Injectable()
export class AuditRepository implements IAuditRepository { export class AuditRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ @GenerateSql({

View File

@ -1,19 +1,106 @@
import { RegisterQueueOptions } from '@nestjs/bullmq';
import { Inject, Injectable, Optional } from '@nestjs/common'; import { Inject, Injectable, Optional } from '@nestjs/common';
import { QueueOptions } from 'bullmq';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator'; import { validateSync } from 'class-validator';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { RedisOptions } from 'ioredis';
import { KyselyConfig } from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js'; import { PostgresJSDialect } from 'kysely-postgres-js';
import { CLS_ID } from 'nestjs-cls'; import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import postgres, { Notice } from 'postgres'; import postgres, { Notice } from 'postgres';
import { citiesFile, excludePaths, IWorker } from 'src/constants'; import { citiesFile, excludePaths, IWorker } from 'src/constants';
import { Telemetry } from 'src/decorators'; import { Telemetry } from 'src/decorators';
import { EnvDto } from 'src/dtos/env.dto'; import { EnvDto } from 'src/dtos/env.dto';
import { ImmichEnvironment, ImmichHeader, ImmichTelemetry, ImmichWorker } from 'src/enum'; import { ImmichEnvironment, ImmichHeader, ImmichTelemetry, ImmichWorker, LogLevel } from 'src/enum';
import { EnvData, IConfigRepository } from 'src/interfaces/config.interface'; import { DatabaseConnectionParams, DatabaseExtension, VectorExtension } from 'src/interfaces/database.interface';
import { DatabaseExtension } from 'src/interfaces/database.interface';
import { QueueName } from 'src/interfaces/job.interface'; import { QueueName } from 'src/interfaces/job.interface';
import { setDifference } from 'src/utils/set'; import { setDifference } from 'src/utils/set';
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
export interface EnvData {
host?: string;
port: number;
environment: ImmichEnvironment;
configFile?: string;
logLevel?: LogLevel;
buildMetadata: {
build?: string;
buildUrl?: string;
buildImage?: string;
buildImageUrl?: string;
repository?: string;
repositoryUrl?: string;
sourceRef?: string;
sourceCommit?: string;
sourceUrl?: string;
thirdPartySourceUrl?: string;
thirdPartyBugFeatureUrl?: string;
thirdPartyDocumentationUrl?: string;
thirdPartySupportUrl?: string;
};
bull: {
config: QueueOptions;
queues: RegisterQueueOptions[];
};
cls: {
config: ClsModuleOptions;
};
database: {
config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: KyselyConfig };
skipMigrations: boolean;
vectorExtension: VectorExtension;
};
licensePublicKey: {
client: string;
server: string;
};
network: {
trustedProxies: string[];
};
otel: OpenTelemetryModuleOptions;
resourcePaths: {
lockFile: string;
geodata: {
dateFile: string;
admin1: string;
admin2: string;
cities500: string;
naturalEarthCountriesPath: string;
};
web: {
root: string;
indexHtml: string;
};
};
redis: RedisOptions;
telemetry: {
apiPort: number;
microservicesPort: number;
metrics: Set<ImmichTelemetry>;
};
storage: {
ignoreMountCheckErrors: boolean;
};
workers: ImmichWorker[];
noColor: boolean;
nodeVersion?: string;
}
const productionKeys = { const productionKeys = {
client: client:
@ -269,10 +356,10 @@ let cached: EnvData | undefined;
@Injectable() @Injectable()
@Telemetry({ enabled: false }) @Telemetry({ enabled: false })
export class ConfigRepository implements IConfigRepository { export class ConfigRepository {
constructor(@Inject(IWorker) @Optional() private worker?: ImmichWorker) {} constructor(@Inject(IWorker) @Optional() private worker?: ImmichWorker) {}
getEnv(): EnvData { getEnv() {
if (!cached) { if (!cached) {
cached = getEnv(); cached = getEnv();
} }

View File

@ -6,7 +6,6 @@ import { InjectKysely } from 'nestjs-kysely';
import semver from 'semver'; import semver from 'semver';
import { POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants'; import { POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants';
import { DB } from 'src/db'; import { DB } from 'src/db';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { import {
DatabaseExtension, DatabaseExtension,
DatabaseLock, DatabaseLock,
@ -18,6 +17,7 @@ import {
VectorUpdateResult, VectorUpdateResult,
} from 'src/interfaces/database.interface'; } from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ConfigRepository } from 'src/repositories/config.repository';
import { UPSERT_COLUMNS } from 'src/utils/database'; import { UPSERT_COLUMNS } from 'src/utils/database';
import { isValidInteger } from 'src/validation'; import { isValidInteger } from 'src/validation';
import { DataSource, EntityManager, EntityMetadata, QueryRunner } from 'typeorm'; import { DataSource, EntityManager, EntityMetadata, QueryRunner } from 'typeorm';
@ -31,7 +31,7 @@ export class DatabaseRepository implements IDatabaseRepository {
@InjectKysely() private db: Kysely<DB>, @InjectKysely() private db: Kysely<DB>,
@InjectDataSource() private dataSource: DataSource, @InjectDataSource() private dataSource: DataSource,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository, configRepository: ConfigRepository,
) { ) {
this.vectorExtension = configRepository.getEnv().database.vectorExtension; this.vectorExtension = configRepository.getEnv().database.vectorExtension;
this.logger.setContext(DatabaseRepository.name); this.logger.setContext(DatabaseRepository.name);

View File

@ -12,7 +12,6 @@ import _ from 'lodash';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import { EventConfig } from 'src/decorators'; import { EventConfig } from 'src/decorators';
import { ImmichWorker, MetadataKey } from 'src/enum'; import { ImmichWorker, MetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { import {
ArgsOf, ArgsOf,
ClientEventMap, ClientEventMap,
@ -52,7 +51,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
constructor( constructor(
private moduleRef: ModuleRef, private moduleRef: ModuleRef,
@Inject(IConfigRepository) private configRepository: ConfigRepository, private configRepository: ConfigRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.logger.setContext(EventRepository.name); this.logger.setContext(EventRepository.name);

View File

@ -1,10 +1,6 @@
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICronRepository } from 'src/interfaces/cron.interface'; import { ICronRepository } from 'src/interfaces/cron.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface';
@ -35,7 +31,6 @@ import { ITelemetryRepository } from 'src/interfaces/telemetry.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface'; import { ITrashRepository } from 'src/interfaces/trash.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
import { IViewRepository } from 'src/interfaces/view.interface';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository'; import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository';
@ -78,22 +73,23 @@ import { ViewRepository } from 'src/repositories/view-repository';
export const repositories = [ export const repositories = [
// //
AccessRepository,
ActivityRepository, ActivityRepository,
AuditRepository,
ApiKeyRepository,
ConfigRepository,
ViewRepository,
]; ];
export const providers = [ export const providers = [
{ provide: IAccessRepository, useClass: AccessRepository },
{ provide: IAlbumRepository, useClass: AlbumRepository }, { provide: IAlbumRepository, useClass: AlbumRepository },
{ provide: IAlbumUserRepository, useClass: AlbumUserRepository }, { provide: IAlbumUserRepository, useClass: AlbumUserRepository },
{ provide: IAssetRepository, useClass: AssetRepository }, { provide: IAssetRepository, useClass: AssetRepository },
{ provide: IAuditRepository, useClass: AuditRepository },
{ provide: IConfigRepository, useClass: ConfigRepository },
{ provide: ICronRepository, useClass: CronRepository }, { provide: ICronRepository, useClass: CronRepository },
{ provide: ICryptoRepository, useClass: CryptoRepository }, { provide: ICryptoRepository, useClass: CryptoRepository },
{ provide: IDatabaseRepository, useClass: DatabaseRepository }, { provide: IDatabaseRepository, useClass: DatabaseRepository },
{ provide: IEventRepository, useClass: EventRepository }, { provide: IEventRepository, useClass: EventRepository },
{ provide: IJobRepository, useClass: JobRepository }, { provide: IJobRepository, useClass: JobRepository },
{ provide: IKeyRepository, useClass: ApiKeyRepository },
{ provide: ILibraryRepository, useClass: LibraryRepository }, { provide: ILibraryRepository, useClass: LibraryRepository },
{ provide: ILoggerRepository, useClass: LoggerRepository }, { provide: ILoggerRepository, useClass: LoggerRepository },
{ provide: IMachineLearningRepository, useClass: MachineLearningRepository }, { provide: IMachineLearningRepository, useClass: MachineLearningRepository },
@ -119,5 +115,4 @@ export const providers = [
{ provide: ITrashRepository, useClass: TrashRepository }, { provide: ITrashRepository, useClass: TrashRepository },
{ provide: IUserRepository, useClass: UserRepository }, { provide: IUserRepository, useClass: UserRepository },
{ provide: IVersionHistoryRepository, useClass: VersionHistoryRepository }, { provide: IVersionHistoryRepository, useClass: VersionHistoryRepository },
{ provide: IViewRepository, useClass: ViewRepository },
]; ];

View File

@ -1,13 +1,11 @@
import { getQueueToken } from '@nestjs/bullmq'; import { getQueueToken } from '@nestjs/bullmq';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core'; import { ModuleRef, Reflector } from '@nestjs/core';
import { SchedulerRegistry } from '@nestjs/schedule';
import { JobsOptions, Queue, Worker } from 'bullmq'; import { JobsOptions, Queue, Worker } from 'bullmq';
import { ClassConstructor } from 'class-transformer'; import { ClassConstructor } from 'class-transformer';
import { setTimeout } from 'node:timers/promises'; import { setTimeout } from 'node:timers/promises';
import { JobConfig } from 'src/decorators'; import { JobConfig } from 'src/decorators';
import { MetadataKey } from 'src/enum'; import { MetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
import { import {
IEntityJob, IEntityJob,
@ -22,6 +20,7 @@ import {
QueueStatus, QueueStatus,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ConfigRepository } from 'src/repositories/config.repository';
import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc'; import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc';
type JobMapItem = { type JobMapItem = {
@ -38,8 +37,7 @@ export class JobRepository implements IJobRepository {
constructor( constructor(
private moduleRef: ModuleRef, private moduleRef: ModuleRef,
private schedulerRegistry: SchedulerRegistry, private configRepository: ConfigRepository,
@Inject(IConfigRepository) private configRepository: IConfigRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {

View File

@ -1,7 +1,7 @@
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import { ImmichWorker } from 'src/enum'; import { ImmichWorker } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { LoggerRepository } from 'src/repositories/logger.repository'; import { LoggerRepository } from 'src/repositories/logger.repository';
import { IConfigRepository } from 'src/types';
import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';

View File

@ -1,10 +1,10 @@
import { ConsoleLogger, Inject, Injectable, Scope } from '@nestjs/common'; import { ConsoleLogger, Injectable, Scope } from '@nestjs/common';
import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-enabled.util'; import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-enabled.util';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import { Telemetry } from 'src/decorators'; import { Telemetry } from 'src/decorators';
import { LogLevel } from 'src/enum'; import { LogLevel } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ConfigRepository } from 'src/repositories/config.repository';
const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL];
@ -25,7 +25,7 @@ export class LoggerRepository extends ConsoleLogger implements ILoggerRepository
constructor( constructor(
private cls: ClsService, private cls: ClsService,
@Inject(IConfigRepository) configRepository: IConfigRepository, configRepository: ConfigRepository,
) { ) {
super(LoggerRepository.name); super(LoggerRepository.name);

View File

@ -11,7 +11,6 @@ import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db';
import { AssetEntity, withExif } from 'src/entities/asset.entity'; import { AssetEntity, withExif } from 'src/entities/asset.entity';
import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity'; import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity';
import { LogLevel, SystemMetadataKey } from 'src/enum'; import { LogLevel, SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { import {
GeoPoint, GeoPoint,
@ -21,6 +20,7 @@ import {
ReverseGeocodeResult, ReverseGeocodeResult,
} from 'src/interfaces/map.interface'; } from 'src/interfaces/map.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ConfigRepository } from 'src/repositories/config.repository';
interface MapDB extends DB { interface MapDB extends DB {
geodata_places_tmp: GeodataPlaces; geodata_places_tmp: GeodataPlaces;
@ -30,7 +30,7 @@ interface MapDB extends DB {
@Injectable() @Injectable()
export class MapRepository implements IMapRepository { export class MapRepository implements IMapRepository {
constructor( constructor(
@Inject(IConfigRepository) private configRepository: IConfigRepository, private configRepository: ConfigRepository,
@Inject(ISystemMetadataRepository) private metadataRepository: ISystemMetadataRepository, @Inject(ISystemMetadataRepository) private metadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
@InjectKysely() private db: Kysely<MapDB>, @InjectKysely() private db: Kysely<MapDB>,

View File

@ -1,13 +1,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { ExpressionBuilder, Insertable, Kysely, SelectExpression, sql } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import _ from 'lodash'; import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFaces, DB, FaceSearch, Person } from 'src/db';
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { FaceSearchEntity } from 'src/entities/face-search.entity';
import { PersonEntity } from 'src/entities/person.entity'; import { PersonEntity } from 'src/entities/person.entity';
import { PaginationMode, SourceType } from 'src/enum'; import { SourceType } from 'src/enum';
import { import {
AssetFaceId, AssetFaceId,
DeleteFacesOptions, DeleteFacesOptions,
@ -17,332 +17,418 @@ import {
PersonNameSearchOptions, PersonNameSearchOptions,
PersonSearchOptions, PersonSearchOptions,
PersonStatistics, PersonStatistics,
SelectFaceOptions,
UnassignFacesOptions, UnassignFacesOptions,
UpdateFacesData, UpdateFacesData,
} from 'src/interfaces/person.interface'; } from 'src/interfaces/person.interface';
import { Paginated, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; import { mapUpsertColumns } from 'src/utils/database';
import { DataSource, FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm'; import { Paginated, PaginationOptions } from 'src/utils/pagination';
import { FindOptionsRelations } from 'typeorm';
const withPerson = (eb: ExpressionBuilder<DB, 'asset_faces'>) => {
return jsonObjectFrom(
eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_faces.personId'),
).as('person');
};
const withAsset = (eb: ExpressionBuilder<DB, 'asset_faces'>) => {
return jsonObjectFrom(
eb.selectFrom('assets').selectAll('assets').whereRef('assets.id', '=', 'asset_faces.assetId'),
).as('asset');
};
const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_faces'>) => {
return jsonObjectFrom(
eb.selectFrom('face_search').selectAll('face_search').whereRef('face_search.faceId', '=', 'asset_faces.id'),
).as('faceSearch');
};
@Injectable() @Injectable()
export class PersonRepository implements IPersonRepository { export class PersonRepository implements IPersonRepository {
constructor( constructor(@InjectKysely() private db: Kysely<DB>) {}
@InjectDataSource() private dataSource: DataSource,
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(FaceSearchEntity) private faceSearchRepository: Repository<FaceSearchEntity>,
@InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository<AssetJobStatusEntity>,
) {}
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] }) @GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> { async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> {
const result = await this.assetFaceRepository const result = await this.db
.createQueryBuilder() .updateTable('asset_faces')
.update()
.set({ personId: newPersonId }) .set({ personId: newPersonId })
.where(_.omitBy({ personId: oldPersonId, id: faceIds ? In(faceIds) : undefined }, _.isUndefined)) .$if(!!oldPersonId, (qb) => qb.where('asset_faces.personId', '=', oldPersonId!))
.execute(); .$if(!!faceIds, (qb) => qb.where('asset_faces.id', 'in', faceIds!))
.executeTakeFirst();
return result.affected ?? 0; return Number(result.numChangedRows) ?? 0;
} }
@GenerateSql({ params: [{ sourceType: SourceType.EXIF }] })
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> { async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
await this.assetFaceRepository await this.db
.createQueryBuilder() .updateTable('asset_faces')
.update()
.set({ personId: null }) .set({ personId: null })
.where({ sourceType }) .where('asset_faces.sourceType', '=', sourceType)
.execute(); .execute();
await this.vacuum({ reindexVectors: false }); await this.vacuum({ reindexVectors: false });
} }
@GenerateSql({ params: [[{ id: DummyValue.UUID }]] })
async delete(entities: PersonEntity[]): Promise<void> { async delete(entities: PersonEntity[]): Promise<void> {
await this.personRepository.remove(entities); if (entities.length === 0) {
return;
}
await this.db
.deleteFrom('person')
.where(
'person.id',
'in',
entities.map(({ id }) => id),
)
.execute();
} }
@GenerateSql({ params: [{ sourceType: SourceType.EXIF }] })
async deleteFaces({ sourceType }: DeleteFacesOptions): Promise<void> { async deleteFaces({ sourceType }: DeleteFacesOptions): Promise<void> {
await this.assetFaceRepository await this.db.deleteFrom('asset_faces').where('asset_faces.sourceType', '=', sourceType).execute();
.createQueryBuilder('asset_faces')
.delete()
.andWhere('sourceType = :sourceType', { sourceType })
.execute();
await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING }); await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING });
} }
getAllFaces( getAllFaces(options: Partial<AssetFaceEntity> = {}): AsyncIterableIterator<AssetFaceEntity> {
pagination: PaginationOptions, return this.db
options: FindManyOptions<AssetFaceEntity> = {}, .selectFrom('asset_faces')
): Paginated<AssetFaceEntity> { .selectAll('asset_faces')
return paginate(this.assetFaceRepository, pagination, options); .$if(options.personId === null, (qb) => qb.where('asset_faces.personId', 'is', null))
.$if(!!options.personId, (qb) => qb.where('asset_faces.personId', '=', options.personId!))
.$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!))
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
.stream() as AsyncIterableIterator<AssetFaceEntity>;
} }
getAll(pagination: PaginationOptions, options: FindManyOptions<PersonEntity> = {}): Paginated<PersonEntity> { getAll(options: Partial<PersonEntity> = {}): AsyncIterableIterator<PersonEntity> {
return paginate(this.personRepository, pagination, options); return this.db
.selectFrom('person')
.selectAll('person')
.$if(!!options.ownerId, (qb) => qb.where('person.ownerId', '=', options.ownerId!))
.$if(!!options.thumbnailPath, (qb) => qb.where('person.thumbnailPath', '=', options.thumbnailPath!))
.$if(options.faceAssetId === null, (qb) => qb.where('person.faceAssetId', 'is', null))
.$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!))
.$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!))
.stream() as AsyncIterableIterator<PersonEntity>;
} }
@GenerateSql({ params: [{ take: 10, skip: 10 }, DummyValue.UUID] })
async getAllForUser( async getAllForUser(
pagination: PaginationOptions, pagination: PaginationOptions,
userId: string, userId: string,
options?: PersonSearchOptions, options?: PersonSearchOptions,
): Paginated<PersonEntity> { ): Paginated<PersonEntity> {
const queryBuilder = this.personRepository const items = (await this.db
.createQueryBuilder('person') .selectFrom('person')
.innerJoin('person.faces', 'face') .selectAll('person')
.where('person.ownerId = :userId', { userId }) .innerJoin('asset_faces', 'asset_faces.personId', 'person.id')
.innerJoin('face.asset', 'asset') .innerJoin('assets', (join) =>
.andWhere('asset.isArchived = false') join
.orderBy('person.isHidden', 'ASC') .onRef('asset_faces.assetId', '=', 'assets.id')
.addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC') .on('assets.isArchived', '=', false)
.addOrderBy('COUNT(face.assetId)', 'DESC') .on('assets.deletedAt', 'is', null),
.addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST') )
.addOrderBy('person.createdAt') .where('person.ownerId', '=', userId)
.having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 }) .orderBy('person.isHidden', 'asc')
.groupBy('person.id'); .orderBy(sql`NULLIF(person.name, '') is null`, 'asc')
if (options?.closestFaceAssetId) { .orderBy((eb) => eb.fn.count('asset_faces.assetId'), 'desc')
const innerQueryBuilder = this.faceSearchRepository .orderBy(sql`NULLIF(person.name, '')`, sql`asc nulls last`)
.createQueryBuilder('face_search') .orderBy('person.createdAt')
.select('embedding', 'embedding') .having((eb) =>
.where('"face_search"."faceId" = "person"."faceAssetId"'); eb.or([
const faceSelectQueryBuilder = this.faceSearchRepository eb('person.name', '!=', ''),
.createQueryBuilder('face_search') eb((innerEb) => innerEb.fn.count('asset_faces.assetId'), '>=', options?.minimumFaceCount || 1),
.select('embedding', 'embedding') ]),
.where('"face_search"."faceId" = :faceId', { faceId: options.closestFaceAssetId }); )
queryBuilder .groupBy('person.id')
.orderBy('(' + innerQueryBuilder.getQuery() + ') <=> (' + faceSelectQueryBuilder.getQuery() + ')') .$if(!!options?.closestFaceAssetId, (qb) =>
.setParameters(faceSelectQueryBuilder.getParameters()); qb.orderBy((eb) =>
eb(
(eb) =>
eb
.selectFrom('face_search')
.select('face_search.embedding')
.whereRef('face_search.faceId', '=', 'person.faceAssetId'),
'<=>',
(eb) =>
eb
.selectFrom('face_search')
.select('face_search.embedding')
.where('face_search.faceId', '=', options!.closestFaceAssetId!),
),
),
)
.$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false))
.offset(pagination.skip ?? 0)
.limit(pagination.take + 1)
.execute()) as PersonEntity[];
if (items.length > pagination.take) {
return { items: items.slice(0, -1), hasNextPage: true };
} }
if (!options?.withHidden) {
queryBuilder.andWhere('person.isHidden = false'); return { items, hasNextPage: false };
}
return paginatedBuilder(queryBuilder, {
mode: PaginationMode.LIMIT_OFFSET,
...pagination,
});
} }
@GenerateSql() @GenerateSql()
getAllWithoutFaces(): Promise<PersonEntity[]> { getAllWithoutFaces(): Promise<PersonEntity[]> {
return this.personRepository return this.db
.createQueryBuilder('person') .selectFrom('person')
.leftJoin('person.faces', 'face') .selectAll('person')
.having('COUNT(face.assetId) = 0') .leftJoin('asset_faces', 'asset_faces.personId', 'person.id')
.having((eb) => eb.fn.count('asset_faces.assetId'), '=', 0)
.groupBy('person.id') .groupBy('person.id')
.withDeleted() .execute() as Promise<PersonEntity[]>;
.getMany();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getFaces(assetId: string): Promise<AssetFaceEntity[]> { getFaces(assetId: string): Promise<AssetFaceEntity[]> {
return this.assetFaceRepository.find({ return this.db
where: { assetId }, .selectFrom('asset_faces')
relations: { .selectAll('asset_faces')
person: true, .select(withPerson)
}, .where('asset_faces.assetId', '=', assetId)
order: { .orderBy('asset_faces.boundingBoxX1', 'asc')
boundingBoxX1: 'ASC', .execute() as Promise<AssetFaceEntity[]>;
},
});
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getFaceById(id: string): Promise<AssetFaceEntity> { getFaceById(id: string): Promise<AssetFaceEntity> {
// TODO return null instead of find or fail // TODO return null instead of find or fail
return this.assetFaceRepository.findOneOrFail({ return this.db
where: { id }, .selectFrom('asset_faces')
relations: { .selectAll('asset_faces')
person: true, .select(withPerson)
}, .where('asset_faces.id', '=', id)
}); .executeTakeFirstOrThrow() as Promise<AssetFaceEntity>;
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getFaceByIdWithAssets( getFaceByIdWithAssets(
id: string, id: string,
relations: FindOptionsRelations<AssetFaceEntity>, relations?: FindOptionsRelations<AssetFaceEntity>,
select: FindOptionsSelect<AssetFaceEntity>, select?: SelectFaceOptions,
): Promise<AssetFaceEntity | null> { ): Promise<AssetFaceEntity | null> {
return this.assetFaceRepository.findOne( return (this.db
_.omitBy( .selectFrom('asset_faces')
{ .$if(!!select, (qb) =>
where: { id }, qb.select(
relations: { Object.keys(
...relations, _.omitBy({ ...select!, faceSearch: undefined, asset: undefined }, _.isUndefined),
person: true, ) as SelectExpression<DB, 'asset_faces'>[],
asset: true, ),
}, )
select, .$if(!select, (qb) => qb.selectAll('asset_faces'))
}, .select(withPerson)
_.isUndefined, .select(withAsset)
), .$if(!!relations?.faceSearch, (qb) => qb.select(withFaceSearch))
); .where('asset_faces.id', '=', id)
.executeTakeFirst() ?? null) as Promise<AssetFaceEntity | null>;
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> { async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
const result = await this.assetFaceRepository const result = await this.db
.createQueryBuilder() .updateTable('asset_faces')
.update()
.set({ personId: newPersonId }) .set({ personId: newPersonId })
.where({ id: assetFaceId }) .where('asset_faces.id', '=', assetFaceId)
.execute(); .executeTakeFirst();
return result.affected ?? 0; return Number(result.numChangedRows) ?? 0;
} }
getById(personId: string): Promise<PersonEntity | null> { getById(personId: string): Promise<PersonEntity | null> {
return this.personRepository.findOne({ where: { id: personId } }); return (this.db //
.selectFrom('person')
.selectAll('person')
.where('person.id', '=', personId)
.executeTakeFirst() ?? null) as Promise<PersonEntity | null>;
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise<PersonEntity[]> { getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise<PersonEntity[]> {
const queryBuilder = this.personRepository return this.db
.createQueryBuilder('person') .selectFrom('person')
.where( .selectAll('person')
'person.ownerId = :userId AND (LOWER(person.name) LIKE :nameStart OR LOWER(person.name) LIKE :nameAnywhere)', .where((eb) =>
{ userId, nameStart: `${personName.toLowerCase()}%`, nameAnywhere: `% ${personName.toLowerCase()}%` }, eb.and([
eb('person.ownerId', '=', userId),
eb.or([
eb(eb.fn('lower', ['person.name']), 'like', `${personName.toLowerCase()}%`),
eb(eb.fn('lower', ['person.name']), 'like', `% ${personName.toLowerCase()}%`),
]),
]),
) )
.limit(1000); .limit(1000)
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
if (!withHidden) { .execute() as Promise<PersonEntity[]>;
queryBuilder.andWhere('person.isHidden = false');
}
return queryBuilder.getMany();
} }
@GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] }) @GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] })
getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise<PersonNameResponse[]> { getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise<PersonNameResponse[]> {
const queryBuilder = this.personRepository return this.db
.createQueryBuilder('person') .selectFrom('person')
.select(['person.id', 'person.name']) .select(['person.id', 'person.name'])
.distinctOn(['lower(person.name)']) .distinctOn((eb) => eb.fn('lower', ['person.name']))
.where(`person.ownerId = :userId AND person.name != ''`, { userId }); .where((eb) => eb.and([eb('person.ownerId', '=', userId), eb('person.name', '!=', '')]))
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
if (!withHidden) { .execute();
queryBuilder.andWhere('person.isHidden = false');
}
return queryBuilder.getMany();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getStatistics(personId: string): Promise<PersonStatistics> { async getStatistics(personId: string): Promise<PersonStatistics> {
const items = await this.assetFaceRepository const result = await this.db
.createQueryBuilder('face') .selectFrom('asset_faces')
.leftJoin('face.asset', 'asset') .leftJoin('assets', (join) =>
.where('face.personId = :personId', { personId }) join
.andWhere('asset.isArchived = false') .onRef('assets.id', '=', 'asset_faces.assetId')
.andWhere('asset.deletedAt IS NULL') .on('asset_faces.personId', '=', personId)
.andWhere('asset.livePhotoVideoId IS NULL') .on('assets.isArchived', '=', false)
.select('COUNT(DISTINCT(asset.id))', 'count') .on('assets.deletedAt', 'is', null)
.getRawOne(); .on('assets.livePhotoVideoId', 'is', null),
)
.select((eb) => eb.fn.count(eb.fn('distinct', ['assets.id'])).as('count'))
.executeTakeFirst();
return { return {
assets: items.count ?? 0, assets: result ? Number(result.count) : 0,
}; };
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getNumberOfPeople(userId: string): Promise<PeopleStatistics> { async getNumberOfPeople(userId: string): Promise<PeopleStatistics> {
const items = await this.personRepository const items = await this.db
.createQueryBuilder('person') .selectFrom('person')
.innerJoin('person.faces', 'face') .innerJoin('asset_faces', 'asset_faces.personId', 'person.id')
.where('person.ownerId = :userId', { userId }) .where('person.ownerId', '=', userId)
.innerJoin('face.asset', 'asset') .innerJoin('assets', (join) =>
.andWhere('asset.isArchived = false') join
.select('COUNT(DISTINCT(person.id))', 'total') .onRef('assets.id', '=', 'asset_faces.assetId')
.addSelect('COUNT(DISTINCT(person.id)) FILTER (WHERE person.isHidden = true)', 'hidden') .on('assets.deletedAt', 'is', null)
.getRawOne(); .on('assets.isArchived', '=', false),
)
.select((eb) => eb.fn.count(eb.fn('distinct', ['person.id'])).as('total'))
.select((eb) =>
eb.fn
.count(eb.fn('distinct', ['person.id']))
.filterWhere('person.isHidden', '=', true)
.as('hidden'),
)
.executeTakeFirst();
if (items == undefined) { if (items == undefined) {
return { total: 0, hidden: 0 }; return { total: 0, hidden: 0 };
} }
const result: PeopleStatistics = { return {
total: items.total ?? 0, total: Number(items.total),
hidden: items.hidden ?? 0, hidden: Number(items.hidden),
}; };
return result;
} }
create(person: Partial<PersonEntity>): Promise<PersonEntity> { create(person: Insertable<Person>): Promise<PersonEntity> {
return this.save(person); return this.db.insertInto('person').values(person).returningAll().executeTakeFirst() as Promise<PersonEntity>;
} }
async createAll(people: Partial<PersonEntity>[]): Promise<string[]> { async createAll(people: Insertable<Person>[]): Promise<string[]> {
const results = await this.personRepository.save(people); const results = await this.db.insertInto('person').values(people).returningAll().execute();
return results.map((person) => person.id); return results.map(({ id }) => id);
} }
@GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] })
async refreshFaces( async refreshFaces(
facesToAdd: Partial<AssetFaceEntity>[], facesToAdd: (Insertable<AssetFaces> & { assetId: string })[],
faceIdsToRemove: string[], faceIdsToRemove: string[],
embeddingsToAdd?: FaceSearchEntity[], embeddingsToAdd?: Insertable<FaceSearch>[],
): Promise<void> { ): Promise<void> {
const query = this.faceSearchRepository.createQueryBuilder().select('1').fromDummy(); let query = this.db;
if (facesToAdd.length > 0) { if (facesToAdd.length > 0) {
const insertCte = this.assetFaceRepository.createQueryBuilder().insert().values(facesToAdd); (query as any) = query.with('added', (db) => db.insertInto('asset_faces').values(facesToAdd));
query.addCommonTableExpression(insertCte, 'added');
} }
if (faceIdsToRemove.length > 0) { if (faceIdsToRemove.length > 0) {
const deleteCte = this.assetFaceRepository (query as any) = query.with('removed', (db) =>
.createQueryBuilder() db.deleteFrom('asset_faces').where('asset_faces.id', '=', (eb) => eb.fn.any(eb.val(faceIdsToRemove))),
.delete() );
.where('id = any(:faceIdsToRemove)', { faceIdsToRemove });
query.addCommonTableExpression(deleteCte, 'deleted');
} }
if (embeddingsToAdd?.length) { if (embeddingsToAdd?.length) {
const embeddingCte = this.faceSearchRepository.createQueryBuilder().insert().values(embeddingsToAdd).orIgnore(); (query as any) = query.with('added_embeddings', (db) => db.insertInto('face_search').values(embeddingsToAdd));
query.addCommonTableExpression(embeddingCte, 'embeddings');
query.getQuery(); // typeorm mixes up parameters without this
} }
await query.execute(); await query.selectFrom(sql`(select 1)`.as('dummy')).execute();
} }
async update(person: Partial<PersonEntity>): Promise<PersonEntity> { async update(person: Partial<PersonEntity> & { id: string }): Promise<PersonEntity> {
return this.save(person); return this.db
.updateTable('person')
.set(person)
.where('person.id', '=', person.id)
.returningAll()
.executeTakeFirstOrThrow() as Promise<PersonEntity>;
} }
async updateAll(people: Partial<PersonEntity>[]): Promise<void> { async updateAll(people: Insertable<Person>[]): Promise<void> {
await this.personRepository.save(people); if (people.length === 0) {
return;
}
await this.db
.insertInto('person')
.values(people)
.onConflict((oc) => oc.column('id').doUpdateSet(() => mapUpsertColumns('person', people[0], ['id'])))
.execute();
} }
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] }) @GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
@ChunkedArray() @ChunkedArray()
async getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> { getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> {
return this.assetFaceRepository.find({ where: ids, relations: { asset: true }, withDeleted: true }); const { assetIds, personIds }: { assetIds: string[]; personIds: string[] } = { assetIds: [], personIds: [] };
for (const { assetId, personId } of ids) {
assetIds.push(assetId);
personIds.push(personId);
}
return this.db
.selectFrom('asset_faces')
.selectAll('asset_faces')
.select(withAsset)
.select(withPerson)
.where('asset_faces.assetId', 'in', assetIds)
.where('asset_faces.personId', 'in', personIds)
.execute() as Promise<AssetFaceEntity[]>;
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getRandomFace(personId: string): Promise<AssetFaceEntity | null> { getRandomFace(personId: string): Promise<AssetFaceEntity | null> {
return this.assetFaceRepository.findOneBy({ personId }); return (this.db
.selectFrom('asset_faces')
.selectAll('asset_faces')
.where('asset_faces.personId', '=', personId)
.executeTakeFirst() ?? null) as Promise<AssetFaceEntity | null>;
} }
@GenerateSql() @GenerateSql()
async getLatestFaceDate(): Promise<string | undefined> { async getLatestFaceDate(): Promise<string | undefined> {
const result: { latestDate?: string } | undefined = await this.jobStatusRepository const result = (await this.db
.createQueryBuilder('jobStatus') .selectFrom('asset_job_status')
.select('MAX(jobStatus.facesRecognizedAt)::text', 'latestDate') .select((eb) => sql`${eb.fn.max('asset_job_status.facesRecognizedAt')}::text`.as('latestDate'))
.getRawOne(); .executeTakeFirst()) as { latestDate: string } | undefined;
return result?.latestDate; return result?.latestDate;
} }
private async save(person: Partial<PersonEntity>): Promise<PersonEntity> {
const { id } = await this.personRepository.save(person);
return this.personRepository.findOneByOrFail({ id });
}
private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise<void> { private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise<void> {
await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search, person'); await sql`VACUUM ANALYZE asset_faces, face_search, person`.execute(this.db);
await this.assetFaceRepository.query('REINDEX TABLE asset_faces'); await sql`REINDEX TABLE asset_faces`.execute(this.db);
await this.assetFaceRepository.query('REINDEX TABLE person'); await sql`REINDEX TABLE person`.execute(this.db);
if (reindexVectors) { if (reindexVectors) {
await this.assetFaceRepository.query('REINDEX TABLE face_search'); await sql`REINDEX TABLE face_search`.execute(this.db);
} }
} }
} }

View File

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

View File

@ -4,9 +4,9 @@ import { exec as execCallback } from 'node:child_process';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import sharp from 'sharp'; import sharp from 'sharp';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { GitHubRelease, IServerInfoRepository, ServerBuildVersions } from 'src/interfaces/server-info.interface'; import { GitHubRelease, IServerInfoRepository, ServerBuildVersions } from 'src/interfaces/server-info.interface';
import { ConfigRepository } from 'src/repositories/config.repository';
const exec = promisify(execCallback); const exec = promisify(execCallback);
const maybeFirstLine = async (command: string): Promise<string> => { const maybeFirstLine = async (command: string): Promise<string> => {
@ -36,7 +36,7 @@ const getLockfileVersion = (name: string, lockfile?: BuildLockfile) => {
@Injectable() @Injectable()
export class ServerInfoRepository implements IServerInfoRepository { export class ServerInfoRepository implements IServerInfoRepository {
constructor( constructor(
@Inject(IConfigRepository) private configRepository: IConfigRepository, private configRepository: ConfigRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.logger.setContext(ServerInfoRepository.name); this.logger.setContext(ServerInfoRepository.name);

View File

@ -1,84 +1,113 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { ExpressionBuilder, Kysely, Updateable } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEntity } from 'src/entities/asset.entity';
import { StackEntity } from 'src/entities/stack.entity'; import { StackEntity } from 'src/entities/stack.entity';
import { IStackRepository, StackSearch } from 'src/interfaces/stack.interface'; import { IStackRepository, StackSearch } from 'src/interfaces/stack.interface';
import { DataSource, In, Repository } from 'typeorm'; import { asUuid } from 'src/utils/database';
const withAssets = (eb: ExpressionBuilder<DB, 'asset_stack'>, withTags = false) => {
return jsonArrayFrom(
eb
.selectFrom('assets')
.selectAll()
.$if(withTags, (eb) =>
eb.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('tags')
.selectAll('tags')
.innerJoin('tag_asset', 'tags.id', 'tag_asset.tagsId')
.whereRef('tag_asset.assetsId', '=', 'assets.id'),
).as('tags'),
),
)
.where('assets.deletedAt', 'is', null)
.whereRef('assets.stackId', '=', 'asset_stack.id'),
).as('assets');
};
@Injectable() @Injectable()
export class StackRepository implements IStackRepository { export class StackRepository implements IStackRepository {
constructor( constructor(@InjectKysely() private db: Kysely<DB>) {}
@InjectDataSource() private dataSource: DataSource,
@InjectRepository(StackEntity) private repository: Repository<StackEntity>,
) {}
@GenerateSql({ params: [{ ownerId: DummyValue.UUID }] }) @GenerateSql({ params: [{ ownerId: DummyValue.UUID }] })
search(query: StackSearch): Promise<StackEntity[]> { search(query: StackSearch): Promise<StackEntity[]> {
return this.repository.find({ return this.db
where: { .selectFrom('asset_stack')
ownerId: query.ownerId, .selectAll('asset_stack')
primaryAssetId: query.primaryAssetId, .select(withAssets)
}, .where('asset_stack.ownerId', '=', query.ownerId)
relations: { .$if(!!query.primaryAssetId, (eb) => eb.where('asset_stack.primaryAssetId', '=', query.primaryAssetId!))
assets: { .execute() as unknown as Promise<StackEntity[]>;
exifInfo: true,
},
},
});
} }
async create(entity: { ownerId: string; assetIds: string[] }): Promise<StackEntity> { async create(entity: { ownerId: string; assetIds: string[] }): Promise<StackEntity> {
return this.dataSource.manager.transaction(async (manager) => { return this.db.transaction().execute(async (tx) => {
const stackRepository = manager.getRepository(StackEntity); const stacks = await tx
.selectFrom('asset_stack')
const stacks = await stackRepository.find({ .where('asset_stack.ownerId', '=', entity.ownerId)
where: { .where('asset_stack.primaryAssetId', 'in', entity.assetIds)
ownerId: entity.ownerId, .select('asset_stack.id')
primaryAssetId: In(entity.assetIds), .select((eb) =>
}, jsonArrayFrom(
select: { eb
id: true, .selectFrom('assets')
assets: { .select('assets.id')
id: true, .whereRef('assets.stackId', '=', 'asset_stack.id')
}, .where('assets.deletedAt', 'is', null),
}, ).as('assets'),
relations: { )
assets: { .execute();
exifInfo: true,
},
},
});
const assetIds = new Set<string>(entity.assetIds); const assetIds = new Set<string>(entity.assetIds);
// children // children
for (const stack of stacks) { for (const stack of stacks) {
for (const asset of stack.assets) { if (stack.assets && stack.assets.length > 0) {
assetIds.add(asset.id); for (const asset of stack.assets) {
assetIds.add(asset.id);
}
} }
} }
if (stacks.length > 0) { if (stacks.length > 0) {
await stackRepository.delete({ id: In(stacks.map((stack) => stack.id)) }); await tx
.deleteFrom('asset_stack')
.where(
'id',
'in',
stacks.map((stack) => stack.id),
)
.execute();
} }
const { id } = await stackRepository.save({ const newRecord = await tx
ownerId: entity.ownerId, .insertInto('asset_stack')
primaryAssetId: entity.assetIds[0], .values({
assets: [...assetIds].map((id) => ({ id }) as AssetEntity), ownerId: entity.ownerId,
}); primaryAssetId: entity.assetIds[0],
})
.returning('id')
.executeTakeFirstOrThrow();
return stackRepository.findOneOrFail({ await tx
where: { .updateTable('assets')
id, .set({
}, stackId: newRecord.id,
relations: { updatedAt: new Date(),
assets: { })
exifInfo: true, .where('id', 'in', [...assetIds])
}, .execute();
},
}); return tx
.selectFrom('asset_stack')
.selectAll('asset_stack')
.select(withAssets)
.where('id', '=', newRecord.id)
.executeTakeFirst() as unknown as Promise<StackEntity>;
}); });
} }
@ -91,12 +120,12 @@ export class StackRepository implements IStackRepository {
const assetIds = stack.assets.map(({ id }) => id); const assetIds = stack.assets.map(({ id }) => id);
await this.repository.delete(id); await this.db.deleteFrom('asset_stack').where('id', '=', asUuid(id)).execute();
await this.db
// Update assets updatedAt .updateTable('assets')
await this.dataSource.manager.update(AssetEntity, assetIds, { .set({ stackId: null, updatedAt: new Date() })
updatedAt: new Date(), .where('id', 'in', assetIds)
}); .execute();
} }
async deleteAll(ids: string[]): Promise<void> { async deleteAll(ids: string[]): Promise<void> {
@ -110,54 +139,31 @@ export class StackRepository implements IStackRepository {
assetIds.push(...stack.assets.map(({ id }) => id)); assetIds.push(...stack.assets.map(({ id }) => id));
} }
await this.repository.delete(ids); await this.db
.updateTable('assets')
// Update assets updatedAt .set({ updatedAt: new Date(), stackId: null })
await this.dataSource.manager.update(AssetEntity, assetIds, { .where('id', 'in', assetIds)
updatedAt: new Date(), .where('stackId', 'in', ids)
}); .execute();
} }
update(entity: Partial<StackEntity>) { update(id: string, entity: Updateable<StackEntity>): Promise<StackEntity> {
return this.save(entity); return this.db
.updateTable('asset_stack')
.set(entity)
.where('id', '=', asUuid(id))
.returningAll('asset_stack')
.returning((eb) => withAssets(eb, true))
.executeTakeFirstOrThrow() as unknown as Promise<StackEntity>;
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getById(id: string): Promise<StackEntity | null> { getById(id: string): Promise<StackEntity | undefined> {
return this.repository.findOne({ return this.db
where: { .selectFrom('asset_stack')
id, .selectAll()
}, .select((eb) => withAssets(eb, true))
relations: { .where('id', '=', asUuid(id))
assets: { .executeTakeFirst() as Promise<StackEntity | undefined>;
exifInfo: true,
tags: true,
},
},
order: {
assets: {
fileCreatedAt: 'ASC',
},
},
});
}
private async save(entity: Partial<StackEntity>) {
const { id } = await this.repository.save(entity);
return this.repository.findOneOrFail({
where: {
id,
},
relations: {
assets: {
exifInfo: true,
},
},
order: {
assets: {
fileCreatedAt: 'ASC',
},
},
});
} }
} }

View File

@ -15,9 +15,9 @@ import { MetricService } from 'nestjs-otel';
import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils'; import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils';
import { serverVersion } from 'src/constants'; import { serverVersion } from 'src/constants';
import { ImmichTelemetry, MetadataKey } from 'src/enum'; import { ImmichTelemetry, MetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMetricGroupRepository, ITelemetryRepository, MetricGroupOptions } from 'src/interfaces/telemetry.interface'; import { IMetricGroupRepository, ITelemetryRepository, MetricGroupOptions } from 'src/interfaces/telemetry.interface';
import { ConfigRepository } from 'src/repositories/config.repository';
class MetricGroupRepository implements IMetricGroupRepository { class MetricGroupRepository implements IMetricGroupRepository {
private enabled = false; private enabled = false;
@ -95,7 +95,7 @@ export class TelemetryRepository implements ITelemetryRepository {
constructor( constructor(
private metricService: MetricService, private metricService: MetricService,
private reflect: Reflector, private reflect: Reflector,
@Inject(IConfigRepository) private configRepository: IConfigRepository, private configRepository: ConfigRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
const { telemetry } = this.configRepository.getEnv(); const { telemetry } = this.configRepository.getEnv();

View File

@ -2,15 +2,14 @@ import { Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { DB } from 'src/db'; import { DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEntity, withExif } from 'src/entities/asset.entity'; import { withExif } from 'src/entities/asset.entity';
import { IViewRepository } from 'src/interfaces/view.interface';
import { asUuid } from 'src/utils/database'; import { asUuid } from 'src/utils/database';
export class ViewRepository implements IViewRepository { export class ViewRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getUniqueOriginalPaths(userId: string): Promise<string[]> { async getUniqueOriginalPaths(userId: string) {
const results = await this.db const results = await this.db
.selectFrom('assets') .selectFrom('assets')
.select((eb) => eb.fn<string>('substring', ['assets.originalPath', eb.val('^(.*/)[^/]*$')]).as('directoryPath')) .select((eb) => eb.fn<string>('substring', ['assets.originalPath', eb.val('^(.*/)[^/]*$')]).as('directoryPath'))
@ -25,7 +24,7 @@ export class ViewRepository implements IViewRepository {
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getAssetsByOriginalPath(userId: string, partialPath: string): Promise<AssetEntity[]> { async getAssetsByOriginalPath(userId: string, partialPath: string) {
const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, ''); const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, '');
return this.db return this.db
@ -42,6 +41,6 @@ export class ViewRepository implements IViewRepository {
(eb) => eb.fn('regexp_replace', ['assets.originalPath', eb.val('.*/(.+)'), eb.val(String.raw`\1`)]), (eb) => eb.fn('regexp_replace', ['assets.originalPath', eb.val('.*/(.+)'), eb.val(String.raw`\1`)]),
'asc', 'asc',
) )
.execute() as any as Promise<AssetEntity[]>; .execute();
} }
} }

View File

@ -135,14 +135,17 @@ describe(AlbumService.name, () => {
assetIds: ['123'], assetIds: ['123'],
}); });
expect(albumMock.create).toHaveBeenCalledWith({ expect(albumMock.create).toHaveBeenCalledWith(
ownerId: authStub.admin.user.id, {
albumName: albumStub.empty.albumName, ownerId: authStub.admin.user.id,
description: albumStub.empty.description, albumName: albumStub.empty.albumName,
albumUsers: [{ userId: 'user-id', role: AlbumUserRole.EDITOR }], description: albumStub.empty.description,
assets: [{ id: '123' }],
albumThumbnailAssetId: '123', albumThumbnailAssetId: '123',
}); },
['123'],
[{ userId: 'user-id', role: AlbumUserRole.EDITOR }],
);
expect(userMock.get).toHaveBeenCalledWith('user-id', {}); expect(userMock.get).toHaveBeenCalledWith('user-id', {});
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123'])); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']));
@ -175,14 +178,17 @@ describe(AlbumService.name, () => {
assetIds: ['asset-1', 'asset-2'], assetIds: ['asset-1', 'asset-2'],
}); });
expect(albumMock.create).toHaveBeenCalledWith({ expect(albumMock.create).toHaveBeenCalledWith(
ownerId: authStub.admin.user.id, {
albumName: 'Test album', ownerId: authStub.admin.user.id,
description: '', albumName: 'Test album',
albumUsers: [], description: '',
assets: [{ id: 'asset-1' }],
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
}); },
['asset-1'],
[],
);
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith( expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id, authStub.admin.user.id,
new Set(['asset-1', 'asset-2']), new Set(['asset-1', 'asset-2']),
@ -192,7 +198,7 @@ describe(AlbumService.name, () => {
describe('update', () => { describe('update', () => {
it('should prevent updating an album that does not exist', async () => { it('should prevent updating an album that does not exist', async () => {
albumMock.getById.mockResolvedValue(null); albumMock.getById.mockResolvedValue(void 0);
await expect( await expect(
sut.update(authStub.user1, 'invalid-id', { sut.update(authStub.user1, 'invalid-id', {
@ -238,7 +244,7 @@ describe(AlbumService.name, () => {
}); });
expect(albumMock.update).toHaveBeenCalledTimes(1); expect(albumMock.update).toHaveBeenCalledTimes(1);
expect(albumMock.update).toHaveBeenCalledWith({ expect(albumMock.update).toHaveBeenCalledWith('album-4', {
id: 'album-4', id: 'album-4',
albumName: 'new album name', albumName: 'new album name',
}); });
@ -344,7 +350,7 @@ describe(AlbumService.name, () => {
describe('removeUser', () => { describe('removeUser', () => {
it('should require a valid album id', async () => { it('should require a valid album id', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1']));
albumMock.getById.mockResolvedValue(null); albumMock.getById.mockResolvedValue(void 0);
await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.update).not.toHaveBeenCalled(); expect(albumMock.update).not.toHaveBeenCalled();
}); });
@ -529,7 +535,7 @@ describe(AlbumService.name, () => {
{ success: true, id: 'asset-3' }, { success: true, id: 'asset-3' },
]); ]);
expect(albumMock.update).toHaveBeenCalledWith({ expect(albumMock.update).toHaveBeenCalledWith('album-123', {
id: 'album-123', id: 'album-123',
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
@ -547,7 +553,7 @@ describe(AlbumService.name, () => {
{ success: true, id: 'asset-1' }, { success: true, id: 'asset-1' },
]); ]);
expect(albumMock.update).toHaveBeenCalledWith({ expect(albumMock.update).toHaveBeenCalledWith('album-123', {
id: 'album-123', id: 'album-123',
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-id', albumThumbnailAssetId: 'asset-id',
@ -569,7 +575,7 @@ describe(AlbumService.name, () => {
{ success: true, id: 'asset-3' }, { success: true, id: 'asset-3' },
]); ]);
expect(albumMock.update).toHaveBeenCalledWith({ expect(albumMock.update).toHaveBeenCalledWith('album-123', {
id: 'album-123', id: 'album-123',
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
@ -606,7 +612,7 @@ describe(AlbumService.name, () => {
{ success: true, id: 'asset-3' }, { success: true, id: 'asset-3' },
]); ]);
expect(albumMock.update).toHaveBeenCalledWith({ expect(albumMock.update).toHaveBeenCalledWith('album-123', {
id: 'album-123', id: 'album-123',
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
@ -629,7 +635,7 @@ describe(AlbumService.name, () => {
{ success: true, id: 'asset-1' }, { success: true, id: 'asset-1' },
]); ]);
expect(albumMock.update).toHaveBeenCalledWith({ expect(albumMock.update).toHaveBeenCalledWith('album-123', {
id: 'album-123', id: 'album-123',
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
@ -696,7 +702,6 @@ describe(AlbumService.name, () => {
{ success: true, id: 'asset-id' }, { success: true, id: 'asset-id' },
]); ]);
expect(albumMock.update).toHaveBeenCalledWith({ id: 'album-123', updatedAt: expect.any(Date) });
expect(albumMock.removeAssetIds).toHaveBeenCalledWith('album-123', ['asset-id']); expect(albumMock.removeAssetIds).toHaveBeenCalledWith('album-123', ['asset-id']);
}); });
@ -720,8 +725,6 @@ describe(AlbumService.name, () => {
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' }, { success: true, id: 'asset-id' },
]); ]);
expect(albumMock.update).toHaveBeenCalledWith({ id: 'album-123', updatedAt: expect.any(Date) });
}); });
it('should reset the thumbnail if it is removed', async () => { it('should reset the thumbnail if it is removed', async () => {
@ -734,10 +737,6 @@ describe(AlbumService.name, () => {
{ success: true, id: 'asset-id' }, { success: true, id: 'asset-id' },
]); ]);
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
});
expect(albumMock.updateThumbnails).toHaveBeenCalled(); expect(albumMock.updateThumbnails).toHaveBeenCalled();
}); });
}); });

View File

@ -15,7 +15,6 @@ import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AlbumUserEntity } from 'src/entities/album-user.entity';
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/interfaces/album.interface'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/interfaces/album.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
@ -112,16 +111,18 @@ export class AlbumService extends BaseService {
permission: Permission.ASSET_SHARE, permission: Permission.ASSET_SHARE,
ids: dto.assetIds || [], ids: dto.assetIds || [],
}); });
const assets = [...allowedAssetIdsSet].map((id) => ({ id }) as AssetEntity); const assetIds = [...allowedAssetIdsSet].map((id) => id);
const album = await this.albumRepository.create({ const album = await this.albumRepository.create(
ownerId: auth.user.id, {
albumName: dto.albumName, ownerId: auth.user.id,
description: dto.description, albumName: dto.albumName,
albumUsers: albumUsers.map((albumUser) => albumUser as AlbumUserEntity) ?? [], description: dto.description,
assets, albumThumbnailAssetId: assetIds[0] || null,
albumThumbnailAssetId: assets[0]?.id || null, },
}); assetIds,
albumUsers,
);
for (const { userId } of albumUsers) { for (const { userId } of albumUsers) {
await this.eventRepository.emit('album.invite', { id: album.id, userId }); await this.eventRepository.emit('album.invite', { id: album.id, userId });
@ -141,7 +142,7 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Invalid album thumbnail'); throw new BadRequestException('Invalid album thumbnail');
} }
} }
const updatedAlbum = await this.albumRepository.update({ const updatedAlbum = await this.albumRepository.update(album.id, {
id: album.id, id: album.id,
albumName: dto.albumName, albumName: dto.albumName,
description: dto.description, description: dto.description,
@ -170,7 +171,7 @@ export class AlbumService extends BaseService {
const { id: firstNewAssetId } = results.find(({ success }) => success) || {}; const { id: firstNewAssetId } = results.find(({ success }) => success) || {};
if (firstNewAssetId) { if (firstNewAssetId) {
await this.albumRepository.update({ await this.albumRepository.update(id, {
id, id,
updatedAt: new Date(), updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId, albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId,
@ -199,11 +200,8 @@ export class AlbumService extends BaseService {
); );
const removedIds = results.filter(({ success }) => success).map(({ id }) => id); const removedIds = results.filter(({ success }) => success).map(({ id }) => id);
if (removedIds.length > 0) { if (removedIds.length > 0 && album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) {
await this.albumRepository.update({ id, updatedAt: new Date() }); await this.albumRepository.updateThumbnails();
if (album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) {
await this.albumRepository.updateThumbnails();
}
} }
return results; return results;

View File

@ -1,8 +1,8 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { APIKeyService } from 'src/services/api-key.service'; import { APIKeyService } from 'src/services/api-key.service';
import { IApiKeyRepository } from 'src/types';
import { keyStub } from 'test/fixtures/api-key.stub'; import { keyStub } from 'test/fixtures/api-key.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { newTestService } from 'test/utils'; import { newTestService } from 'test/utils';
@ -12,7 +12,7 @@ describe(APIKeyService.name, () => {
let sut: APIKeyService; let sut: APIKeyService;
let cryptoMock: Mocked<ICryptoRepository>; let cryptoMock: Mocked<ICryptoRepository>;
let keyMock: Mocked<IKeyRepository>; let keyMock: Mocked<IApiKeyRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, cryptoMock, keyMock } = newTestService(APIKeyService)); ({ sut, cryptoMock, keyMock } = newTestService(APIKeyService));
@ -56,8 +56,6 @@ describe(APIKeyService.name, () => {
describe('update', () => { describe('update', () => {
it('should throw an error if the key is not found', async () => { it('should throw an error if the key is not found', async () => {
keyMock.getById.mockResolvedValue(null);
await expect(sut.update(authStub.admin, 'random-guid', { name: 'New Name' })).rejects.toBeInstanceOf( await expect(sut.update(authStub.admin, 'random-guid', { name: 'New Name' })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
@ -77,8 +75,6 @@ describe(APIKeyService.name, () => {
describe('delete', () => { describe('delete', () => {
it('should throw an error if the key is not found', async () => { it('should throw an error if the key is not found', async () => {
keyMock.getById.mockResolvedValue(null);
await expect(sut.delete(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.delete(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException);
expect(keyMock.delete).not.toHaveBeenCalledWith('random-guid'); expect(keyMock.delete).not.toHaveBeenCalledWith('random-guid');
@ -95,8 +91,6 @@ describe(APIKeyService.name, () => {
describe('getById', () => { describe('getById', () => {
it('should throw an error if the key is not found', async () => { it('should throw an error if the key is not found', async () => {
keyMock.getById.mockResolvedValue(null);
await expect(sut.getById(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.getById(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException);
expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid');

View File

@ -1,8 +1,9 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto'; import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { APIKeyEntity } from 'src/entities/api-key.entity'; import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { ApiKeyItem } from 'src/types';
import { isGranted } from 'src/utils/access'; import { isGranted } from 'src/utils/access';
@Injectable() @Injectable()
@ -57,13 +58,13 @@ export class APIKeyService extends BaseService {
return keys.map((key) => this.map(key)); return keys.map((key) => this.map(key));
} }
private map(entity: APIKeyEntity): APIKeyResponseDto { private map(entity: ApiKeyItem): APIKeyResponseDto {
return { return {
id: entity.id, id: entity.id,
name: entity.name, name: entity.name,
createdAt: entity.createdAt, createdAt: entity.createdAt,
updatedAt: entity.updatedAt, updatedAt: entity.updatedAt,
permissions: entity.permissions, permissions: entity.permissions as Permission[],
}; };
} }
} }

View File

@ -3,8 +3,8 @@ import { Cron, CronExpression, Interval } from '@nestjs/schedule';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { ONE_HOUR } from 'src/constants'; import { ONE_HOUR } from 'src/constants';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AuthService } from 'src/services/auth.service'; import { AuthService } from 'src/services/auth.service';
import { JobService } from 'src/services/job.service'; import { JobService } from 'src/services/job.service';
import { SharedLinkService } from 'src/services/shared-link.service'; import { SharedLinkService } from 'src/services/shared-link.service';
@ -38,7 +38,7 @@ export class ApiService {
private jobService: JobService, private jobService: JobService,
private sharedLinkService: SharedLinkService, private sharedLinkService: SharedLinkService,
private versionService: VersionService, private versionService: VersionService,
@Inject(IConfigRepository) private configRepository: IConfigRepository, private configRepository: ConfigRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.logger.setContext(ApiService.name); this.logger.setContext(ApiService.name);

View File

@ -520,7 +520,7 @@ describe(AssetService.name, () => {
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });
expect(stackMock.update).toHaveBeenCalledWith({ expect(stackMock.update).toHaveBeenCalledWith('stack-1', {
id: 'stack-1', id: 'stack-1',
primaryAssetId: 'stack-child-asset-1', primaryAssetId: 'stack-child-asset-1',
}); });

View File

@ -192,7 +192,7 @@ export class AssetService extends BaseService {
const stackAssetIds = asset.stack.assets.map((a) => a.id); const stackAssetIds = asset.stack.assets.map((a) => a.id);
if (stackAssetIds.length > 2) { if (stackAssetIds.length > 2) {
const newPrimaryAssetId = stackAssetIds.find((a) => a !== id)!; const newPrimaryAssetId = stackAssetIds.find((a) => a !== id)!;
await this.stackRepository.update({ await this.stackRepository.update(asset.stack.id, {
id: asset.stack.id, id: asset.stack.id,
primaryAssetId: newPrimaryAssetId, primaryAssetId: newPrimaryAssetId,
}); });

View File

@ -2,12 +2,12 @@ import { BadRequestException } from '@nestjs/common';
import { FileReportItemDto } from 'src/dtos/audit.dto'; import { FileReportItemDto } from 'src/dtos/audit.dto';
import { AssetFileType, AssetPathType, DatabaseAction, EntityType, PersonPathType, UserPathType } from 'src/enum'; import { AssetFileType, AssetPathType, DatabaseAction, EntityType, PersonPathType, UserPathType } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { JobStatus } from 'src/interfaces/job.interface'; import { JobStatus } from 'src/interfaces/job.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { AuditService } from 'src/services/audit.service'; import { AuditService } from 'src/services/audit.service';
import { IAuditRepository } from 'src/types';
import { auditStub } from 'test/fixtures/audit.stub'; import { auditStub } from 'test/fixtures/audit.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { newTestService } from 'test/utils'; import { newTestService } from 'test/utils';

View File

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

View File

@ -3,7 +3,6 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { AuthType, Permission } from 'src/enum'; import { AuthType, Permission } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
import { IOAuthRepository } from 'src/interfaces/oauth.interface'; import { IOAuthRepository } from 'src/interfaces/oauth.interface';
@ -12,6 +11,7 @@ import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { AuthService } from 'src/services/auth.service'; import { AuthService } from 'src/services/auth.service';
import { IApiKeyRepository } from 'src/types';
import { keyStub } from 'test/fixtures/api-key.stub'; import { keyStub } from 'test/fixtures/api-key.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { sessionStub } from 'test/fixtures/session.stub'; import { sessionStub } from 'test/fixtures/session.stub';
@ -62,7 +62,7 @@ describe('AuthService', () => {
let cryptoMock: Mocked<ICryptoRepository>; let cryptoMock: Mocked<ICryptoRepository>;
let eventMock: Mocked<IEventRepository>; let eventMock: Mocked<IEventRepository>;
let keyMock: Mocked<IKeyRepository>; let keyMock: Mocked<IApiKeyRepository>;
let oauthMock: Mocked<IOAuthRepository>; let oauthMock: Mocked<IOAuthRepository>;
let sessionMock: Mocked<ISessionRepository>; let sessionMock: Mocked<ISessionRepository>;
let sharedLinkMock: Mocked<ISharedLinkRepository>; let sharedLinkMock: Mocked<ISharedLinkRepository>;

View File

@ -21,6 +21,7 @@ import { UserEntity } from 'src/entities/user.entity';
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum'; import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum';
import { OAuthProfile } from 'src/interfaces/oauth.interface'; import { OAuthProfile } from 'src/interfaces/oauth.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { AuthApiKey } from 'src/types';
import { isGranted } from 'src/utils/access'; import { isGranted } from 'src/utils/access';
import { HumanReadableSize } from 'src/utils/bytes'; import { HumanReadableSize } from 'src/utils/bytes';
@ -309,7 +310,10 @@ export class AuthService extends BaseService {
const hashedKey = this.cryptoRepository.hashSha256(key); const hashedKey = this.cryptoRepository.hashSha256(key);
const apiKey = await this.keyRepository.getKey(hashedKey); const apiKey = await this.keyRepository.getKey(hashedKey);
if (apiKey) { if (apiKey) {
return { user: apiKey.user, apiKey }; return {
user: apiKey.user as unknown as UserEntity,
apiKey: apiKey as unknown as AuthApiKey,
};
} }
throw new UnauthorizedException('Invalid API key'); throw new UnauthorizedException('Invalid API key');

View File

@ -2,7 +2,6 @@ import { PassThrough } from 'node:stream';
import { defaults, SystemConfig } from 'src/config'; import { defaults, SystemConfig } from 'src/config';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { ImmichWorker, StorageFolder } from 'src/enum'; import { ImmichWorker, StorageFolder } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICronRepository } from 'src/interfaces/cron.interface'; import { ICronRepository } from 'src/interfaces/cron.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { JobStatus } from 'src/interfaces/job.interface'; import { JobStatus } from 'src/interfaces/job.interface';
@ -10,6 +9,7 @@ import { IProcessRepository } from 'src/interfaces/process.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BackupService } from 'src/services/backup.service'; import { BackupService } from 'src/services/backup.service';
import { IConfigRepository } from 'src/types';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { mockSpawn, newTestService } from 'test/utils'; import { mockSpawn, newTestService } from 'test/utils';
import { describe, Mocked } from 'vitest'; import { describe, Mocked } from 'vitest';

View File

@ -6,13 +6,9 @@ import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { Users } from 'src/db'; import { Users } from 'src/db';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICronRepository } from 'src/interfaces/cron.interface'; import { ICronRepository } from 'src/interfaces/cron.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface';
@ -43,8 +39,12 @@ import { ITelemetryRepository } from 'src/interfaces/telemetry.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface'; import { ITrashRepository } from 'src/interfaces/trash.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
import { IViewRepository } from 'src/interfaces/view.interface'; import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository'; import { ActivityRepository } from 'src/repositories/activity.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { ViewRepository } from 'src/repositories/view-repository';
import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access'; import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access';
import { getConfig, updateConfig } from 'src/utils/config'; import { getConfig, updateConfig } from 'src/utils/config';
@ -53,19 +53,19 @@ export class BaseService {
constructor( constructor(
@Inject(ILoggerRepository) protected logger: ILoggerRepository, @Inject(ILoggerRepository) protected logger: ILoggerRepository,
@Inject(IAccessRepository) protected accessRepository: IAccessRepository, protected accessRepository: AccessRepository,
protected activityRepository: ActivityRepository, protected activityRepository: ActivityRepository,
@Inject(IAuditRepository) protected auditRepository: IAuditRepository, protected auditRepository: AuditRepository,
@Inject(IAlbumRepository) protected albumRepository: IAlbumRepository, @Inject(IAlbumRepository) protected albumRepository: IAlbumRepository,
@Inject(IAlbumUserRepository) protected albumUserRepository: IAlbumUserRepository, @Inject(IAlbumUserRepository) protected albumUserRepository: IAlbumUserRepository,
@Inject(IAssetRepository) protected assetRepository: IAssetRepository, @Inject(IAssetRepository) protected assetRepository: IAssetRepository,
@Inject(IConfigRepository) protected configRepository: IConfigRepository, protected configRepository: ConfigRepository,
@Inject(ICronRepository) protected cronRepository: ICronRepository, @Inject(ICronRepository) protected cronRepository: ICronRepository,
@Inject(ICryptoRepository) protected cryptoRepository: ICryptoRepository, @Inject(ICryptoRepository) protected cryptoRepository: ICryptoRepository,
@Inject(IDatabaseRepository) protected databaseRepository: IDatabaseRepository, @Inject(IDatabaseRepository) protected databaseRepository: IDatabaseRepository,
@Inject(IEventRepository) protected eventRepository: IEventRepository, @Inject(IEventRepository) protected eventRepository: IEventRepository,
@Inject(IJobRepository) protected jobRepository: IJobRepository, @Inject(IJobRepository) protected jobRepository: IJobRepository,
@Inject(IKeyRepository) protected keyRepository: IKeyRepository, protected keyRepository: ApiKeyRepository,
@Inject(ILibraryRepository) protected libraryRepository: ILibraryRepository, @Inject(ILibraryRepository) protected libraryRepository: ILibraryRepository,
@Inject(IMachineLearningRepository) protected machineLearningRepository: IMachineLearningRepository, @Inject(IMachineLearningRepository) protected machineLearningRepository: IMachineLearningRepository,
@Inject(IMapRepository) protected mapRepository: IMapRepository, @Inject(IMapRepository) protected mapRepository: IMapRepository,
@ -90,7 +90,7 @@ export class BaseService {
@Inject(ITrashRepository) protected trashRepository: ITrashRepository, @Inject(ITrashRepository) protected trashRepository: ITrashRepository,
@Inject(IUserRepository) protected userRepository: IUserRepository, @Inject(IUserRepository) protected userRepository: IUserRepository,
@Inject(IVersionHistoryRepository) protected versionRepository: IVersionHistoryRepository, @Inject(IVersionHistoryRepository) protected versionRepository: IVersionHistoryRepository,
@Inject(IViewRepository) protected viewRepository: IViewRepository, protected viewRepository: ViewRepository,
) { ) {
this.logger.setContext(this.constructor.name); this.logger.setContext(this.constructor.name);
this.storageCore = StorageCore.create( this.storageCore = StorageCore.create(

View File

@ -1,5 +1,4 @@
import { PostgresJSDialect } from 'kysely-postgres-js'; import { PostgresJSDialect } from 'kysely-postgres-js';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { import {
DatabaseExtension, DatabaseExtension,
EXTENSION_NAMES, EXTENSION_NAMES,
@ -8,6 +7,7 @@ import {
} from 'src/interfaces/database.interface'; } from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { DatabaseService } from 'src/services/database.service'; import { DatabaseService } from 'src/services/database.service';
import { IConfigRepository } from 'src/types';
import { mockEnvData } from 'test/repositories/config.repository.mock'; import { mockEnvData } from 'test/repositories/config.repository.mock';
import { newTestService } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';

View File

@ -2,11 +2,11 @@ import { BadRequestException } from '@nestjs/common';
import { defaults, SystemConfig } from 'src/config'; import { defaults, SystemConfig } from 'src/config';
import { ImmichWorker } from 'src/enum'; import { ImmichWorker } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IJobRepository, JobCommand, JobItem, JobName, JobStatus, QueueName } from 'src/interfaces/job.interface'; import { IJobRepository, JobCommand, JobItem, JobName, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ITelemetryRepository } from 'src/interfaces/telemetry.interface'; import { ITelemetryRepository } from 'src/interfaces/telemetry.interface';
import { JobService } from 'src/services/job.service'; import { JobService } from 'src/services/job.service';
import { IConfigRepository } from 'src/types';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { newTestService } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';

View File

@ -5,7 +5,6 @@ import { mapLibrary } from 'src/dtos/library.dto';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { AssetType, ImmichWorker } from 'src/enum'; import { AssetType, ImmichWorker } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICronRepository } from 'src/interfaces/cron.interface'; import { ICronRepository } from 'src/interfaces/cron.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { import {
@ -19,6 +18,7 @@ import {
import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { LibraryService } from 'src/services/library.service'; import { LibraryService } from 'src/services/library.service';
import { IConfigRepository } from 'src/types';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { libraryStub } from 'test/fixtures/library.stub'; import { libraryStub } from 'test/fixtures/library.stub';

View File

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

View File

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

View File

@ -7,7 +7,6 @@ import { ExifEntity } from 'src/entities/exif.entity';
import { AssetType, ExifOrientation, ImmichWorker, SourceType } from 'src/enum'; import { AssetType, ExifOrientation, ImmichWorker, SourceType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
@ -20,6 +19,7 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf
import { ITagRepository } from 'src/interfaces/tag.interface'; import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { MetadataService } from 'src/services/metadata.service'; import { MetadataService } from 'src/services/metadata.service';
import { IConfigRepository } from 'src/types';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { fileStub } from 'test/fixtures/file.stub'; import { fileStub } from 'test/fixtures/file.stub';
import { probeStub } from 'test/fixtures/media.stub'; import { probeStub } from 'test/fixtures/media.stub';
@ -1086,7 +1086,9 @@ describe(MetadataService.name, () => {
], ],
[], [],
); );
expect(personMock.updateAll).toHaveBeenCalledWith([{ id: 'random-uuid', faceAssetId: 'random-uuid' }]); expect(personMock.updateAll).toHaveBeenCalledWith([
{ id: 'random-uuid', ownerId: 'admin-id', faceAssetId: 'random-uuid' },
]);
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.GENERATE_PERSON_THUMBNAIL, name: JobName.GENERATE_PERSON_THUMBNAIL,

View File

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

View File

@ -20,8 +20,7 @@ import { faceStub } from 'test/fixtures/face.stub';
import { personStub } from 'test/fixtures/person.stub'; import { personStub } from 'test/fixtures/person.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils'; import { makeStream, newTestService } from 'test/utils';
import { IsNull } from 'typeorm';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
const responseDto: PersonResponseDto = { const responseDto: PersonResponseDto = {
@ -46,7 +45,7 @@ const face = {
imageHeight: 500, imageHeight: 500,
imageWidth: 400, imageWidth: 400,
}; };
const faceSearch = { faceId, embedding: [1, 2, 3, 4] }; const faceSearch = { faceId, embedding: '[1, 2, 3, 4]' };
const detectFaceMock: DetectedFaces = { const detectFaceMock: DetectedFaces = {
faces: [ faces: [
{ {
@ -495,14 +494,8 @@ describe(PersonService.name, () => {
}); });
it('should delete existing people and faces if forced', async () => { it('should delete existing people and faces if forced', async () => {
personMock.getAll.mockResolvedValue({ personMock.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson]));
items: [faceStub.face1.person, personStub.randomPerson], personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
hasNextPage: false,
});
personMock.getAllFaces.mockResolvedValue({
items: [faceStub.face1],
hasNextPage: false,
});
assetMock.getAll.mockResolvedValue({ assetMock.getAll.mockResolvedValue({
items: [assetStub.image], items: [assetStub.image],
hasNextPage: false, hasNextPage: false,
@ -544,18 +537,12 @@ describe(PersonService.name, () => {
it('should queue missing assets', async () => { it('should queue missing assets', async () => {
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
personMock.getAllFaces.mockResolvedValue({ personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
items: [faceStub.face1],
hasNextPage: false,
});
personMock.getAllWithoutFaces.mockResolvedValue([]); personMock.getAllWithoutFaces.mockResolvedValue([]);
await sut.handleQueueRecognizeFaces({}); await sut.handleQueueRecognizeFaces({});
expect(personMock.getAllFaces).toHaveBeenCalledWith( expect(personMock.getAllFaces).toHaveBeenCalledWith({ personId: null, sourceType: SourceType.MACHINE_LEARNING });
{ skip: 0, take: 1000 },
{ where: { personId: IsNull(), sourceType: SourceType.MACHINE_LEARNING } },
);
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.FACIAL_RECOGNITION, name: JobName.FACIAL_RECOGNITION,
@ -569,19 +556,13 @@ describe(PersonService.name, () => {
it('should queue all assets', async () => { it('should queue all assets', async () => {
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
personMock.getAll.mockResolvedValue({ personMock.getAll.mockReturnValue(makeStream());
items: [], personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
hasNextPage: false,
});
personMock.getAllFaces.mockResolvedValue({
items: [faceStub.face1],
hasNextPage: false,
});
personMock.getAllWithoutFaces.mockResolvedValue([]); personMock.getAllWithoutFaces.mockResolvedValue([]);
await sut.handleQueueRecognizeFaces({ force: true }); await sut.handleQueueRecognizeFaces({ force: true });
expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, {}); expect(personMock.getAllFaces).toHaveBeenCalledWith(undefined);
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.FACIAL_RECOGNITION, name: JobName.FACIAL_RECOGNITION,
@ -595,26 +576,17 @@ describe(PersonService.name, () => {
it('should run nightly if new face has been added since last run', async () => { it('should run nightly if new face has been added since last run', async () => {
personMock.getLatestFaceDate.mockResolvedValue(new Date().toISOString()); personMock.getLatestFaceDate.mockResolvedValue(new Date().toISOString());
personMock.getAllFaces.mockResolvedValue({ personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
items: [faceStub.face1],
hasNextPage: false,
});
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
personMock.getAll.mockResolvedValue({ personMock.getAll.mockReturnValue(makeStream());
items: [], personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
hasNextPage: false,
});
personMock.getAllFaces.mockResolvedValue({
items: [faceStub.face1],
hasNextPage: false,
});
personMock.getAllWithoutFaces.mockResolvedValue([]); personMock.getAllWithoutFaces.mockResolvedValue([]);
await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); await sut.handleQueueRecognizeFaces({ force: true, nightly: true });
expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE);
expect(personMock.getLatestFaceDate).toHaveBeenCalledOnce(); expect(personMock.getLatestFaceDate).toHaveBeenCalledOnce();
expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, {}); expect(personMock.getAllFaces).toHaveBeenCalledWith(undefined);
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.FACIAL_RECOGNITION, name: JobName.FACIAL_RECOGNITION,
@ -631,10 +603,7 @@ describe(PersonService.name, () => {
systemMock.get.mockResolvedValue({ lastRun: lastRun.toISOString() }); systemMock.get.mockResolvedValue({ lastRun: lastRun.toISOString() });
personMock.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString()); personMock.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString());
personMock.getAllFaces.mockResolvedValue({ personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
items: [faceStub.face1],
hasNextPage: false,
});
personMock.getAllWithoutFaces.mockResolvedValue([]); personMock.getAllWithoutFaces.mockResolvedValue([]);
await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); await sut.handleQueueRecognizeFaces({ force: true, nightly: true });
@ -648,15 +617,8 @@ describe(PersonService.name, () => {
it('should delete existing people if forced', async () => { it('should delete existing people if forced', async () => {
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
personMock.getAll.mockResolvedValue({ personMock.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson]));
items: [faceStub.face1.person, personStub.randomPerson], personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
hasNextPage: false,
});
personMock.getAllFaces.mockResolvedValue({
items: [faceStub.face1],
hasNextPage: false,
});
personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]);
await sut.handleQueueRecognizeFaces({ force: true }); await sut.handleQueueRecognizeFaces({ force: true });

View File

@ -50,7 +50,6 @@ import { ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc'; import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
import { IsNull } from 'typeorm';
@Injectable() @Injectable()
export class PersonService extends BaseService { export class PersonService extends BaseService {
@ -306,7 +305,7 @@ export class PersonService extends BaseService {
); );
this.logger.debug(`${faces.length} faces detected in ${previewFile.path}`); this.logger.debug(`${faces.length} faces detected in ${previewFile.path}`);
const facesToAdd: (Partial<AssetFaceEntity> & { id: string })[] = []; const facesToAdd: (Partial<AssetFaceEntity> & { id: string; assetId: string })[] = [];
const embeddings: FaceSearchEntity[] = []; const embeddings: FaceSearchEntity[] = [];
const mlFaceIds = new Set<string>(); const mlFaceIds = new Set<string>();
for (const face of asset.faces) { for (const face of asset.faces) {
@ -414,18 +413,22 @@ export class PersonService extends BaseService {
} }
const lastRun = new Date().toISOString(); const lastRun = new Date().toISOString();
const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => const facePagination = this.personRepository.getAllFaces(
this.personRepository.getAllFaces(pagination, { force ? undefined : { personId: null, sourceType: SourceType.MACHINE_LEARNING },
where: force ? undefined : { personId: IsNull(), sourceType: SourceType.MACHINE_LEARNING },
}),
); );
for await (const page of facePagination) { let jobs: { name: JobName.FACIAL_RECOGNITION; data: { id: string; deferred: false } }[] = [];
await this.jobRepository.queueAll( for await (const face of facePagination) {
page.map((face) => ({ name: JobName.FACIAL_RECOGNITION, data: { id: face.id, deferred: false } })), jobs.push({ name: JobName.FACIAL_RECOGNITION, data: { id: face.id, deferred: false } });
);
if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) {
await this.jobRepository.queueAll(jobs);
jobs = [];
}
} }
await this.jobRepository.queueAll(jobs);
await this.systemMetadataRepository.set(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun }); await this.systemMetadataRepository.set(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun });
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
@ -441,7 +444,7 @@ export class PersonService extends BaseService {
const face = await this.personRepository.getFaceByIdWithAssets( const face = await this.personRepository.getFaceByIdWithAssets(
id, id,
{ person: true, asset: true, faceSearch: true }, { person: true, asset: true, faceSearch: true },
{ id: true, personId: true, sourceType: true, faceSearch: { embedding: true } }, { id: true, personId: true, sourceType: true, faceSearch: true },
); );
if (!face || !face.asset) { if (!face || !face.asset) {
this.logger.warn(`Face ${id} not found`); this.logger.warn(`Face ${id} not found`);

View File

@ -1,13 +1,13 @@
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { ImmichWorker } from 'src/enum'; import { ImmichWorker } from 'src/enum';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SmartInfoService } from 'src/services/smart-info.service'; import { SmartInfoService } from 'src/services/smart-info.service';
import { IConfigRepository } from 'src/types';
import { getCLIPModelInfo } from 'src/utils/misc'; import { getCLIPModelInfo } from 'src/utils/misc';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
@ -284,7 +284,7 @@ describe(SmartInfoService.name, () => {
}); });
it('should save the returned objects', async () => { it('should save the returned objects', async () => {
machineLearningMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]); machineLearningMock.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]');
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS);
@ -293,7 +293,7 @@ describe(SmartInfoService.name, () => {
'/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }), expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
); );
expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, [0.01, 0.02, 0.03]); expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]');
}); });
it('should skip invisible assets', async () => { it('should skip invisible assets', async () => {
@ -315,7 +315,7 @@ describe(SmartInfoService.name, () => {
}); });
it('should wait for database', async () => { it('should wait for database', async () => {
machineLearningMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]); machineLearningMock.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]');
databaseMock.isBusy.mockReturnValue(true); databaseMock.isBusy.mockReturnValue(true);
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS);
@ -326,7 +326,7 @@ describe(SmartInfoService.name, () => {
'/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }), expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
); );
expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, [0.01, 0.02, 0.03]); expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]');
}); });
}); });

View File

@ -141,7 +141,10 @@ describe(StackService.name, () => {
await sut.update(authStub.admin, 'stack-id', { primaryAssetId: assetStub.image1.id }); await sut.update(authStub.admin, 'stack-id', { primaryAssetId: assetStub.image1.id });
expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); expect(stackMock.getById).toHaveBeenCalledWith('stack-id');
expect(stackMock.update).toHaveBeenCalledWith({ id: 'stack-id', primaryAssetId: assetStub.image1.id }); expect(stackMock.update).toHaveBeenCalledWith('stack-id', {
id: 'stack-id',
primaryAssetId: assetStub.image1.id,
});
expect(eventMock.emit).toHaveBeenCalledWith('stack.update', { expect(eventMock.emit).toHaveBeenCalledWith('stack.update', {
stackId: 'stack-id', stackId: 'stack-id',
userId: authStub.admin.user.id, userId: authStub.admin.user.id,

View File

@ -39,7 +39,7 @@ export class StackService extends BaseService {
throw new BadRequestException('Primary asset must be in the stack'); throw new BadRequestException('Primary asset must be in the stack');
} }
const updatedStack = await this.stackRepository.update({ id, primaryAssetId: dto.primaryAssetId }); const updatedStack = await this.stackRepository.update(id, { id, primaryAssetId: dto.primaryAssetId });
await this.eventRepository.emit('stack.update', { stackId: id, userId: auth.user.id }); await this.eventRepository.emit('stack.update', { stackId: id, userId: auth.user.id });

View File

@ -1,9 +1,9 @@
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { StorageService } from 'src/services/storage.service'; import { StorageService } from 'src/services/storage.service';
import { IConfigRepository } from 'src/types';
import { ImmichStartupError } from 'src/utils/misc'; import { ImmichStartupError } from 'src/utils/misc';
import { mockEnvData } from 'test/repositories/config.repository.mock'; import { mockEnvData } from 'test/repositories/config.repository.mock';
import { newTestService } from 'test/utils'; import { newTestService } from 'test/utils';

Some files were not shown because too many files have changed in this diff Show More