From 97ec3b147c8a1cc580e77231d84959f86012126a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=BCtz?= Date: Sun, 19 Jan 2025 08:26:25 -0800 Subject: [PATCH 01/24] fix(deps): use node-addon-api v8 (#15438) --- server/package-lock.json | 57 +++++++++++----------------------------- server/package.json | 1 + 2 files changed, 16 insertions(+), 42 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index bb00426c0f..338f89bfb4 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -104,6 +104,7 @@ "globals": "^15.9.0", "kysely-codegen": "^0.16.3", "mock-fs": "^5.2.0", + "node-addon-api": "^8.3.0", "pngjs": "^7.0.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^4.0.0", @@ -6436,6 +6437,12 @@ "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": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", @@ -11128,10 +11135,14 @@ "license": "MIT" }, "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" + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.0.tgz", + "integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } }, "node_modules/node-emoji": { "version": "1.11.0", @@ -16205,44 +16216,6 @@ "engines": { "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" - } } } } diff --git a/server/package.json b/server/package.json index 98226b20d9..30af194460 100644 --- a/server/package.json +++ b/server/package.json @@ -130,6 +130,7 @@ "globals": "^15.9.0", "kysely-codegen": "^0.16.3", "mock-fs": "^5.2.0", + "node-addon-api": "^8.3.0", "pngjs": "^7.0.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^4.0.0", From 70809c146514775f7169db3812e71879caa53be9 Mon Sep 17 00:00:00 2001 From: David Wolff Date: Sun, 19 Jan 2025 19:01:21 +0100 Subject: [PATCH 02/24] fix(server): searching for multiple people yields false positives (#15447) --- server/src/entities/asset.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 401f599d6f..69d4345f44 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -248,7 +248,7 @@ export function hasPeopleCte(db: Kysely, personIds: string[]) { .select('assetId') .where('personId', '=', anyUuid(personIds!)) .groupBy('assetId') - .having((eb) => eb.fn.count('personId'), '>=', personIds.length), + .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length), ); } From a0b2c69b99edbeb108cabf9e362fbf0a261fa78e Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 20 Jan 2025 07:25:43 -0600 Subject: [PATCH 03/24] fix(mobile): cannot get new photos on Android (#15461) --- mobile/lib/repositories/album_media.repository.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/lib/repositories/album_media.repository.dart b/mobile/lib/repositories/album_media.repository.dart index dac9ccd4da..c3795f75df 100644 --- a/mobile/lib/repositories/album_media.repository.dart +++ b/mobile/lib/repositories/album_media.repository.dart @@ -14,6 +14,7 @@ class AlbumMediaRepository implements IAlbumMediaRepository { final List assetPathEntities = await PhotoManager.getAssetPathList( hasAll: true, + filterOption: FilterOptionGroup(containsPathModified: true), ); return assetPathEntities.map(_toAlbum).toList(); } From 6fdb8f83f0a0f0970ff675db4b1f377cd8c7c986 Mon Sep 17 00:00:00 2001 From: Yan-Ru Huang <14368787+r1235613@users.noreply.github.com> Date: Mon, 20 Jan 2025 22:22:05 +0800 Subject: [PATCH 04/24] feat: Add rule on robots.txt to allow robots access og tags (#15470) Allow social media access og tags --- web/static/robots.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/static/robots.txt b/web/static/robots.txt index b21f0887ac..9be576a0f5 100644 --- a/web/static/robots.txt +++ b/web/static/robots.txt @@ -1,3 +1,8 @@ +# Allow social media access og Tags +User-agent: * +Allow: /share/ +Allow: /api/assets/ + # https://www.robotstxt.org/robotstxt.html User-agent: * Disallow: / From 07698f8a405302a726b734bc119e08026cfe6500 Mon Sep 17 00:00:00 2001 From: Aaron Rodrigues <38214417+aaronjrodrigues@users.noreply.github.com> Date: Tue, 21 Jan 2025 00:14:49 +0200 Subject: [PATCH 05/24] fix: grammar on docs homepage (#15455) Fix grammar on index.tsx --- docs/src/pages/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index a5dbc7aa98..b3cf10b810 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -73,9 +73,9 @@ function HomepageHeader() { />
-

Download mobile app

+

Download the mobile app

- 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

From 345791c0e6f4a60938ed18e81a781cbc93ea4a13 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 Jan 2025 21:38:50 -0500 Subject: [PATCH 06/24] chore(deps): update machine-learning (#15476) --- machine-learning/Dockerfile | 2 +- machine-learning/poetry.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 7b0f97c1cf..df2b5b95fe 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ 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 diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index ebfd075c7c..c7944b3f51 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -1625,13 +1625,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"] [[package]] name = "locust" -version = "2.32.5" +version = "2.32.6" description = "Developer-friendly load testing framework" optional = false python-versions = ">=3.9" files = [ - {file = "locust-2.32.5-py3-none-any.whl", hash = "sha256:2f49509868ffc2e368be40921c6825f92147c84e997206760a85dab3058f5efb"}, - {file = "locust-2.32.5.tar.gz", hash = "sha256:ea7bc1e8ce2520e8893c471b4b0a56a4f53b01b4b618adfe8d2c8ab2728b5821"}, + {file = "locust-2.32.6-py3-none-any.whl", hash = "sha256:d5c0e4f73134415d250087034431cf3ea42ca695d3dee7f10812287cacb6c4ef"}, + {file = "locust-2.32.6.tar.gz", hash = "sha256:6600cc308398e724764aacc56ccddf6cfcd0127c4c92dedd5c4979dd37ef5b15"}, ] [package.dependencies] From 1d0d4fc281a6d97986ef138c55e8bda7d622ad5f Mon Sep 17 00:00:00 2001 From: Tempest <110401501+1-tempest@users.noreply.github.com> Date: Mon, 20 Jan 2025 20:39:14 -0600 Subject: [PATCH 07/24] feat: Allow multiple ML models to be preloaded (#15418) --- docs/docs/install/environment-variables.md | 8 ++++---- machine-learning/app/main.py | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index b4cb905a0c..a57eef540d 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -159,10 +159,10 @@ Redis (Sentinel) URL example JSON before encoding: | `MACHINE_LEARNING_WORKERS`\*2 | Number of worker processes to spawn | `1` | machine learning | | `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`\*3 | 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_PRELOAD__CLIP__TEXTUAL` | Name of the textual CLIP model to be preloaded and kept in 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__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__DETECTION` | Name of the detection portion of the facial recognition 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` | Comma-separated list of (visual) CLIP model(s) to preload and 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` | 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_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 | diff --git a/machine-learning/app/main.py b/machine-learning/app/main.py index fb6f84499a..0f50257a4e 100644 --- a/machine-learning/app/main.py +++ b/machine-learning/app/main.py @@ -77,29 +77,31 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: async def preload_models(preload: PreloadModelData) -> None: 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: - model = await model_cache.get(preload.clip.textual, ModelType.TEXTUAL, ModelTask.SEARCH) - await load(model) + await load_models(preload.clip.textual, ModelType.TEXTUAL, ModelTask.SEARCH) if preload.clip.visual is not None: - model = await model_cache.get(preload.clip.visual, ModelType.VISUAL, ModelTask.SEARCH) - await load(model) + await load_models(preload.clip.visual, ModelType.VISUAL, ModelTask.SEARCH) if preload.facial_recognition.detection is not None: - model = await model_cache.get( + await load_models( preload.facial_recognition.detection, ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION, ) - await load(model) if preload.facial_recognition.recognition is not None: - model = await model_cache.get( + await load_models( preload.facial_recognition.recognition, ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION, ) - await load(model) if preload.clip_fallback is not None: log.warning( From 887267b133738ff27a718450baaf04e8a7d8fb06 Mon Sep 17 00:00:00 2001 From: Jeff Sloyer Date: Mon, 20 Jan 2025 23:20:03 -0500 Subject: [PATCH 08/24] fix: broken link on monitoring page (#15478) * fix: broken link on monitoring page * use absolute link --- docs/docs/features/monitoring.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/features/monitoring.md b/docs/docs/features/monitoring.md index c7ce817e71..64377ec073 100644 --- a/docs/docs/features/monitoring.md +++ b/docs/docs/features/monitoring.md @@ -68,7 +68,7 @@ After bringing down the containers with `docker compose down` and back up with ` :::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. 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 From 318dd323639015c7c6ab623b7e43e81a8c147fe1 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 21 Jan 2025 09:36:28 -0600 Subject: [PATCH 09/24] refactor: migrate stack repo to kysely (#15440) * wip * wip: add tags * wip * sql * pr feedback * pr feedback * ergonomic * pr feedback * pr feedback --- server/src/interfaces/stack.interface.ts | 5 +- server/src/queries/stack.repository.sql | 334 +++++-------------- server/src/repositories/access.repository.ts | 5 +- server/src/repositories/stack.repository.ts | 218 ++++++------ server/src/services/asset.service.spec.ts | 2 +- server/src/services/asset.service.ts | 2 +- server/src/services/stack.service.spec.ts | 5 +- server/src/services/stack.service.ts | 2 +- 8 files changed, 209 insertions(+), 364 deletions(-) diff --git a/server/src/interfaces/stack.interface.ts b/server/src/interfaces/stack.interface.ts index 378f63fd95..a9fb8cec76 100644 --- a/server/src/interfaces/stack.interface.ts +++ b/server/src/interfaces/stack.interface.ts @@ -1,3 +1,4 @@ +import { Updateable } from 'kysely'; import { StackEntity } from 'src/entities/stack.entity'; export const IStackRepository = 'IStackRepository'; @@ -10,8 +11,8 @@ export interface StackSearch { export interface IStackRepository { search(query: StackSearch): Promise; create(stack: { ownerId: string; assetIds: string[] }): Promise; - update(stack: Pick & Partial): Promise; + update(id: string, entity: Updateable): Promise; delete(id: string): Promise; deleteAll(ids: string[]): Promise; - getById(id: string): Promise; + getById(id: string): Promise; } diff --git a/server/src/queries/stack.repository.sql b/server/src/queries/stack.repository.sql index f7da019f05..54f86c94af 100644 --- a/server/src/queries/stack.repository.sql +++ b/server/src/queries/stack.repository.sql @@ -1,257 +1,95 @@ -- NOTE: This file is auto generated by ./sql-generator -- StackRepository.search -SELECT - "StackEntity"."id" AS "StackEntity_id", - "StackEntity"."ownerId" AS "StackEntity_ownerId", - "StackEntity"."primaryAssetId" AS "StackEntity_primaryAssetId", - "StackEntity__StackEntity_assets"."id" AS "StackEntity__StackEntity_assets_id", - "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", - "StackEntity__StackEntity_assets"."deviceId" AS "StackEntity__StackEntity_assets_deviceId", - "StackEntity__StackEntity_assets"."type" AS "StackEntity__StackEntity_assets_type", - "StackEntity__StackEntity_assets"."status" AS "StackEntity__StackEntity_assets_status", - "StackEntity__StackEntity_assets"."originalPath" AS "StackEntity__StackEntity_assets_originalPath", - "StackEntity__StackEntity_assets"."thumbhash" AS "StackEntity__StackEntity_assets_thumbhash", - "StackEntity__StackEntity_assets"."encodedVideoPath" AS "StackEntity__StackEntity_assets_encodedVideoPath", - "StackEntity__StackEntity_assets"."createdAt" AS "StackEntity__StackEntity_assets_createdAt", - "StackEntity__StackEntity_assets"."updatedAt" AS "StackEntity__StackEntity_assets_updatedAt", - "StackEntity__StackEntity_assets"."deletedAt" AS "StackEntity__StackEntity_assets_deletedAt", - "StackEntity__StackEntity_assets"."fileCreatedAt" AS "StackEntity__StackEntity_assets_fileCreatedAt", - "StackEntity__StackEntity_assets"."localDateTime" AS "StackEntity__StackEntity_assets_localDateTime", - "StackEntity__StackEntity_assets"."fileModifiedAt" AS "StackEntity__StackEntity_assets_fileModifiedAt", - "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)) +select + "asset_stack".*, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "assets" + where + "assets"."deletedAt" is null + and "assets"."stackId" = "asset_stack"."id" + ) as agg + ) as "assets" +from + "asset_stack" +where + "asset_stack"."ownerId" = $1 -- StackRepository.delete -SELECT DISTINCT - "distinctAlias"."StackEntity_id" AS "ids_StackEntity_id", - "distinctAlias"."StackEntity__StackEntity_assets_fileCreatedAt" -FROM +select + *, ( - SELECT - "StackEntity"."id" AS "StackEntity_id", - "StackEntity"."ownerId" AS "StackEntity_ownerId", - "StackEntity"."primaryAssetId" AS "StackEntity_primaryAssetId", - "StackEntity__StackEntity_assets"."id" AS "StackEntity__StackEntity_assets_id", - "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", - "StackEntity__StackEntity_assets"."deviceId" AS "StackEntity__StackEntity_assets_deviceId", - "StackEntity__StackEntity_assets"."type" AS "StackEntity__StackEntity_assets_type", - "StackEntity__StackEntity_assets"."status" AS "StackEntity__StackEntity_assets_status", - "StackEntity__StackEntity_assets"."originalPath" AS "StackEntity__StackEntity_assets_originalPath", - "StackEntity__StackEntity_assets"."thumbhash" AS "StackEntity__StackEntity_assets_thumbhash", - "StackEntity__StackEntity_assets"."encodedVideoPath" AS "StackEntity__StackEntity_assets_encodedVideoPath", - "StackEntity__StackEntity_assets"."createdAt" AS "StackEntity__StackEntity_assets_createdAt", - "StackEntity__StackEntity_assets"."updatedAt" AS "StackEntity__StackEntity_assets_updatedAt", - "StackEntity__StackEntity_assets"."deletedAt" AS "StackEntity__StackEntity_assets_deletedAt", - "StackEntity__StackEntity_assets"."fileCreatedAt" AS "StackEntity__StackEntity_assets_fileCreatedAt", - "StackEntity__StackEntity_assets"."localDateTime" AS "StackEntity__StackEntity_assets_localDateTime", - "StackEntity__StackEntity_assets"."fileModifiedAt" AS "StackEntity__StackEntity_assets_fileModifiedAt", - "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", - "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 + select + coalesce(json_agg(agg), '[]') + from + ( + select + *, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "tags".* + from + "tags" + inner join "tag_asset" on "tags"."id" = "tag_asset"."tagsId" + where + "tag_asset"."assetsId" = "assets"."id" + ) as agg + ) as "tags" + from + "assets" + where + "assets"."deletedAt" is null + and "assets"."stackId" = "asset_stack"."id" + ) as agg + ) as "assets" +from + "asset_stack" +where + "id" = $1::uuid -- StackRepository.getById -SELECT DISTINCT - "distinctAlias"."StackEntity_id" AS "ids_StackEntity_id", - "distinctAlias"."StackEntity__StackEntity_assets_fileCreatedAt" -FROM +select + *, ( - SELECT - "StackEntity"."id" AS "StackEntity_id", - "StackEntity"."ownerId" AS "StackEntity_ownerId", - "StackEntity"."primaryAssetId" AS "StackEntity_primaryAssetId", - "StackEntity__StackEntity_assets"."id" AS "StackEntity__StackEntity_assets_id", - "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", - "StackEntity__StackEntity_assets"."deviceId" AS "StackEntity__StackEntity_assets_deviceId", - "StackEntity__StackEntity_assets"."type" AS "StackEntity__StackEntity_assets_type", - "StackEntity__StackEntity_assets"."status" AS "StackEntity__StackEntity_assets_status", - "StackEntity__StackEntity_assets"."originalPath" AS "StackEntity__StackEntity_assets_originalPath", - "StackEntity__StackEntity_assets"."thumbhash" AS "StackEntity__StackEntity_assets_thumbhash", - "StackEntity__StackEntity_assets"."encodedVideoPath" AS "StackEntity__StackEntity_assets_encodedVideoPath", - "StackEntity__StackEntity_assets"."createdAt" AS "StackEntity__StackEntity_assets_createdAt", - "StackEntity__StackEntity_assets"."updatedAt" AS "StackEntity__StackEntity_assets_updatedAt", - "StackEntity__StackEntity_assets"."deletedAt" AS "StackEntity__StackEntity_assets_deletedAt", - "StackEntity__StackEntity_assets"."fileCreatedAt" AS "StackEntity__StackEntity_assets_fileCreatedAt", - "StackEntity__StackEntity_assets"."localDateTime" AS "StackEntity__StackEntity_assets_localDateTime", - "StackEntity__StackEntity_assets"."fileModifiedAt" AS "StackEntity__StackEntity_assets_fileModifiedAt", - "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", - "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 + select + coalesce(json_agg(agg), '[]') + from + ( + select + *, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "tags".* + from + "tags" + inner join "tag_asset" on "tags"."id" = "tag_asset"."tagsId" + where + "tag_asset"."assetsId" = "assets"."id" + ) as agg + ) as "tags" + from + "assets" + where + "assets"."deletedAt" is null + and "assets"."stackId" = "asset_stack"."id" + ) as agg + ) as "assets" +from + "asset_stack" +where + "id" = $1::uuid diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 15288b94fa..4d32950d85 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -36,10 +36,7 @@ class ActivityAccess implements IActivityAccess { .where('activity.id', 'in', [...activityIds]) .where('activity.userId', '=', userId) .execute() - .then((activities) => { - console.log('activities', activities); - return new Set(activities.map((activity) => activity.id)); - }); + .then((activities) => new Set(activities.map((activity) => activity.id))); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) diff --git a/server/src/repositories/stack.repository.ts b/server/src/repositories/stack.repository.ts index 2887fbeb96..6a80c1f59c 100644 --- a/server/src/repositories/stack.repository.ts +++ b/server/src/repositories/stack.repository.ts @@ -1,84 +1,113 @@ 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 { AssetEntity } from 'src/entities/asset.entity'; import { StackEntity } from 'src/entities/stack.entity'; import { IStackRepository, StackSearch } from 'src/interfaces/stack.interface'; -import { DataSource, In, Repository } from 'typeorm'; +import { asUuid } from 'src/utils/database'; + +const withAssets = (eb: ExpressionBuilder, 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() export class StackRepository implements IStackRepository { - constructor( - @InjectDataSource() private dataSource: DataSource, - @InjectRepository(StackEntity) private repository: Repository, - ) {} + constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [{ ownerId: DummyValue.UUID }] }) search(query: StackSearch): Promise { - return this.repository.find({ - where: { - ownerId: query.ownerId, - primaryAssetId: query.primaryAssetId, - }, - relations: { - assets: { - exifInfo: true, - }, - }, - }); + return this.db + .selectFrom('asset_stack') + .selectAll('asset_stack') + .select(withAssets) + .where('asset_stack.ownerId', '=', query.ownerId) + .$if(!!query.primaryAssetId, (eb) => eb.where('asset_stack.primaryAssetId', '=', query.primaryAssetId!)) + .execute() as unknown as Promise; } async create(entity: { ownerId: string; assetIds: string[] }): Promise { - return this.dataSource.manager.transaction(async (manager) => { - const stackRepository = manager.getRepository(StackEntity); - - const stacks = await stackRepository.find({ - where: { - ownerId: entity.ownerId, - primaryAssetId: In(entity.assetIds), - }, - select: { - id: true, - assets: { - id: true, - }, - }, - relations: { - assets: { - exifInfo: true, - }, - }, - }); + return this.db.transaction().execute(async (tx) => { + const stacks = await tx + .selectFrom('asset_stack') + .where('asset_stack.ownerId', '=', entity.ownerId) + .where('asset_stack.primaryAssetId', 'in', entity.assetIds) + .select('asset_stack.id') + .select((eb) => + jsonArrayFrom( + eb + .selectFrom('assets') + .select('assets.id') + .whereRef('assets.stackId', '=', 'asset_stack.id') + .where('assets.deletedAt', 'is', null), + ).as('assets'), + ) + .execute(); const assetIds = new Set(entity.assetIds); // children for (const stack of stacks) { - for (const asset of stack.assets) { - assetIds.add(asset.id); + if (stack.assets && stack.assets.length > 0) { + for (const asset of stack.assets) { + assetIds.add(asset.id); + } } } 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({ - ownerId: entity.ownerId, - primaryAssetId: entity.assetIds[0], - assets: [...assetIds].map((id) => ({ id }) as AssetEntity), - }); + const newRecord = await tx + .insertInto('asset_stack') + .values({ + ownerId: entity.ownerId, + primaryAssetId: entity.assetIds[0], + }) + .returning('id') + .executeTakeFirstOrThrow(); - return stackRepository.findOneOrFail({ - where: { - id, - }, - relations: { - assets: { - exifInfo: true, - }, - }, - }); + await tx + .updateTable('assets') + .set({ + stackId: newRecord.id, + updatedAt: new Date(), + }) + .where('id', 'in', [...assetIds]) + .execute(); + + return tx + .selectFrom('asset_stack') + .selectAll('asset_stack') + .select(withAssets) + .where('id', '=', newRecord.id) + .executeTakeFirst() as unknown as Promise; }); } @@ -91,12 +120,12 @@ export class StackRepository implements IStackRepository { const assetIds = stack.assets.map(({ id }) => id); - await this.repository.delete(id); - - // Update assets updatedAt - await this.dataSource.manager.update(AssetEntity, assetIds, { - updatedAt: new Date(), - }); + await this.db.deleteFrom('asset_stack').where('id', '=', asUuid(id)).execute(); + await this.db + .updateTable('assets') + .set({ stackId: null, updatedAt: new Date() }) + .where('id', 'in', assetIds) + .execute(); } async deleteAll(ids: string[]): Promise { @@ -110,54 +139,31 @@ export class StackRepository implements IStackRepository { assetIds.push(...stack.assets.map(({ id }) => id)); } - await this.repository.delete(ids); - - // Update assets updatedAt - await this.dataSource.manager.update(AssetEntity, assetIds, { - updatedAt: new Date(), - }); + await this.db + .updateTable('assets') + .set({ updatedAt: new Date(), stackId: null }) + .where('id', 'in', assetIds) + .where('stackId', 'in', ids) + .execute(); } - update(entity: Partial) { - return this.save(entity); + update(id: string, entity: Updateable): Promise { + 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; } @GenerateSql({ params: [DummyValue.UUID] }) - async getById(id: string): Promise { - return this.repository.findOne({ - where: { - id, - }, - relations: { - assets: { - exifInfo: true, - tags: true, - }, - }, - order: { - assets: { - fileCreatedAt: 'ASC', - }, - }, - }); - } - - private async save(entity: Partial) { - const { id } = await this.repository.save(entity); - return this.repository.findOneOrFail({ - where: { - id, - }, - relations: { - assets: { - exifInfo: true, - }, - }, - order: { - assets: { - fileCreatedAt: 'ASC', - }, - }, - }); + getById(id: string): Promise { + return this.db + .selectFrom('asset_stack') + .selectAll() + .select((eb) => withAssets(eb, true)) + .where('id', '=', asUuid(id)) + .executeTakeFirst() as Promise; } } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index cc8f0a1ab0..bf36c181fc 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -520,7 +520,7 @@ describe(AssetService.name, () => { await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); - expect(stackMock.update).toHaveBeenCalledWith({ + expect(stackMock.update).toHaveBeenCalledWith('stack-1', { id: 'stack-1', primaryAssetId: 'stack-child-asset-1', }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index de4c0fe0f1..3913c0ce4c 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -192,7 +192,7 @@ export class AssetService extends BaseService { const stackAssetIds = asset.stack.assets.map((a) => a.id); if (stackAssetIds.length > 2) { const newPrimaryAssetId = stackAssetIds.find((a) => a !== id)!; - await this.stackRepository.update({ + await this.stackRepository.update(asset.stack.id, { id: asset.stack.id, primaryAssetId: newPrimaryAssetId, }); diff --git a/server/src/services/stack.service.spec.ts b/server/src/services/stack.service.spec.ts index 4e8813145c..f37e2c4af4 100644 --- a/server/src/services/stack.service.spec.ts +++ b/server/src/services/stack.service.spec.ts @@ -141,7 +141,10 @@ describe(StackService.name, () => { await sut.update(authStub.admin, 'stack-id', { primaryAssetId: assetStub.image1.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', { stackId: 'stack-id', userId: authStub.admin.user.id, diff --git a/server/src/services/stack.service.ts b/server/src/services/stack.service.ts index 58fccc8be2..29413109c5 100644 --- a/server/src/services/stack.service.ts +++ b/server/src/services/stack.service.ts @@ -39,7 +39,7 @@ export class StackService extends BaseService { 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 }); From b0cdd8f4757b496aff37bf65dabaeae9b77a8e74 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 21 Jan 2025 11:09:24 -0500 Subject: [PATCH 10/24] refactor: access repository (#15490) --- server/src/interfaces/access.interface.ts | 53 ------- server/src/repositories/access.repository.ts | 129 ++++++++---------- server/src/repositories/index.ts | 3 +- server/src/services/base.service.ts | 4 +- server/src/types.ts | 2 + server/src/utils/access.ts | 10 +- server/src/utils/asset.util.ts | 6 +- .../repositories/access.repository.mock.ts | 15 +- server/test/utils.ts | 5 +- 9 files changed, 75 insertions(+), 152 deletions(-) delete mode 100644 server/src/interfaces/access.interface.ts diff --git a/server/src/interfaces/access.interface.ts b/server/src/interfaces/access.interface.ts deleted file mode 100644 index d8d7b4e807..0000000000 --- a/server/src/interfaces/access.interface.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { AlbumUserRole } from 'src/enum'; - -export const IAccessRepository = 'IAccessRepository'; - -export interface IAccessRepository { - activity: { - checkOwnerAccess(userId: string, activityIds: Set): Promise>; - checkAlbumOwnerAccess(userId: string, activityIds: Set): Promise>; - checkCreateAccess(userId: string, albumIds: Set): Promise>; - }; - - asset: { - checkOwnerAccess(userId: string, assetIds: Set): Promise>; - checkAlbumAccess(userId: string, assetIds: Set): Promise>; - checkPartnerAccess(userId: string, assetIds: Set): Promise>; - checkSharedLinkAccess(sharedLinkId: string, assetIds: Set): Promise>; - }; - - authDevice: { - checkOwnerAccess(userId: string, deviceIds: Set): Promise>; - }; - - album: { - checkOwnerAccess(userId: string, albumIds: Set): Promise>; - checkSharedAlbumAccess(userId: string, albumIds: Set, access: AlbumUserRole): Promise>; - checkSharedLinkAccess(sharedLinkId: string, albumIds: Set): Promise>; - }; - - timeline: { - checkPartnerAccess(userId: string, partnerIds: Set): Promise>; - }; - - memory: { - checkOwnerAccess(userId: string, memoryIds: Set): Promise>; - }; - - person: { - checkFaceOwnerAccess(userId: string, assetFaceId: Set): Promise>; - checkOwnerAccess(userId: string, personIds: Set): Promise>; - }; - - partner: { - checkUpdateAccess(userId: string, partnerIds: Set): Promise>; - }; - - stack: { - checkOwnerAccess(userId: string, stackIds: Set): Promise>; - }; - - tag: { - checkOwnerAccess(userId: string, tagIds: Set): Promise>; - }; -} diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 4d32950d85..9fa8b6243c 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -1,33 +1,18 @@ -import { Injectable } from '@nestjs/common'; import { Kysely, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DB } from 'src/db'; import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; - import { AlbumUserRole } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; import { asUuid } from 'src/utils/database'; -type IActivityAccess = IAccessRepository['activity']; -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 { +class ActivityAccess { constructor(private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkOwnerAccess(userId: string, activityIds: Set): Promise> { + async checkOwnerAccess(userId: string, activityIds: Set) { if (activityIds.size === 0) { - return new Set(); + return new Set(); } return this.db @@ -41,9 +26,9 @@ class ActivityAccess implements IActivityAccess { @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkAlbumOwnerAccess(userId: string, activityIds: Set): Promise> { + async checkAlbumOwnerAccess(userId: string, activityIds: Set) { if (activityIds.size === 0) { - return new Set(); + return new Set(); } return this.db @@ -58,9 +43,9 @@ class ActivityAccess implements IActivityAccess { @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkCreateAccess(userId: string, albumIds: Set): Promise> { + async checkCreateAccess(userId: string, albumIds: Set) { if (albumIds.size === 0) { - return new Set(); + return new Set(); } return this.db @@ -77,14 +62,14 @@ class ActivityAccess implements IActivityAccess { } } -class AlbumAccess implements IAlbumAccess { +class AlbumAccess { constructor(private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkOwnerAccess(userId: string, albumIds: Set): Promise> { + async checkOwnerAccess(userId: string, albumIds: Set) { if (albumIds.size === 0) { - return new Set(); + return new Set(); } return this.db @@ -99,9 +84,9 @@ class AlbumAccess implements IAlbumAccess { @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkSharedAlbumAccess(userId: string, albumIds: Set, access: AlbumUserRole): Promise> { + async checkSharedAlbumAccess(userId: string, albumIds: Set, access: AlbumUserRole) { if (albumIds.size === 0) { - return new Set(); + return new Set(); } const accessRole = @@ -122,9 +107,9 @@ class AlbumAccess implements IAlbumAccess { @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkSharedLinkAccess(sharedLinkId: string, albumIds: Set): Promise> { + async checkSharedLinkAccess(sharedLinkId: string, albumIds: Set) { if (albumIds.size === 0) { - return new Set(); + return new Set(); } return this.db @@ -139,14 +124,14 @@ class AlbumAccess implements IAlbumAccess { } } -class AssetAccess implements IAssetAccess { +class AssetAccess { constructor(private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkAlbumAccess(userId: string, assetIds: Set): Promise> { + async checkAlbumAccess(userId: string, assetIds: Set) { if (assetIds.size === 0) { - return new Set(); + return new Set(); } return this.db @@ -182,9 +167,9 @@ class AssetAccess implements IAssetAccess { @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkOwnerAccess(userId: string, assetIds: Set): Promise> { + async checkOwnerAccess(userId: string, assetIds: Set) { if (assetIds.size === 0) { - return new Set(); + return new Set(); } return this.db @@ -198,9 +183,9 @@ class AssetAccess implements IAssetAccess { @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkPartnerAccess(userId: string, assetIds: Set): Promise> { + async checkPartnerAccess(userId: string, assetIds: Set) { if (assetIds.size === 0) { - return new Set(); + return new Set(); } return this.db @@ -221,9 +206,9 @@ class AssetAccess implements IAssetAccess { @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkSharedLinkAccess(sharedLinkId: string, assetIds: Set): Promise> { + async checkSharedLinkAccess(sharedLinkId: string, assetIds: Set) { if (assetIds.size === 0) { - return new Set(); + return new Set(); } return this.db @@ -273,14 +258,14 @@ class AssetAccess implements IAssetAccess { } } -class AuthDeviceAccess implements IAuthDeviceAccess { +class AuthDeviceAccess { constructor(private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkOwnerAccess(userId: string, deviceIds: Set): Promise> { + async checkOwnerAccess(userId: string, deviceIds: Set) { if (deviceIds.size === 0) { - return new Set(); + return new Set(); } return this.db @@ -293,14 +278,14 @@ class AuthDeviceAccess implements IAuthDeviceAccess { } } -class StackAccess implements IStackAccess { +class StackAccess { constructor(private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkOwnerAccess(userId: string, stackIds: Set): Promise> { + async checkOwnerAccess(userId: string, stackIds: Set) { if (stackIds.size === 0) { - return new Set(); + return new Set(); } return this.db @@ -313,14 +298,14 @@ class StackAccess implements IStackAccess { } } -class TimelineAccess implements ITimelineAccess { +class TimelineAccess { constructor(private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkPartnerAccess(userId: string, partnerIds: Set): Promise> { + async checkPartnerAccess(userId: string, partnerIds: Set) { if (partnerIds.size === 0) { - return new Set(); + return new Set(); } return this.db @@ -333,14 +318,14 @@ class TimelineAccess implements ITimelineAccess { } } -class MemoryAccess implements IMemoryAccess { +class MemoryAccess { constructor(private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkOwnerAccess(userId: string, memoryIds: Set): Promise> { + async checkOwnerAccess(userId: string, memoryIds: Set) { if (memoryIds.size === 0) { - return new Set(); + return new Set(); } return this.db @@ -354,14 +339,14 @@ class MemoryAccess implements IMemoryAccess { } } -class PersonAccess implements IPersonAccess { +class PersonAccess { constructor(private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkOwnerAccess(userId: string, personIds: Set): Promise> { + async checkOwnerAccess(userId: string, personIds: Set) { if (personIds.size === 0) { - return new Set(); + return new Set(); } return this.db @@ -375,9 +360,9 @@ class PersonAccess implements IPersonAccess { @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkFaceOwnerAccess(userId: string, assetFaceIds: Set): Promise> { + async checkFaceOwnerAccess(userId: string, assetFaceIds: Set) { if (assetFaceIds.size === 0) { - return new Set(); + return new Set(); } return this.db @@ -393,14 +378,14 @@ class PersonAccess implements IPersonAccess { } } -class PartnerAccess implements IPartnerAccess { +class PartnerAccess { constructor(private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkUpdateAccess(userId: string, partnerIds: Set): Promise> { + async checkUpdateAccess(userId: string, partnerIds: Set) { if (partnerIds.size === 0) { - return new Set(); + return new Set(); } return this.db @@ -413,14 +398,14 @@ class PartnerAccess implements IPartnerAccess { } } -class TagAccess implements ITagAccess { +class TagAccess { constructor(private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkOwnerAccess(userId: string, tagIds: Set): Promise> { + async checkOwnerAccess(userId: string, tagIds: Set) { if (tagIds.size === 0) { - return new Set(); + return new Set(); } return this.db @@ -433,17 +418,17 @@ class TagAccess implements ITagAccess { } } -export class AccessRepository implements IAccessRepository { - activity: IActivityAccess; - album: IAlbumAccess; - asset: IAssetAccess; - authDevice: IAuthDeviceAccess; - memory: IMemoryAccess; - person: IPersonAccess; - partner: IPartnerAccess; - stack: IStackAccess; - tag: ITagAccess; - timeline: ITimelineAccess; +export class AccessRepository { + activity: ActivityAccess; + album: AlbumAccess; + asset: AssetAccess; + authDevice: AuthDeviceAccess; + memory: MemoryAccess; + person: PersonAccess; + partner: PartnerAccess; + stack: StackAccess; + tag: TagAccess; + timeline: TimelineAccess; constructor(@InjectKysely() db: Kysely) { this.activity = new ActivityAccess(db); diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index c48233f08f..8f691ac9e7 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -1,4 +1,3 @@ -import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; @@ -78,11 +77,11 @@ import { ViewRepository } from 'src/repositories/view-repository'; export const repositories = [ // + AccessRepository, ActivityRepository, ]; export const providers = [ - { provide: IAccessRepository, useClass: AccessRepository }, { provide: IAlbumRepository, useClass: AlbumRepository }, { provide: IAlbumUserRepository, useClass: AlbumUserRepository }, { provide: IAssetRepository, useClass: AssetRepository }, diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 9e024daacd..fa77bcc388 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -6,7 +6,6 @@ import { SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { Users } from 'src/db'; import { UserEntity } from 'src/entities/user.entity'; -import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; @@ -44,6 +43,7 @@ import { ITrashRepository } from 'src/interfaces/trash.interface'; import { IUserRepository } from 'src/interfaces/user.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 { AccessRequest, checkAccess, requireAccess } from 'src/utils/access'; import { getConfig, updateConfig } from 'src/utils/config'; @@ -53,7 +53,7 @@ export class BaseService { constructor( @Inject(ILoggerRepository) protected logger: ILoggerRepository, - @Inject(IAccessRepository) protected accessRepository: IAccessRepository, + protected accessRepository: AccessRepository, protected activityRepository: ActivityRepository, @Inject(IAuditRepository) protected auditRepository: IAuditRepository, @Inject(IAlbumRepository) protected albumRepository: IAlbumRepository, diff --git a/server/src/types.ts b/server/src/types.ts index 0d3b037f9e..a6a070dc63 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,5 +1,6 @@ import { UserEntity } from 'src/entities/user.entity'; import { Permission } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; export type AuthApiKey = { @@ -12,6 +13,7 @@ export type AuthApiKey = { export type RepositoryInterface = Pick; export type IActivityRepository = RepositoryInterface; +export type IAccessRepository = { [K in keyof AccessRepository]: RepositoryInterface }; export type ActivityItem = | Awaited> diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index d3219a1a6c..cb91737349 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -2,7 +2,7 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { AuthDto } from 'src/dtos/auth.dto'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { AlbumUserRole, Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; +import { AccessRepository } from 'src/repositories/access.repository'; import { setDifference, setIsEqual, setIsSuperset, setUnion } from 'src/utils/set'; export type GrantedRequest = { @@ -34,7 +34,7 @@ export const requireUploadAccess = (auth: AuthDto | null): AuthDto => { return auth; }; -export const requireAccess = async (access: IAccessRepository, request: AccessRequest) => { +export const requireAccess = async (access: AccessRepository, request: AccessRequest) => { const allowedIds = await checkAccess(access, request); if (!setIsEqual(new Set(request.ids), allowedIds)) { throw new BadRequestException(`Not found or no ${request.permission} access`); @@ -42,7 +42,7 @@ export const requireAccess = async (access: IAccessRepository, request: AccessRe }; export const checkAccess = async ( - access: IAccessRepository, + access: AccessRepository, { ids, auth, permission }: AccessRequest, ): Promise> => { const idSet = Array.isArray(ids) ? new Set(ids) : ids; @@ -56,7 +56,7 @@ export const checkAccess = async ( }; const checkSharedLinkAccess = async ( - access: IAccessRepository, + access: AccessRepository, request: SharedLinkAccessRequest, ): Promise> => { const { sharedLink, permission, ids } = request; @@ -102,7 +102,7 @@ const checkSharedLinkAccess = async ( } }; -const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessRequest): Promise> => { +const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRequest): Promise> => { const { auth, permission, ids } = request; switch (permission) { diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index f8bed5485f..39593a77f3 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -5,12 +5,12 @@ import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, AssetType, Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { AuthRequest } from 'src/middleware/auth.guard'; import { ImmichFile } from 'src/middleware/file-upload.interceptor'; +import { AccessRepository } from 'src/repositories/access.repository'; import { UploadFile } from 'src/services/asset-media.service'; import { checkAccess } from 'src/utils/access'; @@ -31,7 +31,7 @@ export const getAssetFiles = (files?: AssetFileEntity[]) => ({ export const addAssets = async ( auth: AuthDto, - repositories: { access: IAccessRepository; bulk: IBulkAsset }, + repositories: { access: AccessRepository; bulk: IBulkAsset }, dto: { parentId: string; assetIds: string[] }, ) => { const { access, bulk } = repositories; @@ -71,7 +71,7 @@ export const addAssets = async ( export const removeAssets = async ( auth: AuthDto, - repositories: { access: IAccessRepository; bulk: IBulkAsset }, + repositories: { access: AccessRepository; bulk: IBulkAsset }, dto: { parentId: string; assetIds: string[]; canAlwaysRemove: Permission }, ) => { const { access, bulk } = repositories; diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 9e9bf5406b..23886e0495 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -1,18 +1,7 @@ -import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IAccessRepository } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export interface IAccessRepositoryMock { - activity: Mocked; - asset: Mocked; - album: Mocked; - authDevice: Mocked; - memory: Mocked; - person: Mocked; - partner: Mocked; - stack: Mocked; - timeline: Mocked; - tag: Mocked; -} +export type IAccessRepositoryMock = { [K in keyof IAccessRepository]: Mocked }; export const newAccessRepositoryMock = (): IAccessRepositoryMock => { return { diff --git a/server/test/utils.ts b/server/test/utils.ts index bc0ada3259..d6d1cd71be 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -3,9 +3,10 @@ import { Writable } from 'node:stream'; import { PNG } from 'pngjs'; import { ImmichWorker } from 'src/enum'; import { IMetadataRepository } from 'src/interfaces/metadata.interface'; +import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { BaseService } from 'src/services/base.service'; -import { IActivityRepository } from 'src/types'; +import { IAccessRepository, IActivityRepository } from 'src/types'; import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newActivityRepositoryMock } from 'test/repositories/activity.repository.mock'; import { newAlbumUserRepositoryMock } from 'test/repositories/album-user.repository.mock'; @@ -105,7 +106,7 @@ export const newTestService = ( const sut = new Service( loggerMock, - accessMock, + accessMock as IAccessRepository as AccessRepository, activityMock as IActivityRepository as ActivityRepository, auditMock, albumMock, From 1745f48f3dd812759d5982fc1f56836c39261d80 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 21 Jan 2025 11:26:52 -0500 Subject: [PATCH 11/24] feat: better spec urls (#15487) --- server/src/utils/misc.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 6a64923a3b..fddaa1f061 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -253,6 +253,8 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean }) swaggerOptions: { persistAuthorization: true, }, + jsonDocumentUrl: '/api/spec.json', + yamlDocumentUrl: '/api/spec.yaml', customSiteTitle: 'Immich API Documentation', }; From 9a1068c867d7cd128a1ee2f618c39ad44376cb57 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 21 Jan 2025 11:45:59 -0500 Subject: [PATCH 12/24] refactor: api key repository (#15491) --- server/src/interfaces/api-key.interface.ts | 19 --------- server/src/repositories/api-key.repository.ts | 40 ++++++------------- server/src/repositories/index.ts | 3 +- server/src/services/api-key.service.spec.ts | 10 +---- server/src/services/api-key.service.ts | 7 ++-- server/src/services/auth.service.spec.ts | 4 +- server/src/services/auth.service.ts | 6 ++- server/src/services/base.service.ts | 4 +- server/src/types.ts | 7 ++++ server/test/fixtures/api-key.stub.ts | 7 ++-- .../repositories/api-key.repository.mock.ts | 4 +- server/test/utils.ts | 5 ++- 12 files changed, 44 insertions(+), 72 deletions(-) delete mode 100644 server/src/interfaces/api-key.interface.ts diff --git a/server/src/interfaces/api-key.interface.ts b/server/src/interfaces/api-key.interface.ts deleted file mode 100644 index 473a2b8019..0000000000 --- a/server/src/interfaces/api-key.interface.ts +++ /dev/null @@ -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): Promise; - update(userId: string, id: string, dto: Partial): Promise; - delete(userId: string, id: string): Promise; - /** - * Includes the hashed `key` for verification - * @param id - */ - getKey(hashedToken: string): Promise; - getById(userId: string, id: string): Promise; - getByUserId(userId: string): Promise; -} diff --git a/server/src/repositories/api-key.repository.ts b/server/src/repositories/api-key.repository.ts index c0fc753213..5422ad569e 100644 --- a/server/src/repositories/api-key.repository.ts +++ b/server/src/repositories/api-key.repository.ts @@ -1,50 +1,36 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { Insertable, Kysely, Updateable } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { ApiKeys, DB } from 'src/db'; 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 { Repository } from 'typeorm'; const columns = ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'] as const; @Injectable() -export class ApiKeyRepository implements IKeyRepository { - constructor( - @InjectRepository(APIKeyEntity) private repository: Repository, - @InjectKysely() private db: Kysely, - ) {} +export class ApiKeyRepository { + constructor(@InjectKysely() private db: Kysely) {} - async create(dto: Insertable): Promise { - const { id, name, createdAt, updatedAt, permissions } = await this.db - .insertInto('api_keys') - .values(dto) - .returningAll() - .executeTakeFirstOrThrow(); - - return { id, name, createdAt, updatedAt, permissions } as APIKeyEntity; + create(dto: Insertable) { + return this.db.insertInto('api_keys').values(dto).returningAll().executeTakeFirstOrThrow(); } - async update(userId: string, id: string, dto: Updateable): Promise { + async update(userId: string, id: string, dto: Updateable) { return this.db .updateTable('api_keys') .set(dto) .where('api_keys.userId', '=', userId) .where('id', '=', asUuid(id)) .returningAll() - .executeTakeFirstOrThrow() as unknown as Promise; + .executeTakeFirstOrThrow(); } - async delete(userId: string, id: string): Promise { + async delete(userId: string, id: string) { await this.db.deleteFrom('api_keys').where('userId', '=', userId).where('id', '=', asUuid(id)).execute(); } @GenerateSql({ params: [DummyValue.STRING] }) - getKey(hashedToken: string): Promise { + getKey(hashedToken: string) { return this.db .selectFrom('api_keys') .innerJoinLateral( @@ -72,26 +58,26 @@ export class ApiKeyRepository implements IKeyRepository { eb.fn.toJson('user').as('user'), ]) .where('api_keys.key', '=', hashedToken) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) - getById(userId: string, id: string): Promise { + getById(userId: string, id: string) { return this.db .selectFrom('api_keys') .select(columns) .where('id', '=', asUuid(id)) .where('userId', '=', userId) - .executeTakeFirst() as unknown as Promise; + .executeTakeFirst(); } @GenerateSql({ params: [DummyValue.UUID] }) - getByUserId(userId: string): Promise { + getByUserId(userId: string) { return this.db .selectFrom('api_keys') .select(columns) .where('userId', '=', userId) .orderBy('createdAt', 'desc') - .execute() as unknown as Promise; + .execute(); } } diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 8f691ac9e7..434efa935f 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -1,6 +1,5 @@ import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface'; import { IConfigRepository } from 'src/interfaces/config.interface'; @@ -79,6 +78,7 @@ export const repositories = [ // AccessRepository, ActivityRepository, + ApiKeyRepository, ]; export const providers = [ @@ -92,7 +92,6 @@ export const providers = [ { provide: IDatabaseRepository, useClass: DatabaseRepository }, { provide: IEventRepository, useClass: EventRepository }, { provide: IJobRepository, useClass: JobRepository }, - { provide: IKeyRepository, useClass: ApiKeyRepository }, { provide: ILibraryRepository, useClass: LibraryRepository }, { provide: ILoggerRepository, useClass: LoggerRepository }, { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 8d07985440..928978b698 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -1,8 +1,8 @@ import { BadRequestException } from '@nestjs/common'; import { Permission } from 'src/enum'; -import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { APIKeyService } from 'src/services/api-key.service'; +import { IApiKeyRepository } from 'src/types'; import { keyStub } from 'test/fixtures/api-key.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { newTestService } from 'test/utils'; @@ -12,7 +12,7 @@ describe(APIKeyService.name, () => { let sut: APIKeyService; let cryptoMock: Mocked; - let keyMock: Mocked; + let keyMock: Mocked; beforeEach(() => { ({ sut, cryptoMock, keyMock } = newTestService(APIKeyService)); @@ -56,8 +56,6 @@ describe(APIKeyService.name, () => { describe('update', () => { 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( BadRequestException, ); @@ -77,8 +75,6 @@ describe(APIKeyService.name, () => { describe('delete', () => { 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); expect(keyMock.delete).not.toHaveBeenCalledWith('random-guid'); @@ -95,8 +91,6 @@ describe(APIKeyService.name, () => { describe('getById', () => { 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); expect(keyMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); diff --git a/server/src/services/api-key.service.ts b/server/src/services/api-key.service.ts index 303ca05537..7d9a4f3776 100644 --- a/server/src/services/api-key.service.ts +++ b/server/src/services/api-key.service.ts @@ -1,8 +1,9 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.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 { ApiKeyItem } from 'src/types'; import { isGranted } from 'src/utils/access'; @Injectable() @@ -57,13 +58,13 @@ export class APIKeyService extends BaseService { return keys.map((key) => this.map(key)); } - private map(entity: APIKeyEntity): APIKeyResponseDto { + private map(entity: ApiKeyItem): APIKeyResponseDto { return { id: entity.id, name: entity.name, createdAt: entity.createdAt, updatedAt: entity.updatedAt, - permissions: entity.permissions, + permissions: entity.permissions as Permission[], }; } } diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 917f3681bd..ffa280677a 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -3,7 +3,6 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { AuthType, Permission } from 'src/enum'; -import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IEventRepository } from 'src/interfaces/event.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 { IUserRepository } from 'src/interfaces/user.interface'; import { AuthService } from 'src/services/auth.service'; +import { IApiKeyRepository } from 'src/types'; import { keyStub } from 'test/fixtures/api-key.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { sessionStub } from 'test/fixtures/session.stub'; @@ -62,7 +62,7 @@ describe('AuthService', () => { let cryptoMock: Mocked; let eventMock: Mocked; - let keyMock: Mocked; + let keyMock: Mocked; let oauthMock: Mocked; let sessionMock: Mocked; let sharedLinkMock: Mocked; diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 9999c16f64..4c0cdbab91 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -21,6 +21,7 @@ import { UserEntity } from 'src/entities/user.entity'; import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum'; import { OAuthProfile } from 'src/interfaces/oauth.interface'; import { BaseService } from 'src/services/base.service'; +import { AuthApiKey } from 'src/types'; import { isGranted } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; @@ -309,7 +310,10 @@ export class AuthService extends BaseService { const hashedKey = this.cryptoRepository.hashSha256(key); const apiKey = await this.keyRepository.getKey(hashedKey); 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'); diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index fa77bcc388..adcddd8d66 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -8,7 +8,6 @@ import { Users } from 'src/db'; import { UserEntity } from 'src/entities/user.entity'; import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface'; import { IConfigRepository } from 'src/interfaces/config.interface'; @@ -45,6 +44,7 @@ import { IVersionHistoryRepository } from 'src/interfaces/version-history.interf import { IViewRepository } from 'src/interfaces/view.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; +import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access'; import { getConfig, updateConfig } from 'src/utils/config'; @@ -65,7 +65,7 @@ export class BaseService { @Inject(IDatabaseRepository) protected databaseRepository: IDatabaseRepository, @Inject(IEventRepository) protected eventRepository: IEventRepository, @Inject(IJobRepository) protected jobRepository: IJobRepository, - @Inject(IKeyRepository) protected keyRepository: IKeyRepository, + protected keyRepository: ApiKeyRepository, @Inject(ILibraryRepository) protected libraryRepository: ILibraryRepository, @Inject(IMachineLearningRepository) protected machineLearningRepository: IMachineLearningRepository, @Inject(IMapRepository) protected mapRepository: IMapRepository, diff --git a/server/src/types.ts b/server/src/types.ts index a6a070dc63..dd1fea710f 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -2,6 +2,7 @@ import { UserEntity } from 'src/entities/user.entity'; import { Permission } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; +import { ApiKeyRepository } from 'src/repositories/api-key.repository'; export type AuthApiKey = { id: string; @@ -14,7 +15,13 @@ export type RepositoryInterface = Pick; export type IActivityRepository = RepositoryInterface; export type IAccessRepository = { [K in keyof AccessRepository]: RepositoryInterface }; +export type IApiKeyRepository = RepositoryInterface; export type ActivityItem = | Awaited> | Awaited>[0]; + +export type ApiKeyItem = + | Awaited> + | NonNullable>> + | Awaited>[0]; diff --git a/server/test/fixtures/api-key.stub.ts b/server/test/fixtures/api-key.stub.ts index 248d30c2ec..905bda34b4 100644 --- a/server/test/fixtures/api-key.stub.ts +++ b/server/test/fixtures/api-key.stub.ts @@ -1,5 +1,3 @@ -import { APIKeyEntity } from 'src/entities/api-key.entity'; -import { AuthApiKey } from 'src/types'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -9,7 +7,7 @@ export const keyStub = { key: 'my-api-key (hashed)', user: userStub.admin, permissions: [], - } as AuthApiKey), + } as any), admin: Object.freeze({ id: 'my-random-guid', @@ -17,5 +15,6 @@ export const keyStub = { key: 'my-api-key (hashed)', userId: authStub.admin.user.id, user: userStub.admin, - } as APIKeyEntity), + permissions: [], + } as any), }; diff --git a/server/test/repositories/api-key.repository.mock.ts b/server/test/repositories/api-key.repository.mock.ts index a7cfb6369a..8c471e520f 100644 --- a/server/test/repositories/api-key.repository.mock.ts +++ b/server/test/repositories/api-key.repository.mock.ts @@ -1,7 +1,7 @@ -import { IKeyRepository } from 'src/interfaces/api-key.interface'; +import { IApiKeyRepository } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export const newKeyRepositoryMock = (): Mocked => { +export const newKeyRepositoryMock = (): Mocked => { return { create: vitest.fn(), update: vitest.fn(), diff --git a/server/test/utils.ts b/server/test/utils.ts index d6d1cd71be..929fcb9da0 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -5,8 +5,9 @@ import { ImmichWorker } from 'src/enum'; import { IMetadataRepository } from 'src/interfaces/metadata.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; +import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { BaseService } from 'src/services/base.service'; -import { IAccessRepository, IActivityRepository } from 'src/types'; +import { IAccessRepository, IActivityRepository, IApiKeyRepository } from 'src/types'; import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newActivityRepositoryMock } from 'test/repositories/activity.repository.mock'; import { newAlbumUserRepositoryMock } from 'test/repositories/album-user.repository.mock'; @@ -118,7 +119,7 @@ export const newTestService = ( databaseMock, eventMock, jobMock, - keyMock, + keyMock as IApiKeyRepository as ApiKeyRepository, libraryMock, machineLearningMock, mapMock, From 58d5cc1e4bfd43fc643f8dc32503d3f2b393f9b9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Jan 2025 11:54:47 -0500 Subject: [PATCH 13/24] chore(deps): update dependency @types/node to ^22.10.7 (#15479) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package-lock.json | 10 +++++----- cli/package.json | 2 +- e2e/package-lock.json | 12 ++++++------ e2e/package.json | 2 +- open-api/typescript-sdk/package-lock.json | 8 ++++---- open-api/typescript-sdk/package.json | 2 +- server/package-lock.json | 8 ++++---- server/package.json | 2 +- 8 files changed, 23 insertions(+), 23 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index ce6d14dc35..472c7b7b5f 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -24,7 +24,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@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/parser": "^8.15.0", "@vitest/coverage-v8": "^2.0.5", @@ -59,7 +59,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.10.5", + "@types/node": "^22.10.7", "typescript": "^5.3.3" } }, @@ -1397,9 +1397,9 @@ } }, "node_modules/@types/node": { - "version": "22.10.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", - "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", + "version": "22.10.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz", + "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 740bf11fed..dcdd3acdb8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@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/parser": "^8.15.0", "@vitest/coverage-v8": "^2.0.5", diff --git a/e2e/package-lock.json b/e2e/package-lock.json index e8aa5f4411..7029d8eda4 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -15,7 +15,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^22.10.5", + "@types/node": "^22.10.7", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", @@ -64,7 +64,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@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/parser": "^8.15.0", "@vitest/coverage-v8": "^2.0.5", @@ -99,7 +99,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.10.5", + "@types/node": "^22.10.7", "typescript": "^5.3.3" } }, @@ -1658,9 +1658,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.10.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", - "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", + "version": "22.10.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz", + "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 88b63d157b..7a590c2323 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -25,7 +25,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^22.10.5", + "@types/node": "^22.10.7", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index ddcc46658a..1dee25f3f8 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -12,7 +12,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.10.5", + "@types/node": "^22.10.7", "typescript": "^5.3.3" } }, @@ -22,9 +22,9 @@ "integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g==" }, "node_modules/@types/node": { - "version": "22.10.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", - "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", + "version": "22.10.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz", + "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 5f9603554c..7dc053f4fb 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.10.5", + "@types/node": "^22.10.7", "typescript": "^5.3.3" }, "repository": { diff --git a/server/package-lock.json b/server/package-lock.json index 338f89bfb4..f8eca4a70c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -86,7 +86,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^22.10.5", + "@types/node": "^22.10.7", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/pngjs": "^6.0.5", @@ -5129,9 +5129,9 @@ } }, "node_modules/@types/node": { - "version": "22.10.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", - "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", + "version": "22.10.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz", + "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==", "license": "MIT", "dependencies": { "undici-types": "~6.20.0" diff --git a/server/package.json b/server/package.json index 30af194460..ebde6a4159 100644 --- a/server/package.json +++ b/server/package.json @@ -112,7 +112,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^22.10.5", + "@types/node": "^22.10.7", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/pngjs": "^6.0.5", From c35fd6cbdbae78372615b855645cbe152d8e5b81 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 21 Jan 2025 11:24:48 -0600 Subject: [PATCH 14/24] refactor: migrate album repo to kysely (#15474) --- e2e/src/api/specs/album.e2e-spec.ts | 16 + server/src/dtos/album.dto.ts | 2 +- server/src/interfaces/album.interface.ts | 9 +- server/src/queries/album.repository.sql | 894 ++++++++++---------- server/src/repositories/album.repository.ts | 429 ++++++---- server/src/services/album.service.spec.ts | 61 +- server/src/services/album.service.ts | 32 +- 7 files changed, 782 insertions(+), 661 deletions(-) diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index 5c101a0793..5b40234e8d 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -142,6 +142,10 @@ describe('/albums', () => { ...user1Albums[0], assets: [expect.objectContaining({ isFavorite: false })], 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], assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })], 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], assets: [expect.objectContaining({ id: user1Albums[0].assets[0].id })], 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: [], assetCount: 1, lastModifiedAssetTimestamp: expect.any(String), + endDate: expect.any(String), + startDate: expect.any(String), + albumUsers: expect.any(Array), + shared: true, }); }); }); diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 76f4fdfc98..2f99b958c4 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -29,7 +29,7 @@ export class AddUsersDto { albumUsers!: AlbumUserAddDto[]; } -class AlbumUserCreateDto { +export class AlbumUserCreateDto { @ValidateUUID() userId!: string; diff --git a/server/src/interfaces/album.interface.ts b/server/src/interfaces/album.interface.ts index 24c64bdc9d..7af1bd97e1 100644 --- a/server/src/interfaces/album.interface.ts +++ b/server/src/interfaces/album.interface.ts @@ -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 { IBulkAsset } from 'src/utils/asset.util'; @@ -15,7 +18,7 @@ export interface AlbumInfoOptions { } export interface IAlbumRepository extends IBulkAsset { - getById(id: string, options: AlbumInfoOptions): Promise; + getById(id: string, options: AlbumInfoOptions): Promise; getByAssetId(ownerId: string, assetId: string): Promise; removeAsset(assetId: string): Promise; getMetadataForIds(ids: string[]): Promise; @@ -25,8 +28,8 @@ export interface IAlbumRepository extends IBulkAsset { restoreAll(userId: string): Promise; softDeleteAll(userId: string): Promise; deleteAll(userId: string): Promise; - create(album: Partial): Promise; - update(album: Partial): Promise; + create(album: Insertable, assetIds: string[], albumUsers: AlbumUserCreateDto[]): Promise; + update(id: string, album: Updateable): Promise; delete(id: string): Promise; updateThumbnails(): Promise; } diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index 196a1d1609..89c9e3b4a9 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -1,460 +1,490 @@ -- NOTE: This file is auto generated by ./sql-generator -- AlbumRepository.getById -SELECT DISTINCT - "distinctAlias"."AlbumEntity_id" AS "ids_AlbumEntity_id" -FROM +select + "albums".*, ( - SELECT - "AlbumEntity"."id" AS "AlbumEntity_id", - "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_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", - "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" - FROM - "albums" "AlbumEntity" - LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" - AND ( - "AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL - ) - 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" - WHERE - ((("AlbumEntity"."id" = $1))) - AND ("AlbumEntity"."deletedAt" IS NULL) - ) "distinctAlias" -ORDER BY - "AlbumEntity_id" ASC -LIMIT - 1 + 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 + "album_users".*, + ( + 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" = "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 + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "shared_links" + where + "shared_links"."albumId" = "albums"."id" + ) as agg + ) as "sharedLinks" +from + "albums" +where + "albums"."id" = $1 + and "albums"."deletedAt" is null -- AlbumRepository.getByAssetId -SELECT - "AlbumEntity"."id" AS "AlbumEntity_id", - "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_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", - "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" -FROM - "albums" "AlbumEntity" - LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" - AND ( - "AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL - ) - 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 "albums_assets_assets" "AlbumEntity_AlbumEntity__AlbumEntity_assets" ON "AlbumEntity_AlbumEntity__AlbumEntity_assets"."albumsId" = "AlbumEntity"."id" - LEFT JOIN "assets" "AlbumEntity__AlbumEntity_assets" ON "AlbumEntity__AlbumEntity_assets"."id" = "AlbumEntity_AlbumEntity__AlbumEntity_assets"."assetsId" - AND ( - "AlbumEntity__AlbumEntity_assets"."deletedAt" IS NULL - ) -WHERE +select + "albums".*, + ( + 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 + "album_users".*, + ( + 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" = "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 ( ( - ( - ( - ("AlbumEntity"."ownerId" = $1) - AND ((("AlbumEntity__AlbumEntity_assets"."id" = $2))) - ) - ) - OR ( - ( - ( - ( - ( - "AlbumEntity__AlbumEntity_albumUsers"."usersId" = $3 - ) - ) - ) - AND ((("AlbumEntity__AlbumEntity_assets"."id" = $4))) - ) - ) + "albums"."ownerId" = $1 + and "album_assets"."assetsId" = $2 + ) + or ( + "album_users"."usersId" = $3 + and "album_assets"."assetsId" = $4 ) ) - AND ("AlbumEntity"."deletedAt" IS NULL) -ORDER BY - "AlbumEntity"."createdAt" DESC + and "albums"."deletedAt" is null +order by + "albums"."createdAt" desc, + "albums"."createdAt" desc -- AlbumRepository.getMetadataForIds -SELECT - "album"."id" AS "album_id", - MIN("assets"."fileCreatedAt") AS "start_date", - MAX("assets"."fileCreatedAt") AS "end_date", - COUNT("assets"."id") AS "asset_count" -FROM - "albums" "album" - LEFT JOIN "albums_assets_assets" "album_assets" ON "album_assets"."albumsId" = "album"."id" - LEFT JOIN "assets" "assets" ON "assets"."id" = "album_assets"."assetsId" - AND "assets"."deletedAt" IS NULL -WHERE - ("album"."id" IN ($1)) - AND ("album"."deletedAt" IS NULL) -GROUP BY - "album"."id" +select + "albums"."id", + min("assets"."fileCreatedAt") as "startDate", + max("assets"."fileCreatedAt") as "endDate", + count("assets"."id") as "assetCount" +from + "albums" + left join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id" + left join "assets" on "assets"."id" = "album_assets"."assetsId" +where + "albums"."id" in ($1) +group by + "albums"."id" -- AlbumRepository.getOwned -SELECT - "AlbumEntity"."id" AS "AlbumEntity_id", - "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 - ((("AlbumEntity"."ownerId" = $1))) - AND ("AlbumEntity"."deletedAt" IS NULL) -ORDER BY - "AlbumEntity"."createdAt" DESC +select + "albums".*, + ( + 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 + "album_users".*, + ( + 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" = "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 + coalesce(json_agg(agg), '[]') + 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 -SELECT - "AlbumEntity"."id" AS "AlbumEntity_id", - "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 distinct + on ("albums"."createdAt") "albums".*, ( - ( + select + coalesce(json_agg(agg), '[]') + from ( - ( + select + "album_users".*, ( - ( + select + to_json(obj) + from ( - "AlbumEntity__AlbumEntity_albumUsers"."usersId" = $1 - ) - ) - ) - ) - ) - OR ( - ( - ( - ( - ( - "AlbumEntity__AlbumEntity_sharedLinks"."userId" = $2 - ) - ) - ) - ) - ) - OR ( - ( - ("AlbumEntity"."ownerId" = $3) - AND ( - ( - ( - NOT ( - "AlbumEntity__AlbumEntity_albumUsers"."usersId" IS NULL - ) - ) - ) - ) - ) - ) + select + "id", + "email", + "createdAt", + "profileImagePath", + "isAdmin", + "shouldChangePassword", + "deletedAt", + "oauthId", + "updatedAt", + "storageLabel", + "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 + ( + "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) -ORDER BY - "AlbumEntity"."createdAt" DESC + and "albums"."deletedAt" is null +order by + "albums"."createdAt" desc -- AlbumRepository.getNotShared -SELECT - "AlbumEntity"."id" AS "AlbumEntity_id", - "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 distinct + on ("albums"."createdAt") "albums".*, ( - ( - ("AlbumEntity"."ownerId" = $1) - AND ( - ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "album_users".*, ( - "AlbumEntity__AlbumEntity_albumUsers"."usersId" IS NULL - ) - ) - ) - AND ( - ( - ( - "AlbumEntity__AlbumEntity_sharedLinks"."id" IS NULL - ) - ) - ) - ) - ) - AND ("AlbumEntity"."deletedAt" IS NULL) -ORDER BY - "AlbumEntity"."createdAt" DESC + 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" = "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 -SELECT - "albums_assets"."assetsId" AS "assetId" -FROM - "albums_assets_assets" "albums_assets" -WHERE - "albums_assets"."albumsId" = $1 - AND "albums_assets"."assetsId" IN ($2) +select + * +from + "albums_assets_assets" +where + "albums_assets_assets"."albumsId" = $1 + and "albums_assets_assets"."assetsId" in ($2) diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index 8ac352e945..bae91349f5 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -1,72 +1,116 @@ 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 { AlbumUserCreateDto } from 'src/dtos/album.dto'; import { AlbumEntity } from 'src/entities/album.entity'; -import { AssetEntity } from 'src/entities/asset.entity'; import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface'; -import { - DataSource, - EntityManager, - FindOptionsOrder, - FindOptionsRelations, - In, - IsNull, - Not, - Repository, -} from 'typeorm'; +import { Repository } from 'typeorm'; -const withoutDeletedUsers = (album: T) => { - if (album) { - album.albumUsers = album.albumUsers.filter((albumUser) => albumUser.user && !albumUser.user.deletedAt); - } - return album; +const userColumns = [ + 'id', + 'email', + 'createdAt', + 'profileImagePath', + 'isAdmin', + 'shouldChangePassword', + 'deletedAt', + 'oauthId', + 'updatedAt', + 'storageLabel', + 'name', + 'quotaSizeInBytes', + 'quotaUsageInBytes', + 'status', + 'profileChangedAt', +] as const; + +const withOwner = (eb: ExpressionBuilder) => { + return jsonObjectFrom(eb.selectFrom('users').select(userColumns).whereRef('users.id', '=', 'albums.ownerId')).as( + 'owner', + ); +}; + +const withAlbumUsers = (eb: ExpressionBuilder) => { + 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) => { + return jsonArrayFrom(eb.selectFrom('shared_links').selectAll().whereRef('shared_links.albumId', '=', 'albums.id')).as( + 'sharedLinks', + ); +}; + +const withAssets = (eb: ExpressionBuilder) => { + 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() export class AlbumRepository implements IAlbumRepository { constructor( - @InjectRepository(AssetEntity) private assetRepository: Repository, @InjectRepository(AlbumEntity) private repository: Repository, - @InjectDataSource() private dataSource: DataSource, + @InjectKysely() private db: Kysely, ) {} @GenerateSql({ params: [DummyValue.UUID, {}] }) - async getById(id: string, options: AlbumInfoOptions): Promise { - const relations: FindOptionsRelations = { - owner: true, - albumUsers: { user: true }, - assets: false, - sharedLinks: true, - }; - - const order: FindOptionsOrder = {}; - - if (options.withAssets) { - relations.assets = { - exifInfo: true, - }; - - order.assets = { - fileCreatedAt: 'DESC', - }; - } - - const album = await this.repository.findOne({ where: { id }, relations, order }); - return withoutDeletedUsers(album); + async getById(id: string, options: AlbumInfoOptions): Promise { + return this.db + .selectFrom('albums') + .selectAll('albums') + .where('albums.id', '=', id) + .where('albums.deletedAt', 'is', null) + .select(withOwner) + .select(withAlbumUsers) + .select(withSharedLink) + .$if(options.withAssets, (eb) => eb.select(withAssets)) + .executeTakeFirst() as Promise; } @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) async getByAssetId(ownerId: string, assetId: string): Promise { - const albums = await this.repository.find({ - where: [ - { ownerId, assets: { id: assetId } }, - { albumUsers: { userId: ownerId }, assets: { id: assetId } }, - ], - relations: { owner: true, albumUsers: { user: true } }, - order: { createdAt: 'DESC' }, - }); - - return albums.map((album) => withoutDeletedUsers(album)); + return this.db + .selectFrom('albums') + .selectAll('albums') + .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') + .where((eb) => + 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)]), + ]), + ) + .where('albums.deletedAt', 'is', null) + .orderBy('albums.createdAt', 'desc') + .select(withOwner) + .select(withAlbumUsers) + .orderBy('albums.createdAt', 'desc') + .execute() as unknown as Promise; } @GenerateSql({ params: [[DummyValue.UUID]] }) @@ -77,36 +121,38 @@ export class AlbumRepository implements IAlbumRepository { return []; } - // Only possible with query builder because of GROUP BY. - const albumMetadatas = await this.repository - .createQueryBuilder('album') - .select('album.id') - .addSelect('MIN(assets.fileCreatedAt)', 'start_date') - .addSelect('MAX(assets.fileCreatedAt)', 'end_date') - .addSelect('COUNT(assets.id)', 'asset_count') - .leftJoin('albums_assets_assets', 'album_assets', 'album_assets.albumsId = album.id') - .leftJoin('assets', 'assets', 'assets.id = album_assets.assetsId') - .where('album.id IN (:...ids)', { ids }) - .groupBy('album.id') - .getRawMany(); + const metadatas = await this.db + .selectFrom('albums') + .leftJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id') + .leftJoin('assets', 'assets.id', 'album_assets.assetsId') + .select('albums.id') + .select((eb) => eb.fn.min('assets.fileCreatedAt').as('startDate')) + .select((eb) => eb.fn.max('assets.fileCreatedAt').as('endDate')) + .select((eb) => eb.fn.count('assets.id').as('assetCount')) + .where('albums.id', 'in', ids) + .groupBy('albums.id') + .execute(); - return albumMetadatas.map((metadatas) => ({ - albumId: metadatas['album_id'], - assetCount: Number(metadatas['asset_count']), - startDate: metadatas['end_date'] ? new Date(metadatas['start_date']) : undefined, - endDate: metadatas['end_date'] ? new Date(metadatas['end_date']) : undefined, + return metadatas.map((metadatas) => ({ + albumId: metadatas.id, + assetCount: Number(metadatas.assetCount), + startDate: metadatas.startDate ? new Date(metadatas.startDate) : undefined, + endDate: metadatas.endDate ? new Date(metadatas.endDate) : undefined, })); } @GenerateSql({ params: [DummyValue.UUID] }) async getOwned(ownerId: string): Promise { - const albums = await this.repository.find({ - relations: { albumUsers: { user: true }, sharedLinks: true, owner: true }, - where: { ownerId }, - order: { createdAt: 'DESC' }, - }); - - return albums.map((album) => withoutDeletedUsers(album)); + return this.db + .selectFrom('albums') + .selectAll('albums') + .select(withOwner) + .select(withAlbumUsers) + .select(withSharedLink) + .where('albums.ownerId', '=', ownerId) + .where('albums.deletedAt', 'is', null) + .orderBy('albums.createdAt', 'desc') + .execute() as unknown as Promise; } /** @@ -114,17 +160,25 @@ export class AlbumRepository implements IAlbumRepository { */ @GenerateSql({ params: [DummyValue.UUID] }) async getShared(ownerId: string): Promise { - const albums = await this.repository.find({ - relations: { albumUsers: { user: true }, sharedLinks: true, owner: true }, - where: [ - { albumUsers: { userId: ownerId } }, - { sharedLinks: { userId: ownerId } }, - { ownerId, albumUsers: { user: Not(IsNull()) } }, - ], - order: { createdAt: 'DESC' }, - }); - - return albums.map((album) => withoutDeletedUsers(album)); + return this.db + .selectFrom('albums') + .selectAll('albums') + .distinctOn('albums.createdAt') + .leftJoin('albums_shared_users_users as shared_albums', 'shared_albums.albumsId', 'albums.id') + .leftJoin('shared_links', 'shared_links.albumId', 'albums.id') + .where((eb) => + eb.or([ + eb('shared_albums.usersId', '=', ownerId), + eb('shared_links.userId', '=', ownerId), + 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; } /** @@ -132,35 +186,37 @@ export class AlbumRepository implements IAlbumRepository { */ @GenerateSql({ params: [DummyValue.UUID] }) async getNotShared(ownerId: string): Promise { - const albums = await this.repository.find({ - relations: { albumUsers: true, sharedLinks: true, owner: true }, - where: { ownerId, albumUsers: { user: IsNull() }, sharedLinks: { id: IsNull() } }, - order: { createdAt: 'DESC' }, - }); - - return albums.map((album) => withoutDeletedUsers(album)); + return this.db + .selectFrom('albums') + .selectAll('albums') + .distinctOn('albums.createdAt') + .leftJoin('albums_shared_users_users as shared_albums', 'shared_albums.albumsId', 'albums.id') + .leftJoin('shared_links', 'shared_links.albumId', 'albums.id') + .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; } async restoreAll(userId: string): Promise { - await this.repository.restore({ ownerId: userId }); + await this.db.updateTable('albums').set({ deletedAt: null }).where('ownerId', '=', userId).execute(); } async softDeleteAll(userId: string): Promise { - await this.repository.softDelete({ ownerId: userId }); + await this.db.updateTable('albums').set({ deletedAt: new Date() }).where('ownerId', '=', userId).execute(); } async deleteAll(userId: string): Promise { - await this.repository.delete({ ownerId: userId }); + await this.db.deleteFrom('albums').where('ownerId', '=', userId).execute(); } async removeAsset(assetId: string): Promise { - // Using dataSource, because there is no direct access to albums_assets_assets. - await this.dataSource - .createQueryBuilder() - .delete() - .from('albums_assets_assets') - .where('"albums_assets_assets"."assetsId" = :assetId', { assetId }) - .execute(); + await this.db.deleteFrom('albums_assets_assets').where('albums_assets_assets.assetsId', '=', assetId).execute(); } @Chunked({ paramIndex: 1 }) @@ -169,14 +225,10 @@ export class AlbumRepository implements IAlbumRepository { return; } - await this.dataSource - .createQueryBuilder() - .delete() - .from('albums_assets_assets') - .where({ - albumsId: albumId, - assetsId: In(assetIds), - }) + await this.db + .deleteFrom('albums_assets_assets') + .where('albums_assets_assets.albumsId', '=', albumId) + .where('albums_assets_assets.assetsId', 'in', assetIds) .execute(); } @@ -194,73 +246,80 @@ export class AlbumRepository implements IAlbumRepository { return new Set(); } - const results = await this.dataSource - .createQueryBuilder() - .select('albums_assets.assetsId', 'assetId') - .from('albums_assets_assets', 'albums_assets') - .where('"albums_assets"."albumsId" = :albumId', { albumId }) - .andWhere('"albums_assets"."assetsId" IN (:...assetIds)', { assetIds }) - .getRawMany<{ assetId: string }>(); - - return new Set(results.map(({ assetId }) => assetId)); + return this.db + .selectFrom('albums_assets_assets') + .selectAll() + .where('albums_assets_assets.albumsId', '=', albumId) + .where('albums_assets_assets.assetsId', 'in', assetIds) + .execute() + .then((results) => new Set(results.map(({ assetsId }) => assetsId))); } async addAssetIds(albumId: string, assetIds: string[]): Promise { - await this.addAssets(this.dataSource.manager, albumId, assetIds); + await this.addAssets(this.db, albumId, assetIds); } - create(album: Partial): Promise { - return this.dataSource.transaction(async (manager) => { - const { id } = await manager.save(AlbumEntity, { ...album, assets: [] }); - const assetIds = (album.assets || []).map((asset) => asset.id); - await this.addAssets(manager, id, assetIds); - return manager.findOneOrFail(AlbumEntity, { - where: { id }, - relations: { - owner: true, - albumUsers: { user: true }, - sharedLinks: true, - assets: true, - }, - }); + create(album: Insertable, assetIds: string[], albumUsers: AlbumUserCreateDto[]): Promise { + return this.db.transaction().execute(async (tx) => { + const newAlbum = await tx.insertInto('albums').values(album).returning('albums.id').executeTakeFirst(); + + if (!newAlbum) { + throw new Error('Failed to create album'); + } + + if (assetIds.length > 0) { + await this.addAssets(tx, newAlbum.id, assetIds); + } + + 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; }); } - update(album: Partial): Promise { - return this.save(album); + update(id: string, album: Updateable): Promise { + 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; } async delete(id: string): Promise { - await this.repository.delete({ id }); + await this.db.deleteFrom('albums').where('id', '=', id).execute(); } @Chunked({ paramIndex: 2, chunkSize: 30_000 }) - private async addAssets(manager: EntityManager, albumId: string, assetIds: string[]): Promise { + private async addAssets(db: Kysely, albumId: string, assetIds: string[]): Promise { if (assetIds.length === 0) { return; } - await manager - .createQueryBuilder() - .insert() - .into('albums_assets_assets', ['albumsId', 'assetsId']) + await db + .insertInto('albums_assets_assets') .values(assetIds.map((assetId) => ({ albumsId: albumId, assetsId: assetId }))) .execute(); } - private async save(album: Partial) { - 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: * - Removing thumbnails from albums without assets @@ -272,28 +331,44 @@ export class AlbumRepository implements IAlbumRepository { async updateThumbnails(): Promise { // Subquery for getting a new thumbnail. - const builder = this.dataSource - .createQueryBuilder('albums_assets_assets', 'album_assets') - .innerJoin('assets', 'assets', '"album_assets"."assetsId" = "assets"."id"') - .where('"album_assets"."albumsId" = "albums"."id"'); + const result = await this.db + .updateTable('albums') + .set((eb) => ({ + 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 - .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"'); + return Number(result[0].numUpdatedRows); + } - const updateAlbums = this.repository - .createQueryBuilder('albums') - .update(AlbumEntity) - .set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` }) - .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${hasAssets.getQuery()})`) - .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${hasInvalidAsset.getQuery()})`); - - const result = await updateAlbums.execute(); - - return result.affected; + private updateThumbnailBuilder(eb: ExpressionBuilder) { + return eb + .selectFrom('albums_assets_assets as album_assets') + .innerJoin('assets', (join) => + join.onRef('album_assets.assetsId', '=', 'assets.id').on('assets.deletedAt', 'is', null), + ) + .whereRef('album_assets.albumsId', '=', 'albums.id'); } } diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index ca6b56e085..99c794adc9 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -135,14 +135,17 @@ describe(AlbumService.name, () => { assetIds: ['123'], }); - expect(albumMock.create).toHaveBeenCalledWith({ - ownerId: authStub.admin.user.id, - albumName: albumStub.empty.albumName, - description: albumStub.empty.description, - albumUsers: [{ userId: 'user-id', role: AlbumUserRole.EDITOR }], - assets: [{ id: '123' }], - albumThumbnailAssetId: '123', - }); + expect(albumMock.create).toHaveBeenCalledWith( + { + ownerId: authStub.admin.user.id, + albumName: albumStub.empty.albumName, + description: albumStub.empty.description, + + albumThumbnailAssetId: '123', + }, + ['123'], + [{ userId: 'user-id', role: AlbumUserRole.EDITOR }], + ); expect(userMock.get).toHaveBeenCalledWith('user-id', {}); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123'])); @@ -175,14 +178,17 @@ describe(AlbumService.name, () => { assetIds: ['asset-1', 'asset-2'], }); - expect(albumMock.create).toHaveBeenCalledWith({ - ownerId: authStub.admin.user.id, - albumName: 'Test album', - description: '', - albumUsers: [], - assets: [{ id: 'asset-1' }], - albumThumbnailAssetId: 'asset-1', - }); + expect(albumMock.create).toHaveBeenCalledWith( + { + ownerId: authStub.admin.user.id, + albumName: 'Test album', + description: '', + + albumThumbnailAssetId: 'asset-1', + }, + ['asset-1'], + [], + ); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set(['asset-1', 'asset-2']), @@ -192,7 +198,7 @@ describe(AlbumService.name, () => { describe('update', () => { it('should prevent updating an album that does not exist', async () => { - albumMock.getById.mockResolvedValue(null); + albumMock.getById.mockResolvedValue(void 0); await expect( sut.update(authStub.user1, 'invalid-id', { @@ -238,7 +244,7 @@ describe(AlbumService.name, () => { }); expect(albumMock.update).toHaveBeenCalledTimes(1); - expect(albumMock.update).toHaveBeenCalledWith({ + expect(albumMock.update).toHaveBeenCalledWith('album-4', { id: 'album-4', albumName: 'new album name', }); @@ -344,7 +350,7 @@ describe(AlbumService.name, () => { describe('removeUser', () => { it('should require a valid album id', async () => { 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); expect(albumMock.update).not.toHaveBeenCalled(); }); @@ -529,7 +535,7 @@ describe(AlbumService.name, () => { { success: true, id: 'asset-3' }, ]); - expect(albumMock.update).toHaveBeenCalledWith({ + expect(albumMock.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', @@ -547,7 +553,7 @@ describe(AlbumService.name, () => { { success: true, id: 'asset-1' }, ]); - expect(albumMock.update).toHaveBeenCalledWith({ + expect(albumMock.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-id', @@ -569,7 +575,7 @@ describe(AlbumService.name, () => { { success: true, id: 'asset-3' }, ]); - expect(albumMock.update).toHaveBeenCalledWith({ + expect(albumMock.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', @@ -606,7 +612,7 @@ describe(AlbumService.name, () => { { success: true, id: 'asset-3' }, ]); - expect(albumMock.update).toHaveBeenCalledWith({ + expect(albumMock.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', @@ -629,7 +635,7 @@ describe(AlbumService.name, () => { { success: true, id: 'asset-1' }, ]); - expect(albumMock.update).toHaveBeenCalledWith({ + expect(albumMock.update).toHaveBeenCalledWith('album-123', { id: 'album-123', updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', @@ -696,7 +702,6 @@ describe(AlbumService.name, () => { { success: true, id: 'asset-id' }, ]); - expect(albumMock.update).toHaveBeenCalledWith({ id: 'album-123', updatedAt: expect.any(Date) }); 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([ { 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 () => { @@ -734,10 +737,6 @@ describe(AlbumService.name, () => { { success: true, id: 'asset-id' }, ]); - expect(albumMock.update).toHaveBeenCalledWith({ - id: 'album-123', - updatedAt: expect.any(Date), - }); expect(albumMock.updateThumbnails).toHaveBeenCalled(); }); }); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index f5685f84eb..efc71c4c8d 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -15,7 +15,6 @@ import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AlbumEntity } from 'src/entities/album.entity'; -import { AssetEntity } from 'src/entities/asset.entity'; import { Permission } from 'src/enum'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/interfaces/album.interface'; import { BaseService } from 'src/services/base.service'; @@ -112,16 +111,18 @@ export class AlbumService extends BaseService { permission: Permission.ASSET_SHARE, ids: dto.assetIds || [], }); - const assets = [...allowedAssetIdsSet].map((id) => ({ id }) as AssetEntity); + const assetIds = [...allowedAssetIdsSet].map((id) => id); - const album = await this.albumRepository.create({ - ownerId: auth.user.id, - albumName: dto.albumName, - description: dto.description, - albumUsers: albumUsers.map((albumUser) => albumUser as AlbumUserEntity) ?? [], - assets, - albumThumbnailAssetId: assets[0]?.id || null, - }); + const album = await this.albumRepository.create( + { + ownerId: auth.user.id, + albumName: dto.albumName, + description: dto.description, + albumThumbnailAssetId: assetIds[0] || null, + }, + assetIds, + albumUsers, + ); for (const { userId } of albumUsers) { 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'); } } - const updatedAlbum = await this.albumRepository.update({ + const updatedAlbum = await this.albumRepository.update(album.id, { id: album.id, albumName: dto.albumName, description: dto.description, @@ -170,7 +171,7 @@ export class AlbumService extends BaseService { const { id: firstNewAssetId } = results.find(({ success }) => success) || {}; if (firstNewAssetId) { - await this.albumRepository.update({ + await this.albumRepository.update(id, { id, updatedAt: new Date(), albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId, @@ -199,11 +200,8 @@ export class AlbumService extends BaseService { ); const removedIds = results.filter(({ success }) => success).map(({ id }) => id); - if (removedIds.length > 0) { - await this.albumRepository.update({ id, updatedAt: new Date() }); - if (album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) { - await this.albumRepository.updateThumbnails(); - } + if (removedIds.length > 0 && album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) { + await this.albumRepository.updateThumbnails(); } return results; From 0c152366ec33276aa08e22d6e30fb9a593b272ba Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Jan 2025 12:34:14 -0500 Subject: [PATCH 15/24] chore(deps): update docker/build-push-action action to v6.12.0 (#15493) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/cli.yml | 2 +- .github/workflows/docker.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index b6371d4a66..e7effc8551 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -88,7 +88,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} - name: Build and push image - uses: docker/build-push-action@v6.11.0 + uses: docker/build-push-action@v6.12.0 with: file: cli/Dockerfile platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7ae51696e6..3c10c3a143 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -174,7 +174,7 @@ jobs: fi - name: Build and push image - uses: docker/build-push-action@v6.11.0 + uses: docker/build-push-action@v6.12.0 with: context: ${{ env.context }} file: ${{ env.file }} @@ -265,7 +265,7 @@ jobs: fi - name: Build and push image - uses: docker/build-push-action@v6.11.0 + uses: docker/build-push-action@v6.12.0 with: context: ${{ env.context }} file: ${{ env.file }} From 332a865ce6a77f75f1cc39a7fbabacffe780973f Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 21 Jan 2025 19:12:28 +0100 Subject: [PATCH 16/24] refactor: migrate person repository to kysely (#15242) * refactor: migrate person repository to kysely * `asVector` begone * linting * fix metadata faces * update test --------- Co-authored-by: Alex Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> --- e2e/src/api/specs/person.e2e-spec.ts | 4 +- machine-learning/app/models/clip/textual.py | 6 +- machine-learning/app/models/clip/visual.py | 14 +- .../models/facial_recognition/recognition.py | 4 +- machine-learning/app/models/transforms.py | 7 + machine-learning/app/schemas.py | 2 +- machine-learning/app/test_main.py | 45 +- server/src/decorators.ts | 1 + server/src/entities/face-search.entity.ts | 8 +- server/src/entities/smart-search.entity.ts | 4 +- .../interfaces/machine-learning.interface.ts | 10 +- server/src/interfaces/person.interface.ts | 25 +- server/src/interfaces/search.interface.ts | 6 +- server/src/queries/person.repository.sql | 538 ++++++++---------- server/src/queries/search.repository.sql | 10 +- server/src/repositories/person.repository.ts | 486 +++++++++------- server/src/repositories/search.repository.ts | 27 +- server/src/services/audit.service.ts | 23 +- server/src/services/media.service.spec.ts | 58 +- server/src/services/media.service.ts | 38 +- server/src/services/metadata.service.spec.ts | 4 +- server/src/services/metadata.service.ts | 10 +- server/src/services/person.service.spec.ts | 70 +-- server/src/services/person.service.ts | 25 +- .../src/services/smart-info.service.spec.ts | 8 +- server/src/utils/database.ts | 2 +- server/test/fixtures/asset.stub.ts | 4 +- server/test/fixtures/face.stub.ts | 16 +- server/test/utils.ts | 7 + 29 files changed, 715 insertions(+), 747 deletions(-) diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts index d6ccf8265f..bb838bbae3 100644 --- a/e2e/src/api/specs/person.e2e-spec.ts +++ b/e2e/src/api/specs/person.e2e-spec.ts @@ -200,7 +200,7 @@ describe('/people', () => { expect(body).toMatchObject({ id: expect.any(String), name: 'New Person', - birthDate: '1990-01-01', + birthDate: '1990-01-01T00:00:00.000Z', }); }); }); @@ -244,7 +244,7 @@ describe('/people', () => { .set('Authorization', `Bearer ${admin.accessToken}`) .send({ birthDate: '1990-01-01' }); expect(status).toBe(200); - expect(body).toMatchObject({ birthDate: '1990-01-01' }); + expect(body).toMatchObject({ birthDate: '1990-01-01T00:00:00.000Z' }); }); it('should clear a date of birth', async () => { diff --git a/machine-learning/app/models/clip/textual.py b/machine-learning/app/models/clip/textual.py index 32c28ea2bb..d338f29296 100644 --- a/machine-learning/app/models/clip/textual.py +++ b/machine-learning/app/models/clip/textual.py @@ -10,7 +10,7 @@ from tokenizers import Encoding, Tokenizer from app.config import log from app.models.base import InferenceModel -from app.models.transforms import clean_text +from app.models.transforms import clean_text, serialize_np_array from app.schemas import ModelSession, ModelTask, ModelType @@ -18,9 +18,9 @@ class BaseCLIPTextualEncoder(InferenceModel): depends = [] identity = (ModelType.TEXTUAL, ModelTask.SEARCH) - def _predict(self, inputs: str, **kwargs: Any) -> NDArray[np.float32]: + def _predict(self, inputs: str, **kwargs: Any) -> str: res: NDArray[np.float32] = self.session.run(None, self.tokenize(inputs))[0][0] - return res + return serialize_np_array(res) def _load(self) -> ModelSession: session = super()._load() diff --git a/machine-learning/app/models/clip/visual.py b/machine-learning/app/models/clip/visual.py index 48058c961a..64be8e0657 100644 --- a/machine-learning/app/models/clip/visual.py +++ b/machine-learning/app/models/clip/visual.py @@ -10,7 +10,15 @@ from PIL import Image from app.config import log from app.models.base import InferenceModel -from app.models.transforms import crop_pil, decode_pil, get_pil_resampling, normalize, resize_pil, to_numpy +from app.models.transforms import ( + crop_pil, + decode_pil, + get_pil_resampling, + normalize, + resize_pil, + serialize_np_array, + to_numpy, +) from app.schemas import ModelSession, ModelTask, ModelType @@ -18,10 +26,10 @@ class BaseCLIPVisualEncoder(InferenceModel): depends = [] identity = (ModelType.VISUAL, ModelTask.SEARCH) - def _predict(self, inputs: Image.Image | bytes, **kwargs: Any) -> NDArray[np.float32]: + def _predict(self, inputs: Image.Image | bytes, **kwargs: Any) -> str: image = decode_pil(inputs) res: NDArray[np.float32] = self.session.run(None, self.transform(image))[0][0] - return res + return serialize_np_array(res) @abstractmethod def transform(self, image: Image.Image) -> dict[str, NDArray[np.float32]]: diff --git a/machine-learning/app/models/facial_recognition/recognition.py b/machine-learning/app/models/facial_recognition/recognition.py index dcfb6b530e..044f19b06f 100644 --- a/machine-learning/app/models/facial_recognition/recognition.py +++ b/machine-learning/app/models/facial_recognition/recognition.py @@ -12,7 +12,7 @@ from PIL import Image from app.config import log, settings from app.models.base import InferenceModel -from app.models.transforms import decode_cv2 +from app.models.transforms import decode_cv2, serialize_np_array from app.schemas import FaceDetectionOutput, FacialRecognitionOutput, ModelFormat, ModelSession, ModelTask, ModelType @@ -61,7 +61,7 @@ class FaceRecognizer(InferenceModel): return [ { "boundingBox": {"x1": x1, "y1": y1, "x2": x2, "y2": y2}, - "embedding": embedding, + "embedding": serialize_np_array(embedding), "score": score, } for (x1, y1, x2, y2), embedding, score in zip(faces["boxes"], embeddings, faces["scores"]) diff --git a/machine-learning/app/models/transforms.py b/machine-learning/app/models/transforms.py index bb03103d4b..e70763a07f 100644 --- a/machine-learning/app/models/transforms.py +++ b/machine-learning/app/models/transforms.py @@ -4,6 +4,7 @@ from typing import IO import cv2 import numpy as np +import orjson from numpy.typing import NDArray from PIL import Image @@ -69,3 +70,9 @@ def clean_text(text: str, canonicalize: bool = False) -> str: if canonicalize: text = text.translate(_PUNCTUATION_TRANS).lower() return text + + +# this allows the client to use the array as a string without deserializing only to serialize back to a string +# TODO: use this in a less invasive way +def serialize_np_array(arr: NDArray[np.float32]) -> str: + return orjson.dumps(arr, option=orjson.OPT_SERIALIZE_NUMPY).decode() diff --git a/machine-learning/app/schemas.py b/machine-learning/app/schemas.py index a7ce2ee60d..d513faed6b 100644 --- a/machine-learning/app/schemas.py +++ b/machine-learning/app/schemas.py @@ -79,7 +79,7 @@ class FaceDetectionOutput(TypedDict): class DetectedFace(TypedDict): boundingBox: BoundingBox - embedding: npt.NDArray[np.float32] + embedding: str score: float diff --git a/machine-learning/app/test_main.py b/machine-learning/app/test_main.py index 5da3baded7..b986f63668 100644 --- a/machine-learning/app/test_main.py +++ b/machine-learning/app/test_main.py @@ -10,6 +10,7 @@ from unittest import mock import cv2 import numpy as np import onnxruntime as ort +import orjson import pytest from fastapi import HTTPException from fastapi.testclient import TestClient @@ -346,11 +347,11 @@ class TestCLIP: mocked.run.return_value = [[self.embedding]] clip_encoder = OpenClipVisualEncoder("ViT-B-32__openai", cache_dir="test_cache") - embedding = clip_encoder.predict(pil_image) - - assert isinstance(embedding, np.ndarray) - assert embedding.shape[0] == clip_model_cfg["embed_dim"] - assert embedding.dtype == np.float32 + embedding_str = clip_encoder.predict(pil_image) + assert isinstance(embedding_str, str) + embedding = orjson.loads(embedding_str) + assert isinstance(embedding, list) + assert len(embedding) == clip_model_cfg["embed_dim"] mocked.run.assert_called_once() def test_basic_text( @@ -368,11 +369,11 @@ class TestCLIP: mocker.patch("app.models.clip.textual.Tokenizer.from_file", autospec=True) clip_encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache") - embedding = clip_encoder.predict("test search query") - - assert isinstance(embedding, np.ndarray) - assert embedding.shape[0] == clip_model_cfg["embed_dim"] - assert embedding.dtype == np.float32 + embedding_str = clip_encoder.predict("test search query") + assert isinstance(embedding_str, str) + embedding = orjson.loads(embedding_str) + assert isinstance(embedding, list) + assert len(embedding) == clip_model_cfg["embed_dim"] mocked.run.assert_called_once() def test_openclip_tokenizer( @@ -508,8 +509,11 @@ class TestFaceRecognition: assert isinstance(face.get("boundingBox"), dict) assert set(face["boundingBox"]) == {"x1", "y1", "x2", "y2"} assert all(isinstance(val, np.float32) for val in face["boundingBox"].values()) - assert isinstance(face.get("embedding"), np.ndarray) - assert face["embedding"].shape[0] == 512 + embedding_str = face.get("embedding") + assert isinstance(embedding_str, str) + embedding = orjson.loads(embedding_str) + assert isinstance(embedding, list) + assert len(embedding) == 512 assert isinstance(face.get("score", None), np.float32) rec_model.get_feat.assert_called_once() @@ -880,8 +884,10 @@ class TestPredictionEndpoints: actual = response.json() assert response.status_code == 200 assert isinstance(actual, dict) - assert isinstance(actual.get("clip", None), list) - assert np.allclose(expected, actual["clip"]) + embedding = actual.get("clip", None) + assert isinstance(embedding, str) + parsed_embedding = orjson.loads(embedding) + assert np.allclose(expected, parsed_embedding) def test_clip_text_endpoint(self, responses: dict[str, Any], deployed_app: TestClient) -> None: expected = responses["clip"]["text"] @@ -901,8 +907,10 @@ class TestPredictionEndpoints: actual = response.json() assert response.status_code == 200 assert isinstance(actual, dict) - assert isinstance(actual.get("clip", None), list) - assert np.allclose(expected, actual["clip"]) + embedding = actual.get("clip", None) + assert isinstance(embedding, str) + parsed_embedding = orjson.loads(embedding) + assert np.allclose(expected, parsed_embedding) def test_face_endpoint(self, pil_image: Image.Image, responses: dict[str, Any], deployed_app: TestClient) -> None: byte_image = BytesIO() @@ -933,5 +941,8 @@ class TestPredictionEndpoints: for expected_face, actual_face in zip(responses["facial-recognition"], actual["facial-recognition"]): assert expected_face["boundingBox"] == actual_face["boundingBox"] - assert np.allclose(expected_face["embedding"], actual_face["embedding"]) + embedding = actual_face.get("embedding", None) + assert isinstance(embedding, str) + parsed_embedding = orjson.loads(embedding) + assert np.allclose(expected_face["embedding"], parsed_embedding) assert np.allclose(expected_face["score"], actual_face["score"]) diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 047b9ec4a7..bb037ee097 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -100,6 +100,7 @@ export const DummyValue = { DATE: new Date(), TIME_BUCKET: '2024-01-01T00:00:00.000Z', BOOLEAN: true, + VECTOR: '[1, 2, 3]', }; export const GENERATE_SQL_KEY = 'generate-sql-key'; diff --git a/server/src/entities/face-search.entity.ts b/server/src/entities/face-search.entity.ts index 2887453862..e907ba6c9e 100644 --- a/server/src/entities/face-search.entity.ts +++ b/server/src/entities/face-search.entity.ts @@ -11,10 +11,6 @@ export class FaceSearchEntity { faceId!: string; @Index('face_index', { synchronize: false }) - @Column({ - type: 'float4', - array: true, - transformer: { from: JSON.parse, to: (v) => `[${v}]` }, - }) - embedding!: number[]; + @Column({ type: 'float4', array: true }) + embedding!: string; } diff --git a/server/src/entities/smart-search.entity.ts b/server/src/entities/smart-search.entity.ts index 66017152ea..42245a17fb 100644 --- a/server/src/entities/smart-search.entity.ts +++ b/server/src/entities/smart-search.entity.ts @@ -11,6 +11,6 @@ export class SmartSearchEntity { assetId!: string; @Index('clip_index', { synchronize: false }) - @Column({ type: 'float4', array: true, transformer: { from: JSON.parse, to: (v) => v } }) - embedding!: number[]; + @Column({ type: 'float4', array: true }) + embedding!: string; } diff --git a/server/src/interfaces/machine-learning.interface.ts b/server/src/interfaces/machine-learning.interface.ts index 372aa0c7cd..934091ef8e 100644 --- a/server/src/interfaces/machine-learning.interface.ts +++ b/server/src/interfaces/machine-learning.interface.ts @@ -28,10 +28,10 @@ export type FaceDetectionOptions = ModelOptions & { minScore: number }; type VisualResponse = { imageHeight: number; imageWidth: number }; export type ClipVisualRequest = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: ModelOptions } }; -export type ClipVisualResponse = { [ModelTask.SEARCH]: number[] } & VisualResponse; +export type ClipVisualResponse = { [ModelTask.SEARCH]: string } & VisualResponse; export type ClipTextualRequest = { [ModelTask.SEARCH]: { [ModelType.TEXTUAL]: ModelOptions } }; -export type ClipTextualResponse = { [ModelTask.SEARCH]: number[] }; +export type ClipTextualResponse = { [ModelTask.SEARCH]: string }; export type FacialRecognitionRequest = { [ModelTask.FACIAL_RECOGNITION]: { @@ -42,7 +42,7 @@ export type FacialRecognitionRequest = { export interface Face { boundingBox: BoundingBox; - embedding: number[]; + embedding: string; score: number; } @@ -51,7 +51,7 @@ export type DetectedFaces = { faces: Face[] } & VisualResponse; export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | FacialRecognitionRequest; export interface IMachineLearningRepository { - encodeImage(urls: string[], imagePath: string, config: ModelOptions): Promise; - encodeText(urls: string[], text: string, config: ModelOptions): Promise; + encodeImage(urls: string[], imagePath: string, config: ModelOptions): Promise; + encodeText(urls: string[], text: string, config: ModelOptions): Promise; detectFaces(urls: string[], imagePath: string, config: FaceDetectionOptions): Promise; } diff --git a/server/src/interfaces/person.interface.ts b/server/src/interfaces/person.interface.ts index dc89f5c1b0..d1404d829a 100644 --- a/server/src/interfaces/person.interface.ts +++ b/server/src/interfaces/person.interface.ts @@ -1,9 +1,10 @@ +import { Insertable, Updateable } from 'kysely'; +import { AssetFaces, FaceSearch, Person } from 'src/db'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { FaceSearchEntity } from 'src/entities/face-search.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { SourceType } from 'src/enum'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; -import { FindManyOptions, FindOptionsRelations, FindOptionsSelect } from 'typeorm'; +import { FindOptionsRelations } from 'typeorm'; export const IPersonRepository = 'IPersonRepository'; @@ -48,29 +49,31 @@ export interface DeleteFacesOptions { export type UnassignFacesOptions = DeleteFacesOptions; +export type SelectFaceOptions = Partial<{ [K in keyof AssetFaceEntity]: boolean }>; + export interface IPersonRepository { - getAll(pagination: PaginationOptions, options?: FindManyOptions): Paginated; + getAll(options?: Partial): AsyncIterableIterator; getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated; getAllWithoutFaces(): Promise; getById(personId: string): Promise; getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise; getDistinctNames(userId: string, options: PersonNameSearchOptions): Promise; - create(person: Partial): Promise; - createAll(people: Partial[]): Promise; + create(person: Insertable): Promise; + createAll(people: Insertable[]): Promise; delete(entities: PersonEntity[]): Promise; deleteFaces(options: DeleteFacesOptions): Promise; refreshFaces( - facesToAdd: Partial[], + facesToAdd: Insertable[], faceIdsToRemove: string[], - embeddingsToAdd?: FaceSearchEntity[], + embeddingsToAdd?: Insertable[], ): Promise; - getAllFaces(pagination: PaginationOptions, options?: FindManyOptions): Paginated; + getAllFaces(options?: Partial): AsyncIterableIterator; getFaceById(id: string): Promise; getFaceByIdWithAssets( id: string, relations?: FindOptionsRelations, - select?: FindOptionsSelect, + select?: SelectFaceOptions, ): Promise; getFaces(assetId: string): Promise; getFacesByIds(ids: AssetFaceId[]): Promise; @@ -80,7 +83,7 @@ export interface IPersonRepository { getNumberOfPeople(userId: string): Promise; reassignFaces(data: UpdateFacesData): Promise; unassignFaces(options: UnassignFacesOptions): Promise; - update(person: Partial): Promise; - updateAll(people: Partial[]): Promise; + update(person: Updateable & { id: string }): Promise; + updateAll(people: Insertable[]): Promise; getLatestFaceDate(): Promise; } diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index 0de8ef07d5..bb76ff7b1f 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -104,7 +104,7 @@ export interface SearchExifOptions { } export interface SearchEmbeddingOptions { - embedding: number[]; + embedding: string; userIds: string[]; } @@ -152,7 +152,7 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { export interface AssetDuplicateSearch { assetId: string; - embedding: number[]; + embedding: string; maxDistance: number; type: AssetType; userIds: string[]; @@ -192,7 +192,7 @@ export interface ISearchRepository { searchDuplicates(options: AssetDuplicateSearch): Promise; searchFaces(search: FaceEmbeddingSearch): Promise; searchRandom(size: number, options: AssetSearchOptions): Promise; - upsert(assetId: string, embedding: number[]): Promise; + upsert(assetId: string, embedding: string): Promise; searchPlaces(placeName: string): Promise; getAssetsByCity(userIds: string[]): Promise; deleteAllSearchEmbeddings(): Promise; diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index a7e683fca1..2c06d7c3f2 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -1,342 +1,252 @@ -- NOTE: This file is auto generated by ./sql-generator -- PersonRepository.reassignFaces -UPDATE "asset_faces" -SET +update "asset_faces" +set "personId" = $1 -WHERE - "personId" = $2 +where + "asset_faces"."personId" = $2 --- PersonRepository.getAllForUser -SELECT - "person"."id" AS "person_id", - "person"."createdAt" AS "person_createdAt", - "person"."updatedAt" AS "person_updatedAt", - "person"."ownerId" AS "person_ownerId", - "person"."name" AS "person_name", - "person"."birthDate" AS "person_birthDate", - "person"."thumbnailPath" AS "person_thumbnailPath", - "person"."faceAssetId" AS "person_faceAssetId", - "person"."isHidden" AS "person_isHidden" -FROM - "person" "person" - INNER JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" - INNER JOIN "assets" "asset" ON "asset"."id" = "face"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "person"."ownerId" = $1 - AND "asset"."isArchived" = false - AND "person"."isHidden" = false -GROUP BY - "person"."id" -HAVING - "person"."name" != '' - OR COUNT("face"."assetId") >= $2 -ORDER BY - "person"."isHidden" ASC, - NULLIF("person"."name", '') IS NULL ASC, - COUNT("face"."assetId") DESC, - NULLIF("person"."name", '') ASC NULLS LAST, - "person"."createdAt" ASC -LIMIT - 11 -OFFSET - 10 +-- PersonRepository.unassignFaces +update "asset_faces" +set + "personId" = $1 +where + "asset_faces"."sourceType" = $2 +VACUUM +ANALYZE asset_faces, +face_search, +person +REINDEX TABLE asset_faces +REINDEX TABLE person + +-- PersonRepository.delete +delete from "person" +where + "person"."id" in ($1) + +-- PersonRepository.deleteFaces +delete from "asset_faces" +where + "asset_faces"."sourceType" = $1 +VACUUM +ANALYZE asset_faces, +face_search, +person +REINDEX TABLE asset_faces +REINDEX TABLE person -- PersonRepository.getAllWithoutFaces -SELECT - "person"."id" AS "person_id", - "person"."createdAt" AS "person_createdAt", - "person"."updatedAt" AS "person_updatedAt", - "person"."ownerId" AS "person_ownerId", - "person"."name" AS "person_name", - "person"."birthDate" AS "person_birthDate", - "person"."thumbnailPath" AS "person_thumbnailPath", - "person"."faceAssetId" AS "person_faceAssetId", - "person"."isHidden" AS "person_isHidden" -FROM - "person" "person" - LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" -GROUP BY +select + "person".* +from + "person" + left join "asset_faces" on "asset_faces"."personId" = "person"."id" +group by "person"."id" -HAVING - COUNT("face"."assetId") = 0 +having + count("asset_faces"."assetId") = $1 -- PersonRepository.getFaces -SELECT - "AssetFaceEntity"."id" AS "AssetFaceEntity_id", - "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", - "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", - "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", - "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", - "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", - "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", - "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", - "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", - "AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", - "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", - "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", - "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", - "AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId", - "AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name", - "AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate", - "AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath", - "AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId", - "AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden" -FROM - "asset_faces" "AssetFaceEntity" - LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId" -WHERE - (("AssetFaceEntity"."assetId" = $1)) -ORDER BY - "AssetFaceEntity"."boundingBoxX1" ASC +select + "asset_faces".*, + ( + select + to_json(obj) + from + ( + select + "person".* + from + "person" + where + "person"."id" = "asset_faces"."personId" + ) as obj + ) as "person" +from + "asset_faces" +where + "asset_faces"."assetId" = $1 +order by + "asset_faces"."boundingBoxX1" asc -- PersonRepository.getFaceById -SELECT DISTINCT - "distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id" -FROM +select + "asset_faces".*, ( - SELECT - "AssetFaceEntity"."id" AS "AssetFaceEntity_id", - "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", - "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", - "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", - "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", - "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", - "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", - "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", - "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", - "AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", - "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", - "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", - "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", - "AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId", - "AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name", - "AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate", - "AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath", - "AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId", - "AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden" - FROM - "asset_faces" "AssetFaceEntity" - LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId" - WHERE - (("AssetFaceEntity"."id" = $1)) - ) "distinctAlias" -ORDER BY - "AssetFaceEntity_id" ASC -LIMIT - 1 + select + to_json(obj) + from + ( + select + "person".* + from + "person" + where + "person"."id" = "asset_faces"."personId" + ) as obj + ) as "person" +from + "asset_faces" +where + "asset_faces"."id" = $1 -- PersonRepository.getFaceByIdWithAssets -SELECT DISTINCT - "distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id" -FROM +select + "asset_faces".*, ( - SELECT - "AssetFaceEntity"."id" AS "AssetFaceEntity_id", - "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", - "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", - "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", - "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", - "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", - "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", - "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", - "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", - "AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", - "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", - "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", - "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", - "AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId", - "AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name", - "AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate", - "AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath", - "AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId", - "AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden", - "AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id", - "AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId", - "AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId", - "AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId", - "AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId", - "AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type", - "AssetFaceEntity__AssetFaceEntity_asset"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status", - "AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath", - "AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash", - "AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath", - "AssetFaceEntity__AssetFaceEntity_asset"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_asset_createdAt", - "AssetFaceEntity__AssetFaceEntity_asset"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_updatedAt", - "AssetFaceEntity__AssetFaceEntity_asset"."deletedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_deletedAt", - "AssetFaceEntity__AssetFaceEntity_asset"."fileCreatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileCreatedAt", - "AssetFaceEntity__AssetFaceEntity_asset"."localDateTime" AS "AssetFaceEntity__AssetFaceEntity_asset_localDateTime", - "AssetFaceEntity__AssetFaceEntity_asset"."fileModifiedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileModifiedAt", - "AssetFaceEntity__AssetFaceEntity_asset"."isFavorite" AS "AssetFaceEntity__AssetFaceEntity_asset_isFavorite", - "AssetFaceEntity__AssetFaceEntity_asset"."isArchived" AS "AssetFaceEntity__AssetFaceEntity_asset_isArchived", - "AssetFaceEntity__AssetFaceEntity_asset"."isExternal" AS "AssetFaceEntity__AssetFaceEntity_asset_isExternal", - "AssetFaceEntity__AssetFaceEntity_asset"."isOffline" AS "AssetFaceEntity__AssetFaceEntity_asset_isOffline", - "AssetFaceEntity__AssetFaceEntity_asset"."checksum" AS "AssetFaceEntity__AssetFaceEntity_asset_checksum", - "AssetFaceEntity__AssetFaceEntity_asset"."duration" AS "AssetFaceEntity__AssetFaceEntity_asset_duration", - "AssetFaceEntity__AssetFaceEntity_asset"."isVisible" AS "AssetFaceEntity__AssetFaceEntity_asset_isVisible", - "AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId", - "AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName", - "AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath", - "AssetFaceEntity__AssetFaceEntity_asset"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId", - "AssetFaceEntity__AssetFaceEntity_asset"."duplicateId" AS "AssetFaceEntity__AssetFaceEntity_asset_duplicateId" - FROM - "asset_faces" "AssetFaceEntity" - LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId" - LEFT JOIN "assets" "AssetFaceEntity__AssetFaceEntity_asset" ON "AssetFaceEntity__AssetFaceEntity_asset"."id" = "AssetFaceEntity"."assetId" - AND ( - "AssetFaceEntity__AssetFaceEntity_asset"."deletedAt" IS NULL - ) - WHERE - (("AssetFaceEntity"."id" = $1)) - ) "distinctAlias" -ORDER BY - "AssetFaceEntity_id" ASC -LIMIT - 1 + select + to_json(obj) + from + ( + select + "person".* + from + "person" + where + "person"."id" = "asset_faces"."personId" + ) as obj + ) as "person", + ( + select + to_json(obj) + from + ( + select + "assets".* + from + "assets" + where + "assets"."id" = "asset_faces"."assetId" + ) as obj + ) as "asset" +from + "asset_faces" +where + "asset_faces"."id" = $1 -- PersonRepository.reassignFace -UPDATE "asset_faces" -SET +update "asset_faces" +set "personId" = $1 -WHERE - "id" = $2 +where + "asset_faces"."id" = $2 -- PersonRepository.getByName -SELECT - "person"."id" AS "person_id", - "person"."createdAt" AS "person_createdAt", - "person"."updatedAt" AS "person_updatedAt", - "person"."ownerId" AS "person_ownerId", - "person"."name" AS "person_name", - "person"."birthDate" AS "person_birthDate", - "person"."thumbnailPath" AS "person_thumbnailPath", - "person"."faceAssetId" AS "person_faceAssetId", - "person"."isHidden" AS "person_isHidden" -FROM - "person" "person" -WHERE - "person"."ownerId" = $1 - AND ( - LOWER("person"."name") LIKE $2 - OR LOWER("person"."name") LIKE $3 - ) -LIMIT - 1000 - --- PersonRepository.getDistinctNames -SELECT DISTINCT - ON (lower("person"."name")) "person"."id" AS "person_id", - "person"."name" AS "person_name" -FROM - "person" "person" -WHERE - "person"."ownerId" = $1 - AND "person"."name" != '' - --- PersonRepository.getStatistics -SELECT - COUNT(DISTINCT ("asset"."id")) AS "count" -FROM - "asset_faces" "face" - LEFT JOIN "assets" "asset" ON "asset"."id" = "face"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "face"."personId" = $1 - AND "asset"."isArchived" = false - AND "asset"."deletedAt" IS NULL - AND "asset"."livePhotoVideoId" IS NULL - --- PersonRepository.getNumberOfPeople -SELECT - COUNT(DISTINCT ("person"."id")) AS "total", - COUNT(DISTINCT ("person"."id")) FILTER ( - WHERE - "person"."isHidden" = true - ) AS "hidden" -FROM - "person" "person" - INNER JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" - INNER JOIN "assets" "asset" ON "asset"."id" = "face"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "person"."ownerId" = $1 - AND "asset"."isArchived" = false - --- PersonRepository.getFacesByIds -SELECT - "AssetFaceEntity"."id" AS "AssetFaceEntity_id", - "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", - "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", - "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", - "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", - "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", - "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", - "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", - "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", - "AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", - "AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id", - "AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId", - "AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId", - "AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId", - "AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId", - "AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type", - "AssetFaceEntity__AssetFaceEntity_asset"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status", - "AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath", - "AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash", - "AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath", - "AssetFaceEntity__AssetFaceEntity_asset"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_asset_createdAt", - "AssetFaceEntity__AssetFaceEntity_asset"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_updatedAt", - "AssetFaceEntity__AssetFaceEntity_asset"."deletedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_deletedAt", - "AssetFaceEntity__AssetFaceEntity_asset"."fileCreatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileCreatedAt", - "AssetFaceEntity__AssetFaceEntity_asset"."localDateTime" AS "AssetFaceEntity__AssetFaceEntity_asset_localDateTime", - "AssetFaceEntity__AssetFaceEntity_asset"."fileModifiedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileModifiedAt", - "AssetFaceEntity__AssetFaceEntity_asset"."isFavorite" AS "AssetFaceEntity__AssetFaceEntity_asset_isFavorite", - "AssetFaceEntity__AssetFaceEntity_asset"."isArchived" AS "AssetFaceEntity__AssetFaceEntity_asset_isArchived", - "AssetFaceEntity__AssetFaceEntity_asset"."isExternal" AS "AssetFaceEntity__AssetFaceEntity_asset_isExternal", - "AssetFaceEntity__AssetFaceEntity_asset"."isOffline" AS "AssetFaceEntity__AssetFaceEntity_asset_isOffline", - "AssetFaceEntity__AssetFaceEntity_asset"."checksum" AS "AssetFaceEntity__AssetFaceEntity_asset_checksum", - "AssetFaceEntity__AssetFaceEntity_asset"."duration" AS "AssetFaceEntity__AssetFaceEntity_asset_duration", - "AssetFaceEntity__AssetFaceEntity_asset"."isVisible" AS "AssetFaceEntity__AssetFaceEntity_asset_isVisible", - "AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId", - "AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName", - "AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath", - "AssetFaceEntity__AssetFaceEntity_asset"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId", - "AssetFaceEntity__AssetFaceEntity_asset"."duplicateId" AS "AssetFaceEntity__AssetFaceEntity_asset_duplicateId" -FROM - "asset_faces" "AssetFaceEntity" - LEFT JOIN "assets" "AssetFaceEntity__AssetFaceEntity_asset" ON "AssetFaceEntity__AssetFaceEntity_asset"."id" = "AssetFaceEntity"."assetId" -WHERE +select + "person".* +from + "person" +where ( - ( - ( - ("AssetFaceEntity"."assetId" = $1) - AND ("AssetFaceEntity"."personId" = $2) - ) + "person"."ownerId" = $1 + and ( + lower("person"."name") like $2 + or lower("person"."name") like $3 ) ) +limit + $4 + +-- PersonRepository.getDistinctNames +select distinct + on (lower("person"."name")) "person"."id", + "person"."name" +from + "person" +where + ( + "person"."ownerId" = $1 + and "person"."name" != $2 + ) + +-- PersonRepository.getStatistics +select + count(distinct ("assets"."id")) as "count" +from + "asset_faces" + left join "assets" on "assets"."id" = "asset_faces"."assetId" + and "asset_faces"."personId" = $1 + and "assets"."isArchived" = $2 + and "assets"."deletedAt" is null + and "assets"."livePhotoVideoId" is null + +-- PersonRepository.getNumberOfPeople +select + count(distinct ("person"."id")) as "total", + count(distinct ("person"."id")) filter ( + where + "person"."isHidden" = $1 + ) as "hidden" +from + "person" + inner join "asset_faces" on "asset_faces"."personId" = "person"."id" + inner join "assets" on "assets"."id" = "asset_faces"."assetId" + and "assets"."deletedAt" is null + and "assets"."isArchived" = $2 +where + "person"."ownerId" = $3 + +-- PersonRepository.refreshFaces +with + "added_embeddings" as ( + insert into + "face_search" ("faceId", "embedding") + values + ($1, $2) + ) +select +from + ( + select + 1 + ) as "dummy" + +-- PersonRepository.getFacesByIds +select + "asset_faces".*, + ( + select + to_json(obj) + from + ( + select + "assets".* + from + "assets" + where + "assets"."id" = "asset_faces"."assetId" + ) as obj + ) as "asset", + ( + select + to_json(obj) + from + ( + select + "person".* + from + "person" + where + "person"."id" = "asset_faces"."personId" + ) as obj + ) as "person" +from + "asset_faces" +where + "asset_faces"."assetId" in ($1) + and "asset_faces"."personId" in ($2) -- PersonRepository.getRandomFace -SELECT - "AssetFaceEntity"."id" AS "AssetFaceEntity_id", - "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", - "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", - "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", - "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", - "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", - "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", - "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", - "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", - "AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType" -FROM - "asset_faces" "AssetFaceEntity" -WHERE - (("AssetFaceEntity"."personId" = $1)) -LIMIT - 1 +select + "asset_faces".* +from + "asset_faces" +where + "asset_faces"."personId" = $1 -- PersonRepository.getLatestFaceDate -SELECT - MAX("jobStatus"."facesRecognizedAt")::text AS "latestDate" -FROM - "asset_job_status" "jobStatus" +select + max("asset_job_status"."facesRecognizedAt")::text as "latestDate" +from + "asset_job_status" diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index a6e93bd480..784babfc02 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -76,7 +76,7 @@ where and "assets"."isArchived" = $5 and "assets"."deletedAt" is null order by - smart_search.embedding <= > $6::vector + smart_search.embedding <= > $6 limit $7 offset @@ -88,7 +88,7 @@ with select "assets"."id" as "assetId", "assets"."duplicateId", - smart_search.embedding <= > $1::vector as "distance" + smart_search.embedding <= > $1 as "distance" from "assets" inner join "smart_search" on "assets"."id" = "smart_search"."assetId" @@ -99,7 +99,7 @@ with and "assets"."type" = $4 and "assets"."id" != $5::uuid order by - smart_search.embedding <= > $6::vector + smart_search.embedding <= > $6 limit $7 ) @@ -116,7 +116,7 @@ with select "asset_faces"."id", "asset_faces"."personId", - face_search.embedding <= > $1::vector as "distance" + face_search.embedding <= > $1 as "distance" from "asset_faces" inner join "assets" on "assets"."id" = "asset_faces"."assetId" @@ -125,7 +125,7 @@ with "assets"."ownerId" = any ($2::uuid []) and "assets"."deletedAt" is null order by - face_search.embedding <= > $3::vector + face_search.embedding <= > $3 limit $4 ) diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 4229286706..c810b0def2 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -1,13 +1,13 @@ import { Injectable } from '@nestjs/common'; -import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { ExpressionBuilder, Insertable, Kysely, SelectExpression, sql } from 'kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; import _ from 'lodash'; +import { InjectKysely } from 'nestjs-kysely'; +import { AssetFaces, DB, FaceSearch, Person } from 'src/db'; import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { FaceSearchEntity } from 'src/entities/face-search.entity'; import { PersonEntity } from 'src/entities/person.entity'; -import { PaginationMode, SourceType } from 'src/enum'; +import { SourceType } from 'src/enum'; import { AssetFaceId, DeleteFacesOptions, @@ -17,332 +17,418 @@ import { PersonNameSearchOptions, PersonSearchOptions, PersonStatistics, + SelectFaceOptions, UnassignFacesOptions, UpdateFacesData, } from 'src/interfaces/person.interface'; -import { Paginated, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; -import { DataSource, FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm'; +import { mapUpsertColumns } from 'src/utils/database'; +import { Paginated, PaginationOptions } from 'src/utils/pagination'; +import { FindOptionsRelations } from 'typeorm'; + +const withPerson = (eb: ExpressionBuilder) => { + return jsonObjectFrom( + eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_faces.personId'), + ).as('person'); +}; + +const withAsset = (eb: ExpressionBuilder) => { + return jsonObjectFrom( + eb.selectFrom('assets').selectAll('assets').whereRef('assets.id', '=', 'asset_faces.assetId'), + ).as('asset'); +}; + +const withFaceSearch = (eb: ExpressionBuilder) => { + return jsonObjectFrom( + eb.selectFrom('face_search').selectAll('face_search').whereRef('face_search.faceId', '=', 'asset_faces.id'), + ).as('faceSearch'); +}; @Injectable() export class PersonRepository implements IPersonRepository { - constructor( - @InjectDataSource() private dataSource: DataSource, - @InjectRepository(AssetEntity) private assetRepository: Repository, - @InjectRepository(PersonEntity) private personRepository: Repository, - @InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository, - @InjectRepository(FaceSearchEntity) private faceSearchRepository: Repository, - @InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository, - ) {} + constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] }) async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise { - const result = await this.assetFaceRepository - .createQueryBuilder() - .update() + const result = await this.db + .updateTable('asset_faces') .set({ personId: newPersonId }) - .where(_.omitBy({ personId: oldPersonId, id: faceIds ? In(faceIds) : undefined }, _.isUndefined)) - .execute(); + .$if(!!oldPersonId, (qb) => qb.where('asset_faces.personId', '=', oldPersonId!)) + .$if(!!faceIds, (qb) => qb.where('asset_faces.id', 'in', faceIds!)) + .executeTakeFirst(); - return result.affected ?? 0; + return Number(result.numChangedRows) ?? 0; } + @GenerateSql({ params: [{ sourceType: SourceType.EXIF }] }) async unassignFaces({ sourceType }: UnassignFacesOptions): Promise { - await this.assetFaceRepository - .createQueryBuilder() - .update() + await this.db + .updateTable('asset_faces') .set({ personId: null }) - .where({ sourceType }) + .where('asset_faces.sourceType', '=', sourceType) .execute(); await this.vacuum({ reindexVectors: false }); } + @GenerateSql({ params: [[{ id: DummyValue.UUID }]] }) async delete(entities: PersonEntity[]): Promise { - 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 { - await this.assetFaceRepository - .createQueryBuilder('asset_faces') - .delete() - .andWhere('sourceType = :sourceType', { sourceType }) - .execute(); + await this.db.deleteFrom('asset_faces').where('asset_faces.sourceType', '=', sourceType).execute(); await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING }); } - getAllFaces( - pagination: PaginationOptions, - options: FindManyOptions = {}, - ): Paginated { - return paginate(this.assetFaceRepository, pagination, options); + getAllFaces(options: Partial = {}): AsyncIterableIterator { + return this.db + .selectFrom('asset_faces') + .selectAll('asset_faces') + .$if(options.personId === null, (qb) => qb.where('asset_faces.personId', 'is', null)) + .$if(!!options.personId, (qb) => qb.where('asset_faces.personId', '=', options.personId!)) + .$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!)) + .$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!)) + .$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!)) + .stream() as AsyncIterableIterator; } - getAll(pagination: PaginationOptions, options: FindManyOptions = {}): Paginated { - return paginate(this.personRepository, pagination, options); + getAll(options: Partial = {}): AsyncIterableIterator { + 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; } - @GenerateSql({ params: [{ take: 10, skip: 10 }, DummyValue.UUID] }) async getAllForUser( pagination: PaginationOptions, userId: string, options?: PersonSearchOptions, ): Paginated { - const queryBuilder = this.personRepository - .createQueryBuilder('person') - .innerJoin('person.faces', 'face') - .where('person.ownerId = :userId', { userId }) - .innerJoin('face.asset', 'asset') - .andWhere('asset.isArchived = false') - .orderBy('person.isHidden', 'ASC') - .addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC') - .addOrderBy('COUNT(face.assetId)', 'DESC') - .addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST') - .addOrderBy('person.createdAt') - .having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 }) - .groupBy('person.id'); - if (options?.closestFaceAssetId) { - const innerQueryBuilder = this.faceSearchRepository - .createQueryBuilder('face_search') - .select('embedding', 'embedding') - .where('"face_search"."faceId" = "person"."faceAssetId"'); - const faceSelectQueryBuilder = this.faceSearchRepository - .createQueryBuilder('face_search') - .select('embedding', 'embedding') - .where('"face_search"."faceId" = :faceId', { faceId: options.closestFaceAssetId }); - queryBuilder - .orderBy('(' + innerQueryBuilder.getQuery() + ') <=> (' + faceSelectQueryBuilder.getQuery() + ')') - .setParameters(faceSelectQueryBuilder.getParameters()); + const items = (await this.db + .selectFrom('person') + .selectAll('person') + .innerJoin('asset_faces', 'asset_faces.personId', 'person.id') + .innerJoin('assets', (join) => + join + .onRef('asset_faces.assetId', '=', 'assets.id') + .on('assets.isArchived', '=', false) + .on('assets.deletedAt', 'is', null), + ) + .where('person.ownerId', '=', userId) + .orderBy('person.isHidden', 'asc') + .orderBy(sql`NULLIF(person.name, '') is null`, 'asc') + .orderBy((eb) => eb.fn.count('asset_faces.assetId'), 'desc') + .orderBy(sql`NULLIF(person.name, '')`, sql`asc nulls last`) + .orderBy('person.createdAt') + .having((eb) => + eb.or([ + eb('person.name', '!=', ''), + eb((innerEb) => innerEb.fn.count('asset_faces.assetId'), '>=', options?.minimumFaceCount || 1), + ]), + ) + .groupBy('person.id') + .$if(!!options?.closestFaceAssetId, (qb) => + qb.orderBy((eb) => + eb( + (eb) => + eb + .selectFrom('face_search') + .select('face_search.embedding') + .whereRef('face_search.faceId', '=', 'person.faceAssetId'), + '<=>', + (eb) => + eb + .selectFrom('face_search') + .select('face_search.embedding') + .where('face_search.faceId', '=', options!.closestFaceAssetId!), + ), + ), + ) + .$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false)) + .offset(pagination.skip ?? 0) + .limit(pagination.take + 1) + .execute()) as PersonEntity[]; + + if (items.length > pagination.take) { + return { items: items.slice(0, -1), hasNextPage: true }; } - if (!options?.withHidden) { - queryBuilder.andWhere('person.isHidden = false'); - } - return paginatedBuilder(queryBuilder, { - mode: PaginationMode.LIMIT_OFFSET, - ...pagination, - }); + + return { items, hasNextPage: false }; } @GenerateSql() getAllWithoutFaces(): Promise { - return this.personRepository - .createQueryBuilder('person') - .leftJoin('person.faces', 'face') - .having('COUNT(face.assetId) = 0') + return this.db + .selectFrom('person') + .selectAll('person') + .leftJoin('asset_faces', 'asset_faces.personId', 'person.id') + .having((eb) => eb.fn.count('asset_faces.assetId'), '=', 0) .groupBy('person.id') - .withDeleted() - .getMany(); + .execute() as Promise; } @GenerateSql({ params: [DummyValue.UUID] }) getFaces(assetId: string): Promise { - return this.assetFaceRepository.find({ - where: { assetId }, - relations: { - person: true, - }, - order: { - boundingBoxX1: 'ASC', - }, - }); + return this.db + .selectFrom('asset_faces') + .selectAll('asset_faces') + .select(withPerson) + .where('asset_faces.assetId', '=', assetId) + .orderBy('asset_faces.boundingBoxX1', 'asc') + .execute() as Promise; } @GenerateSql({ params: [DummyValue.UUID] }) getFaceById(id: string): Promise { // TODO return null instead of find or fail - return this.assetFaceRepository.findOneOrFail({ - where: { id }, - relations: { - person: true, - }, - }); + return this.db + .selectFrom('asset_faces') + .selectAll('asset_faces') + .select(withPerson) + .where('asset_faces.id', '=', id) + .executeTakeFirstOrThrow() as Promise; } @GenerateSql({ params: [DummyValue.UUID] }) getFaceByIdWithAssets( id: string, - relations: FindOptionsRelations, - select: FindOptionsSelect, + relations?: FindOptionsRelations, + select?: SelectFaceOptions, ): Promise { - return this.assetFaceRepository.findOne( - _.omitBy( - { - where: { id }, - relations: { - ...relations, - person: true, - asset: true, - }, - select, - }, - _.isUndefined, - ), - ); + return (this.db + .selectFrom('asset_faces') + .$if(!!select, (qb) => + qb.select( + Object.keys( + _.omitBy({ ...select!, faceSearch: undefined, asset: undefined }, _.isUndefined), + ) as SelectExpression[], + ), + ) + .$if(!select, (qb) => qb.selectAll('asset_faces')) + .select(withPerson) + .select(withAsset) + .$if(!!relations?.faceSearch, (qb) => qb.select(withFaceSearch)) + .where('asset_faces.id', '=', id) + .executeTakeFirst() ?? null) as Promise; } @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) async reassignFace(assetFaceId: string, newPersonId: string): Promise { - const result = await this.assetFaceRepository - .createQueryBuilder() - .update() + const result = await this.db + .updateTable('asset_faces') .set({ personId: newPersonId }) - .where({ id: assetFaceId }) - .execute(); + .where('asset_faces.id', '=', assetFaceId) + .executeTakeFirst(); - return result.affected ?? 0; + return Number(result.numChangedRows) ?? 0; } getById(personId: string): Promise { - return this.personRepository.findOne({ where: { id: personId } }); + return (this.db // + .selectFrom('person') + .selectAll('person') + .where('person.id', '=', personId) + .executeTakeFirst() ?? null) as Promise; } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] }) getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise { - const queryBuilder = this.personRepository - .createQueryBuilder('person') - .where( - 'person.ownerId = :userId AND (LOWER(person.name) LIKE :nameStart OR LOWER(person.name) LIKE :nameAnywhere)', - { userId, nameStart: `${personName.toLowerCase()}%`, nameAnywhere: `% ${personName.toLowerCase()}%` }, + return this.db + .selectFrom('person') + .selectAll('person') + .where((eb) => + eb.and([ + eb('person.ownerId', '=', userId), + eb.or([ + eb(eb.fn('lower', ['person.name']), 'like', `${personName.toLowerCase()}%`), + eb(eb.fn('lower', ['person.name']), 'like', `% ${personName.toLowerCase()}%`), + ]), + ]), ) - .limit(1000); - - if (!withHidden) { - queryBuilder.andWhere('person.isHidden = false'); - } - return queryBuilder.getMany(); + .limit(1000) + .$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false)) + .execute() as Promise; } @GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] }) getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise { - const queryBuilder = this.personRepository - .createQueryBuilder('person') + return this.db + .selectFrom('person') .select(['person.id', 'person.name']) - .distinctOn(['lower(person.name)']) - .where(`person.ownerId = :userId AND person.name != ''`, { userId }); - - if (!withHidden) { - queryBuilder.andWhere('person.isHidden = false'); - } - - return queryBuilder.getMany(); + .distinctOn((eb) => eb.fn('lower', ['person.name'])) + .where((eb) => eb.and([eb('person.ownerId', '=', userId), eb('person.name', '!=', '')])) + .$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false)) + .execute(); } @GenerateSql({ params: [DummyValue.UUID] }) async getStatistics(personId: string): Promise { - const items = await this.assetFaceRepository - .createQueryBuilder('face') - .leftJoin('face.asset', 'asset') - .where('face.personId = :personId', { personId }) - .andWhere('asset.isArchived = false') - .andWhere('asset.deletedAt IS NULL') - .andWhere('asset.livePhotoVideoId IS NULL') - .select('COUNT(DISTINCT(asset.id))', 'count') - .getRawOne(); + const result = await this.db + .selectFrom('asset_faces') + .leftJoin('assets', (join) => + join + .onRef('assets.id', '=', 'asset_faces.assetId') + .on('asset_faces.personId', '=', personId) + .on('assets.isArchived', '=', false) + .on('assets.deletedAt', 'is', null) + .on('assets.livePhotoVideoId', 'is', null), + ) + .select((eb) => eb.fn.count(eb.fn('distinct', ['assets.id'])).as('count')) + .executeTakeFirst(); + return { - assets: items.count ?? 0, + assets: result ? Number(result.count) : 0, }; } @GenerateSql({ params: [DummyValue.UUID] }) async getNumberOfPeople(userId: string): Promise { - const items = await this.personRepository - .createQueryBuilder('person') - .innerJoin('person.faces', 'face') - .where('person.ownerId = :userId', { userId }) - .innerJoin('face.asset', 'asset') - .andWhere('asset.isArchived = false') - .select('COUNT(DISTINCT(person.id))', 'total') - .addSelect('COUNT(DISTINCT(person.id)) FILTER (WHERE person.isHidden = true)', 'hidden') - .getRawOne(); + const items = await this.db + .selectFrom('person') + .innerJoin('asset_faces', 'asset_faces.personId', 'person.id') + .where('person.ownerId', '=', userId) + .innerJoin('assets', (join) => + join + .onRef('assets.id', '=', 'asset_faces.assetId') + .on('assets.deletedAt', 'is', null) + .on('assets.isArchived', '=', false), + ) + .select((eb) => eb.fn.count(eb.fn('distinct', ['person.id'])).as('total')) + .select((eb) => + eb.fn + .count(eb.fn('distinct', ['person.id'])) + .filterWhere('person.isHidden', '=', true) + .as('hidden'), + ) + .executeTakeFirst(); if (items == undefined) { return { total: 0, hidden: 0 }; } - const result: PeopleStatistics = { - total: items.total ?? 0, - hidden: items.hidden ?? 0, + return { + total: Number(items.total), + hidden: Number(items.hidden), }; - - return result; } - create(person: Partial): Promise { - return this.save(person); + create(person: Insertable): Promise { + return this.db.insertInto('person').values(person).returningAll().executeTakeFirst() as Promise; } - async createAll(people: Partial[]): Promise { - const results = await this.personRepository.save(people); - return results.map((person) => person.id); + async createAll(people: Insertable[]): Promise { + const results = await this.db.insertInto('person').values(people).returningAll().execute(); + return results.map(({ id }) => id); } + @GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] }) async refreshFaces( - facesToAdd: Partial[], + facesToAdd: (Insertable & { assetId: string })[], faceIdsToRemove: string[], - embeddingsToAdd?: FaceSearchEntity[], + embeddingsToAdd?: Insertable[], ): Promise { - const query = this.faceSearchRepository.createQueryBuilder().select('1').fromDummy(); + let query = this.db; if (facesToAdd.length > 0) { - const insertCte = this.assetFaceRepository.createQueryBuilder().insert().values(facesToAdd); - query.addCommonTableExpression(insertCte, 'added'); + (query as any) = query.with('added', (db) => db.insertInto('asset_faces').values(facesToAdd)); } if (faceIdsToRemove.length > 0) { - const deleteCte = this.assetFaceRepository - .createQueryBuilder() - .delete() - .where('id = any(:faceIdsToRemove)', { faceIdsToRemove }); - query.addCommonTableExpression(deleteCte, 'deleted'); + (query as any) = query.with('removed', (db) => + db.deleteFrom('asset_faces').where('asset_faces.id', '=', (eb) => eb.fn.any(eb.val(faceIdsToRemove))), + ); } if (embeddingsToAdd?.length) { - const embeddingCte = this.faceSearchRepository.createQueryBuilder().insert().values(embeddingsToAdd).orIgnore(); - query.addCommonTableExpression(embeddingCte, 'embeddings'); - query.getQuery(); // typeorm mixes up parameters without this + (query as any) = query.with('added_embeddings', (db) => db.insertInto('face_search').values(embeddingsToAdd)); } - await query.execute(); + await query.selectFrom(sql`(select 1)`.as('dummy')).execute(); } - async update(person: Partial): Promise { - return this.save(person); + async update(person: Partial & { id: string }): Promise { + return this.db + .updateTable('person') + .set(person) + .where('person.id', '=', person.id) + .returningAll() + .executeTakeFirstOrThrow() as Promise; } - async updateAll(people: Partial[]): Promise { - await this.personRepository.save(people); + async updateAll(people: Insertable[]): Promise { + if (people.length === 0) { + return; + } + + await this.db + .insertInto('person') + .values(people) + .onConflict((oc) => oc.column('id').doUpdateSet(() => mapUpsertColumns('person', people[0], ['id']))) + .execute(); } @GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] }) @ChunkedArray() - async getFacesByIds(ids: AssetFaceId[]): Promise { - return this.assetFaceRepository.find({ where: ids, relations: { asset: true }, withDeleted: true }); + getFacesByIds(ids: AssetFaceId[]): Promise { + 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; } @GenerateSql({ params: [DummyValue.UUID] }) - async getRandomFace(personId: string): Promise { - return this.assetFaceRepository.findOneBy({ personId }); + getRandomFace(personId: string): Promise { + return (this.db + .selectFrom('asset_faces') + .selectAll('asset_faces') + .where('asset_faces.personId', '=', personId) + .executeTakeFirst() ?? null) as Promise; } @GenerateSql() async getLatestFaceDate(): Promise { - const result: { latestDate?: string } | undefined = await this.jobStatusRepository - .createQueryBuilder('jobStatus') - .select('MAX(jobStatus.facesRecognizedAt)::text', 'latestDate') - .getRawOne(); + const result = (await this.db + .selectFrom('asset_job_status') + .select((eb) => sql`${eb.fn.max('asset_job_status.facesRecognizedAt')}::text`.as('latestDate')) + .executeTakeFirst()) as { latestDate: string } | undefined; + return result?.latestDate; } - private async save(person: Partial): Promise { - const { id } = await this.personRepository.save(person); - return this.personRepository.findOneByOrFail({ id }); - } - private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise { - await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search, person'); - await this.assetFaceRepository.query('REINDEX TABLE asset_faces'); - await this.assetFaceRepository.query('REINDEX TABLE person'); + await sql`VACUUM ANALYZE asset_faces, face_search, person`.execute(this.db); + await sql`REINDEX TABLE asset_faces`.execute(this.db); + await sql`REINDEX TABLE person`.execute(this.db); if (reindexVectors) { - await this.assetFaceRepository.query('REINDEX TABLE face_search'); + await sql`REINDEX TABLE face_search`.execute(this.db); } } } diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 0c01f3409d..0e43063e9a 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -20,7 +20,7 @@ import { SearchPaginationOptions, SmartSearchOptions, } from 'src/interfaces/search.interface'; -import { anyUuid, asUuid, asVector } from 'src/utils/database'; +import { anyUuid, asUuid } from 'src/utils/database'; import { Paginated } from 'src/utils/pagination'; import { isValidInteger } from 'src/validation'; @@ -82,7 +82,7 @@ export class SearchRepository implements ISearchRepository { { page: 1, size: 200 }, { takenAfter: DummyValue.DATE, - embedding: Array.from({ length: 512 }, Math.random), + embedding: DummyValue.VECTOR, lensModel: DummyValue.STRING, withStacked: true, isFavorite: true, @@ -97,7 +97,7 @@ export class SearchRepository implements ISearchRepository { const items = (await searchAssetBuilder(this.db, options) .innerJoin('smart_search', 'assets.id', 'smart_search.assetId') - .orderBy(sql`smart_search.embedding <=> ${asVector(options.embedding)}`) + .orderBy(sql`smart_search.embedding <=> ${options.embedding}`) .limit(pagination.size + 1) .offset((pagination.page - 1) * pagination.size) .execute()) as any as AssetEntity[]; @@ -111,7 +111,7 @@ export class SearchRepository implements ISearchRepository { params: [ { assetId: DummyValue.UUID, - embedding: Array.from({ length: 512 }, Math.random), + embedding: DummyValue.VECTOR, maxDistance: 0.6, type: AssetType.IMAGE, userIds: [DummyValue.UUID], @@ -119,7 +119,6 @@ export class SearchRepository implements ISearchRepository { ], }) searchDuplicates({ assetId, embedding, maxDistance, type, userIds }: AssetDuplicateSearch) { - const vector = asVector(embedding); return this.db .with('cte', (qb) => qb @@ -127,7 +126,7 @@ export class SearchRepository implements ISearchRepository { .select([ 'assets.id as assetId', 'assets.duplicateId', - sql`smart_search.embedding <=> ${vector}`.as('distance'), + sql`smart_search.embedding <=> ${embedding}`.as('distance'), ]) .innerJoin('smart_search', 'assets.id', 'smart_search.assetId') .where('assets.ownerId', '=', anyUuid(userIds)) @@ -135,7 +134,7 @@ export class SearchRepository implements ISearchRepository { .where('assets.isVisible', '=', true) .where('assets.type', '=', type) .where('assets.id', '!=', asUuid(assetId)) - .orderBy(sql`smart_search.embedding <=> ${vector}`) + .orderBy(sql`smart_search.embedding <=> ${embedding}`) .limit(64), ) .selectFrom('cte') @@ -148,7 +147,7 @@ export class SearchRepository implements ISearchRepository { params: [ { userIds: [DummyValue.UUID], - embedding: Array.from({ length: 512 }, Math.random), + embedding: DummyValue.VECTOR, numResults: 10, maxDistance: 0.6, }, @@ -159,7 +158,6 @@ export class SearchRepository implements ISearchRepository { throw new Error(`Invalid value for 'numResults': ${numResults}`); } - const vector = asVector(embedding); return this.db .with('cte', (qb) => qb @@ -167,14 +165,14 @@ export class SearchRepository implements ISearchRepository { .select([ 'asset_faces.id', 'asset_faces.personId', - sql`face_search.embedding <=> ${vector}`.as('distance'), + sql`face_search.embedding <=> ${embedding}`.as('distance'), ]) .innerJoin('assets', 'assets.id', 'asset_faces.assetId') .innerJoin('face_search', 'face_search.faceId', 'asset_faces.id') .where('assets.ownerId', '=', anyUuid(userIds)) .where('assets.deletedAt', 'is', null) .$if(!!hasPerson, (qb) => qb.where('asset_faces.personId', 'is not', null)) - .orderBy(sql`face_search.embedding <=> ${vector}`) + .orderBy(sql`face_search.embedding <=> ${embedding}`) .limit(numResults), ) .selectFrom('cte') @@ -258,12 +256,11 @@ export class SearchRepository implements ISearchRepository { .execute() as any as Promise; } - async upsert(assetId: string, embedding: number[]): Promise { - const vector = asVector(embedding); + async upsert(assetId: string, embedding: string): Promise { await this.db .insertInto('smart_search') - .values({ assetId: asUuid(assetId), embedding: vector } as any) - .onConflict((oc) => oc.column('assetId').doUpdateSet({ embedding: vector } as any)) + .values({ assetId: asUuid(assetId), embedding } as any) + .onConflict((oc) => oc.column('assetId').doUpdateSet({ embedding } as any)) .execute(); } diff --git a/server/src/services/audit.service.ts b/server/src/services/audit.service.ts index 3fc838e5e9..611f8f69d3 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -201,21 +201,22 @@ export class AuditService extends BaseService { } } - const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.personRepository.getAll(pagination), - ); - for await (const people of personPagination) { - for (const { id, thumbnailPath } of people) { - track(thumbnailPath); - const entity = { entityId: id, entityType: PathEntityType.PERSON }; - if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) { - orphans.push({ ...entity, pathType: PersonPathType.FACE, pathValue: thumbnailPath }); - } + let peopleCount = 0; + for await (const { id, thumbnailPath } of this.personRepository.getAll()) { + track(thumbnailPath); + const entity = { entityId: id, entityType: PathEntityType.PERSON }; + if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) { + orphans.push({ ...entity, pathType: PersonPathType.FACE, pathValue: thumbnailPath }); } - this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${people.length} people`); + if (peopleCount === JOBS_ASSET_PAGINATION_SIZE) { + this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${peopleCount} people`); + peopleCount = 0; + } } + this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${peopleCount} people`); + const extras: string[] = []; for (const file of allFiles) { extras.push(file); diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index f76f832cf3..1784428d31 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -25,7 +25,7 @@ import { assetStub } from 'test/fixtures/asset.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { personStub } from 'test/fixtures/person.stub'; -import { newTestService } from 'test/utils'; +import { makeStream, newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(MediaService.name, () => { @@ -55,10 +55,8 @@ describe(MediaService.name, () => { items: [assetStub.image], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [personStub.newThumbnail], - hasNextPage: false, - }); + + personMock.getAll.mockReturnValue(makeStream([personStub.newThumbnail])); personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -72,7 +70,7 @@ describe(MediaService.name, () => { }, ]); - expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, {}); + expect(personMock.getAll).toHaveBeenCalledWith(undefined); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, @@ -86,10 +84,7 @@ describe(MediaService.name, () => { items: [assetStub.trashed], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [], - hasNextPage: false, - }); + personMock.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -111,10 +106,7 @@ describe(MediaService.name, () => { items: [assetStub.archived], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [], - hasNextPage: false, - }); + personMock.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -136,10 +128,7 @@ describe(MediaService.name, () => { items: [assetStub.image], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [personStub.noThumbnail, personStub.noThumbnail], - hasNextPage: false, - }); + personMock.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail])); personMock.getRandomFace.mockResolvedValueOnce(faceStub.face1); await sut.handleQueueGenerateThumbnails({ force: false }); @@ -147,7 +136,7 @@ describe(MediaService.name, () => { expect(assetMock.getAll).not.toHaveBeenCalled(); expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); - expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } }); + expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); expect(personMock.getRandomFace).toHaveBeenCalled(); expect(personMock.update).toHaveBeenCalledTimes(1); expect(jobMock.queueAll).toHaveBeenCalledWith([ @@ -165,11 +154,7 @@ describe(MediaService.name, () => { items: [assetStub.noResizePath], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [], - hasNextPage: false, - }); - + personMock.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(assetMock.getAll).not.toHaveBeenCalled(); @@ -181,7 +166,7 @@ describe(MediaService.name, () => { }, ]); - expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } }); + expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should queue all assets with missing webp path', async () => { @@ -189,11 +174,7 @@ describe(MediaService.name, () => { items: [assetStub.noWebpPath], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [], - hasNextPage: false, - }); - + personMock.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(assetMock.getAll).not.toHaveBeenCalled(); @@ -205,7 +186,7 @@ describe(MediaService.name, () => { }, ]); - expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } }); + expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should queue all assets with missing thumbhash', async () => { @@ -213,11 +194,7 @@ describe(MediaService.name, () => { items: [assetStub.noThumbhash], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [], - hasNextPage: false, - }); - + personMock.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(assetMock.getAll).not.toHaveBeenCalled(); @@ -229,7 +206,7 @@ describe(MediaService.name, () => { }, ]); - expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } }); + expect(personMock.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); }); @@ -237,7 +214,7 @@ describe(MediaService.name, () => { it('should remove empty directories and queue jobs', async () => { assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); - personMock.getAll.mockResolvedValue({ hasNextPage: false, items: [personStub.withName] }); + personMock.getAll.mockReturnValue(makeStream([personStub.withName])); await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.SUCCESS); @@ -730,10 +707,7 @@ describe(MediaService.name, () => { items: [assetStub.video], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [], - hasNextPage: false, - }); + personMock.getAll.mockReturnValue(makeStream()); await sut.handleQueueVideoConversion({ force: true }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 7036bd32e8..2a5ee39dde 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -72,23 +72,20 @@ export class MediaService extends BaseService { } const jobs: JobItem[] = []; - const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.personRepository.getAll(pagination, { where: force ? undefined : { thumbnailPath: '' } }), - ); - for await (const people of personPagination) { - for (const person of people) { - if (!person.faceAssetId) { - const face = await this.personRepository.getRandomFace(person.id); - if (!face) { - continue; - } + const people = this.personRepository.getAll(force ? undefined : { thumbnailPath: '' }); - await this.personRepository.update({ id: person.id, faceAssetId: face.id }); + for await (const person of people) { + if (!person.faceAssetId) { + const face = await this.personRepository.getRandomFace(person.id); + if (!face) { + continue; } - jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } }); + await this.personRepository.update({ id: person.id, faceAssetId: face.id }); } + + jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } }); } await this.jobRepository.queueAll(jobs); @@ -114,16 +111,19 @@ export class MediaService extends BaseService { ); } - const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.personRepository.getAll(pagination), - ); + let jobs: { name: JobName.MIGRATE_PERSON; data: { id: string } }[] = []; - for await (const people of personPagination) { - await this.jobRepository.queueAll( - people.map((person) => ({ name: JobName.MIGRATE_PERSON, data: { id: person.id } })), - ); + for await (const person of this.personRepository.getAll()) { + jobs.push({ name: JobName.MIGRATE_PERSON, data: { id: person.id } }); + + if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) { + await this.jobRepository.queueAll(jobs); + jobs = []; + } } + await this.jobRepository.queueAll(jobs); + return JobStatus.SUCCESS; } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index a92433e88f..24d2b0e17f 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1086,7 +1086,9 @@ describe(MetadataService.name, () => { ], [], ); - expect(personMock.updateAll).toHaveBeenCalledWith([{ id: 'random-uuid', faceAssetId: 'random-uuid' }]); + expect(personMock.updateAll).toHaveBeenCalledWith([ + { id: 'random-uuid', ownerId: 'admin-id', faceAssetId: 'random-uuid' }, + ]); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 15ea990235..406f80038c 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -509,11 +509,11 @@ export class MetadataService extends BaseService { return; } - const facesToAdd: Partial[] = []; + const facesToAdd: (Partial & { assetId: string })[] = []; const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true }); const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id])); - const missing: Partial[] = []; - const missingWithFaceAsset: Partial[] = []; + const missing: (Partial & { ownerId: string })[] = []; + const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = []; for (const region of tags.RegionInfo.RegionList) { if (!region.Name) { continue; @@ -540,7 +540,7 @@ export class MetadataService extends BaseService { facesToAdd.push(face); if (!existingNameMap.has(loweredName)) { missing.push({ id: personId, ownerId: asset.ownerId, name: region.Name }); - missingWithFaceAsset.push({ id: personId, faceAssetId: face.id }); + missingWithFaceAsset.push({ id: personId, ownerId: asset.ownerId, faceAssetId: face.id }); } } @@ -557,7 +557,7 @@ export class MetadataService extends BaseService { } if (facesToAdd.length > 0) { - this.logger.debug(`Creating ${facesToAdd} faces from metadata for asset ${asset.id}`); + this.logger.debug(`Creating ${facesToAdd.length} faces from metadata for asset ${asset.id}`); } if (facesToRemove.length > 0 || facesToAdd.length > 0) { diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 60cb370881..b18eb7dfd8 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -20,8 +20,7 @@ import { faceStub } from 'test/fixtures/face.stub'; import { personStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newTestService } from 'test/utils'; -import { IsNull } from 'typeorm'; +import { makeStream, newTestService } from 'test/utils'; import { Mocked } from 'vitest'; const responseDto: PersonResponseDto = { @@ -46,7 +45,7 @@ const face = { imageHeight: 500, imageWidth: 400, }; -const faceSearch = { faceId, embedding: [1, 2, 3, 4] }; +const faceSearch = { faceId, embedding: '[1, 2, 3, 4]' }; const detectFaceMock: DetectedFaces = { faces: [ { @@ -495,14 +494,8 @@ describe(PersonService.name, () => { }); it('should delete existing people and faces if forced', async () => { - personMock.getAll.mockResolvedValue({ - items: [faceStub.face1.person, personStub.randomPerson], - hasNextPage: false, - }); - personMock.getAllFaces.mockResolvedValue({ - items: [faceStub.face1], - hasNextPage: false, - }); + personMock.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); + personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); assetMock.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false, @@ -544,18 +537,12 @@ describe(PersonService.name, () => { it('should queue missing assets', async () => { jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAllFaces.mockResolvedValue({ - items: [faceStub.face1], - hasNextPage: false, - }); + personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); personMock.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({}); - expect(personMock.getAllFaces).toHaveBeenCalledWith( - { skip: 0, take: 1000 }, - { where: { personId: IsNull(), sourceType: SourceType.MACHINE_LEARNING } }, - ); + expect(personMock.getAllFaces).toHaveBeenCalledWith({ personId: null, sourceType: SourceType.MACHINE_LEARNING }); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, @@ -569,19 +556,13 @@ describe(PersonService.name, () => { it('should queue all assets', async () => { jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAll.mockResolvedValue({ - items: [], - hasNextPage: false, - }); - personMock.getAllFaces.mockResolvedValue({ - items: [faceStub.face1], - hasNextPage: false, - }); + personMock.getAll.mockReturnValue(makeStream()); + personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); personMock.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true }); - expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, {}); + expect(personMock.getAllFaces).toHaveBeenCalledWith(undefined); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, @@ -595,26 +576,17 @@ describe(PersonService.name, () => { it('should run nightly if new face has been added since last run', async () => { personMock.getLatestFaceDate.mockResolvedValue(new Date().toISOString()); - personMock.getAllFaces.mockResolvedValue({ - items: [faceStub.face1], - hasNextPage: false, - }); + personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAll.mockResolvedValue({ - items: [], - hasNextPage: false, - }); - personMock.getAllFaces.mockResolvedValue({ - items: [faceStub.face1], - hasNextPage: false, - }); + personMock.getAll.mockReturnValue(makeStream()); + personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); personMock.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); expect(personMock.getLatestFaceDate).toHaveBeenCalledOnce(); - expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, {}); + expect(personMock.getAllFaces).toHaveBeenCalledWith(undefined); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, @@ -631,10 +603,7 @@ describe(PersonService.name, () => { systemMock.get.mockResolvedValue({ lastRun: lastRun.toISOString() }); personMock.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString()); - personMock.getAllFaces.mockResolvedValue({ - items: [faceStub.face1], - hasNextPage: false, - }); + personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); personMock.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); @@ -648,15 +617,8 @@ describe(PersonService.name, () => { it('should delete existing people if forced', async () => { jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - personMock.getAll.mockResolvedValue({ - items: [faceStub.face1.person, personStub.randomPerson], - hasNextPage: false, - }); - personMock.getAllFaces.mockResolvedValue({ - items: [faceStub.face1], - hasNextPage: false, - }); - + personMock.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); + personMock.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); await sut.handleQueueRecognizeFaces({ force: true }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index cc488a7f4e..45732c4e7c 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -50,7 +50,6 @@ import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; -import { IsNull } from 'typeorm'; @Injectable() export class PersonService extends BaseService { @@ -306,7 +305,7 @@ export class PersonService extends BaseService { ); this.logger.debug(`${faces.length} faces detected in ${previewFile.path}`); - const facesToAdd: (Partial & { id: string })[] = []; + const facesToAdd: (Partial & { id: string; assetId: string })[] = []; const embeddings: FaceSearchEntity[] = []; const mlFaceIds = new Set(); for (const face of asset.faces) { @@ -414,18 +413,22 @@ export class PersonService extends BaseService { } const lastRun = new Date().toISOString(); - const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.personRepository.getAllFaces(pagination, { - where: force ? undefined : { personId: IsNull(), sourceType: SourceType.MACHINE_LEARNING }, - }), + const facePagination = this.personRepository.getAllFaces( + force ? undefined : { personId: null, sourceType: SourceType.MACHINE_LEARNING }, ); - for await (const page of facePagination) { - await this.jobRepository.queueAll( - page.map((face) => ({ name: JobName.FACIAL_RECOGNITION, data: { id: face.id, deferred: false } })), - ); + let jobs: { name: JobName.FACIAL_RECOGNITION; data: { id: string; deferred: false } }[] = []; + for await (const face of facePagination) { + jobs.push({ name: JobName.FACIAL_RECOGNITION, data: { id: face.id, deferred: false } }); + + if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) { + await this.jobRepository.queueAll(jobs); + jobs = []; + } } + await this.jobRepository.queueAll(jobs); + await this.systemMetadataRepository.set(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun }); return JobStatus.SUCCESS; @@ -441,7 +444,7 @@ export class PersonService extends BaseService { const face = await this.personRepository.getFaceByIdWithAssets( id, { person: true, asset: true, faceSearch: true }, - { id: true, personId: true, sourceType: true, faceSearch: { embedding: true } }, + { id: true, personId: true, sourceType: true, faceSearch: true }, ); if (!face || !face.asset) { this.logger.warn(`Face ${id} not found`); diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 0b0ee6b20f..d485f4244b 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -284,7 +284,7 @@ describe(SmartInfoService.name, () => { }); it('should save the returned objects', async () => { - machineLearningMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]); + machineLearningMock.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); @@ -293,7 +293,7 @@ describe(SmartInfoService.name, () => { '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); - expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, [0.01, 0.02, 0.03]); + expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); }); it('should skip invisible assets', async () => { @@ -315,7 +315,7 @@ describe(SmartInfoService.name, () => { }); it('should wait for database', async () => { - machineLearningMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]); + machineLearningMock.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); databaseMock.isBusy.mockReturnValue(true); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); @@ -326,7 +326,7 @@ describe(SmartInfoService.name, () => { '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); - expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, [0.01, 0.02, 0.03]); + expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); }); }); diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 4ccb68f2e0..7483ef6f92 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -42,7 +42,7 @@ export const asUuid = (id: string | Expression) => sql`${id}::uu export const anyUuid = (ids: string[]) => sql`any(${`{${ids}}`}::uuid[])`; -export const asVector = (embedding: number[]) => sql`${`[${embedding}]`}::vector`; +export const asVector = (embedding: number[]) => sql`${`[${embedding}]`}::vector`; /** * Mainly for type debugging to make VS Code display a more useful tooltip. diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 45390cf92e..8f6c794790 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -824,7 +824,7 @@ export const assetStub = { duplicateId: null, smartSearch: { assetId: 'asset-id', - embedding: Array.from({ length: 512 }, Math.random), + embedding: '[1, 2, 3, 4]', }, isOffline: false, }), @@ -866,7 +866,7 @@ export const assetStub = { duplicateId: 'duplicate-id', smartSearch: { assetId: 'asset-id', - embedding: Array.from({ length: 512 }, Math.random), + embedding: '[1, 2, 3, 4]', }, isOffline: false, }), diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index b8c68d5bf4..4da4e6a0c4 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -19,7 +19,7 @@ export const faceStub = { imageHeight: 1024, imageWidth: 1024, sourceType: SourceType.MACHINE_LEARNING, - faceSearch: { faceId: 'assetFaceId1', embedding: [1, 2, 3, 4] }, + faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' }, }), primaryFace1: Object.freeze>({ id: 'assetFaceId2', @@ -34,7 +34,7 @@ export const faceStub = { imageHeight: 1024, imageWidth: 1024, sourceType: SourceType.MACHINE_LEARNING, - faceSearch: { faceId: 'assetFaceId2', embedding: [1, 2, 3, 4] }, + faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' }, }), mergeFace1: Object.freeze>({ id: 'assetFaceId3', @@ -49,7 +49,7 @@ export const faceStub = { imageHeight: 1024, imageWidth: 1024, sourceType: SourceType.MACHINE_LEARNING, - faceSearch: { faceId: 'assetFaceId3', embedding: [1, 2, 3, 4] }, + faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' }, }), start: Object.freeze>({ id: 'assetFaceId5', @@ -64,7 +64,7 @@ export const faceStub = { imageHeight: 2880, imageWidth: 2160, sourceType: SourceType.MACHINE_LEARNING, - faceSearch: { faceId: 'assetFaceId5', embedding: [1, 2, 3, 4] }, + faceSearch: { faceId: 'assetFaceId5', embedding: '[1, 2, 3, 4]' }, }), middle: Object.freeze>({ id: 'assetFaceId6', @@ -79,7 +79,7 @@ export const faceStub = { imageHeight: 500, imageWidth: 400, sourceType: SourceType.MACHINE_LEARNING, - faceSearch: { faceId: 'assetFaceId6', embedding: [1, 2, 3, 4] }, + faceSearch: { faceId: 'assetFaceId6', embedding: '[1, 2, 3, 4]' }, }), end: Object.freeze>({ id: 'assetFaceId7', @@ -94,7 +94,7 @@ export const faceStub = { imageHeight: 500, imageWidth: 500, sourceType: SourceType.MACHINE_LEARNING, - faceSearch: { faceId: 'assetFaceId7', embedding: [1, 2, 3, 4] }, + faceSearch: { faceId: 'assetFaceId7', embedding: '[1, 2, 3, 4]' }, }), noPerson1: Object.freeze({ id: 'assetFaceId8', @@ -109,7 +109,7 @@ export const faceStub = { imageHeight: 1024, imageWidth: 1024, sourceType: SourceType.MACHINE_LEARNING, - faceSearch: { faceId: 'assetFaceId8', embedding: [1, 2, 3, 4] }, + faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' }, }), noPerson2: Object.freeze({ id: 'assetFaceId9', @@ -124,7 +124,7 @@ export const faceStub = { imageHeight: 1024, imageWidth: 1024, sourceType: SourceType.MACHINE_LEARNING, - faceSearch: { faceId: 'assetFaceId9', embedding: [1, 2, 3, 4] }, + faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' }, }), fromExif1: Object.freeze({ id: 'assetFaceId9', diff --git a/server/test/utils.ts b/server/test/utils.ts index 929fcb9da0..df8ca96c09 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -254,3 +254,10 @@ export const mockSpawn = vitest.fn((exitCode: number, stdout: string, stderr: st }), } as unknown as ChildProcessWithoutNullStreams; }); + +export async function* makeStream(items: T[] = []): AsyncIterableIterator { + for (const item of items) { + await Promise.resolve(); + yield item; + } +} From 9a27a99cab41e630d561e80523ba1ef5647538b4 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 21 Jan 2025 13:13:09 -0500 Subject: [PATCH 17/24] refactor: config repository (#15495) * refactor: access repository * refactor: config repository --- server/src/cores/storage.core.ts | 2 +- server/src/interfaces/audit.interface.ts | 14 --- server/src/interfaces/config.interface.ts | 98 ------------------ server/src/middleware/websocket.adapter.ts | 4 +- server/src/repositories/audit.repository.ts | 9 +- server/src/repositories/config.repository.ts | 99 +++++++++++++++++-- .../src/repositories/database.repository.ts | 4 +- server/src/repositories/event.repository.ts | 3 +- server/src/repositories/index.ts | 6 +- server/src/repositories/job.repository.ts | 6 +- .../repositories/logger.repository.spec.ts | 2 +- server/src/repositories/logger.repository.ts | 6 +- server/src/repositories/map.repository.ts | 4 +- .../repositories/server-info.repository.ts | 4 +- .../src/repositories/telemetry.repository.ts | 4 +- server/src/services/api.service.ts | 4 +- server/src/services/audit.service.spec.ts | 2 +- server/src/services/backup.service.spec.ts | 2 +- server/src/services/base.service.ts | 8 +- server/src/services/database.service.spec.ts | 2 +- server/src/services/job.service.spec.ts | 2 +- server/src/services/library.service.spec.ts | 2 +- server/src/services/metadata.service.spec.ts | 2 +- .../src/services/smart-info.service.spec.ts | 2 +- server/src/services/storage.service.spec.ts | 2 +- server/src/services/sync.service.spec.ts | 2 +- .../services/system-config.service.spec.ts | 2 +- server/src/services/version.service.spec.ts | 2 +- server/src/types.ts | 4 + server/src/utils/config.ts | 2 +- server/src/workers/api.ts | 3 +- server/src/workers/microservices.ts | 3 +- .../repositories/audit.repository.mock.ts | 2 +- .../repositories/config.repository.mock.ts | 3 +- server/test/utils.ts | 5 +- 35 files changed, 150 insertions(+), 171 deletions(-) delete mode 100644 server/src/interfaces/audit.interface.ts delete mode 100644 server/src/interfaces/config.interface.ts diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index d26829d633..7285ff2163 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -5,13 +5,13 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { IConfigRepository } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; import { getConfig } from 'src/utils/config'; diff --git a/server/src/interfaces/audit.interface.ts b/server/src/interfaces/audit.interface.ts deleted file mode 100644 index 0b9f19d8db..0000000000 --- a/server/src/interfaces/audit.interface.ts +++ /dev/null @@ -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; - removeBefore(before: Date): Promise; -} diff --git a/server/src/interfaces/config.interface.ts b/server/src/interfaces/config.interface.ts deleted file mode 100644 index 8b45078039..0000000000 --- a/server/src/interfaces/config.interface.ts +++ /dev/null @@ -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; - }; - - storage: { - ignoreMountCheckErrors: boolean; - }; - - workers: ImmichWorker[]; - - noColor: boolean; - nodeVersion?: string; -} - -export interface IConfigRepository { - getEnv(): EnvData; - getWorker(): ImmichWorker | undefined; -} diff --git a/server/src/middleware/websocket.adapter.ts b/server/src/middleware/websocket.adapter.ts index da5e5e9816..64bb1f9ea5 100644 --- a/server/src/middleware/websocket.adapter.ts +++ b/server/src/middleware/websocket.adapter.ts @@ -3,7 +3,7 @@ import { IoAdapter } from '@nestjs/platform-socket.io'; import { createAdapter } from '@socket.io/redis-adapter'; import { Redis } from 'ioredis'; import { ServerOptions } from 'socket.io'; -import { IConfigRepository } from 'src/interfaces/config.interface'; +import { ConfigRepository } from 'src/repositories/config.repository'; export class WebSocketAdapter extends IoAdapter { constructor(private app: INestApplicationContext) { @@ -11,7 +11,7 @@ export class WebSocketAdapter extends IoAdapter { } createIOServer(port: number, options?: ServerOptions): any { - const { redis } = this.app.get(IConfigRepository).getEnv(); + const { redis } = this.app.get(ConfigRepository).getEnv(); const server = super.createIOServer(port, options); const pubClient = new Redis(redis); const subClient = pubClient.duplicate(); diff --git a/server/src/repositories/audit.repository.ts b/server/src/repositories/audit.repository.ts index 5731087aef..5961e4f25d 100644 --- a/server/src/repositories/audit.repository.ts +++ b/server/src/repositories/audit.repository.ts @@ -4,10 +4,15 @@ import { InjectKysely } from 'nestjs-kysely'; import { DB } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { DatabaseAction, EntityType } from 'src/enum'; -import { AuditSearch, IAuditRepository } from 'src/interfaces/audit.interface'; + +export interface AuditSearch { + action?: DatabaseAction; + entityType?: EntityType; + userIds: string[]; +} @Injectable() -export class AuditRepository implements IAuditRepository { +export class AuditRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index 67699880bd..d78e473da2 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -1,19 +1,106 @@ +import { RegisterQueueOptions } from '@nestjs/bullmq'; import { Inject, Injectable, Optional } from '@nestjs/common'; +import { QueueOptions } from 'bullmq'; import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; import { Request, Response } from 'express'; +import { RedisOptions } from 'ioredis'; +import { KyselyConfig } from 'kysely'; 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 postgres, { Notice } from 'postgres'; import { citiesFile, excludePaths, IWorker } from 'src/constants'; import { Telemetry } from 'src/decorators'; import { EnvDto } from 'src/dtos/env.dto'; -import { ImmichEnvironment, ImmichHeader, ImmichTelemetry, ImmichWorker } from 'src/enum'; -import { EnvData, IConfigRepository } from 'src/interfaces/config.interface'; -import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { ImmichEnvironment, ImmichHeader, ImmichTelemetry, ImmichWorker, LogLevel } from 'src/enum'; +import { DatabaseConnectionParams, DatabaseExtension, VectorExtension } from 'src/interfaces/database.interface'; import { QueueName } from 'src/interfaces/job.interface'; 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; + }; + + storage: { + ignoreMountCheckErrors: boolean; + }; + + workers: ImmichWorker[]; + + noColor: boolean; + nodeVersion?: string; +} const productionKeys = { client: @@ -269,10 +356,10 @@ let cached: EnvData | undefined; @Injectable() @Telemetry({ enabled: false }) -export class ConfigRepository implements IConfigRepository { +export class ConfigRepository { constructor(@Inject(IWorker) @Optional() private worker?: ImmichWorker) {} - getEnv(): EnvData { + getEnv() { if (!cached) { cached = getEnv(); } diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 7188678212..336da8f303 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -6,7 +6,6 @@ import { InjectKysely } from 'nestjs-kysely'; import semver from 'semver'; import { POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants'; import { DB } from 'src/db'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { DatabaseExtension, DatabaseLock, @@ -18,6 +17,7 @@ import { VectorUpdateResult, } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { UPSERT_COLUMNS } from 'src/utils/database'; import { isValidInteger } from 'src/validation'; import { DataSource, EntityManager, EntityMetadata, QueryRunner } from 'typeorm'; @@ -31,7 +31,7 @@ export class DatabaseRepository implements IDatabaseRepository { @InjectKysely() private db: Kysely, @InjectDataSource() private dataSource: DataSource, @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(IConfigRepository) configRepository: IConfigRepository, + configRepository: ConfigRepository, ) { this.vectorExtension = configRepository.getEnv().database.vectorExtension; this.logger.setContext(DatabaseRepository.name); diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 7de8defe6e..e1c31624d5 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -12,7 +12,6 @@ import _ from 'lodash'; import { Server, Socket } from 'socket.io'; import { EventConfig } from 'src/decorators'; import { ImmichWorker, MetadataKey } from 'src/enum'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { ArgsOf, ClientEventMap, @@ -52,7 +51,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect constructor( private moduleRef: ModuleRef, - @Inject(IConfigRepository) private configRepository: ConfigRepository, + private configRepository: ConfigRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(EventRepository.name); diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 434efa935f..68c79fbe98 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -1,8 +1,6 @@ import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumRepository } from 'src/interfaces/album.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 { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; @@ -78,15 +76,15 @@ export const repositories = [ // AccessRepository, ActivityRepository, + AuditRepository, ApiKeyRepository, + ConfigRepository, ]; export const providers = [ { provide: IAlbumRepository, useClass: AlbumRepository }, { provide: IAlbumUserRepository, useClass: AlbumUserRepository }, { provide: IAssetRepository, useClass: AssetRepository }, - { provide: IAuditRepository, useClass: AuditRepository }, - { provide: IConfigRepository, useClass: ConfigRepository }, { provide: ICronRepository, useClass: CronRepository }, { provide: ICryptoRepository, useClass: CryptoRepository }, { provide: IDatabaseRepository, useClass: DatabaseRepository }, diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index c6c2947617..e57b5ee964 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -1,13 +1,11 @@ import { getQueueToken } from '@nestjs/bullmq'; import { Inject, Injectable } from '@nestjs/common'; import { ModuleRef, Reflector } from '@nestjs/core'; -import { SchedulerRegistry } from '@nestjs/schedule'; import { JobsOptions, Queue, Worker } from 'bullmq'; import { ClassConstructor } from 'class-transformer'; import { setTimeout } from 'node:timers/promises'; import { JobConfig } from 'src/decorators'; import { MetadataKey } from 'src/enum'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IEntityJob, @@ -22,6 +20,7 @@ import { QueueStatus, } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc'; type JobMapItem = { @@ -38,8 +37,7 @@ export class JobRepository implements IJobRepository { constructor( private moduleRef: ModuleRef, - private schedulerRegistry: SchedulerRegistry, - @Inject(IConfigRepository) private configRepository: IConfigRepository, + private configRepository: ConfigRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { diff --git a/server/src/repositories/logger.repository.spec.ts b/server/src/repositories/logger.repository.spec.ts index dcb54ada7c..7354035763 100644 --- a/server/src/repositories/logger.repository.spec.ts +++ b/server/src/repositories/logger.repository.spec.ts @@ -1,7 +1,7 @@ import { ClsService } from 'nestjs-cls'; import { ImmichWorker } from 'src/enum'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { LoggerRepository } from 'src/repositories/logger.repository'; +import { IConfigRepository } from 'src/types'; import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; import { Mocked } from 'vitest'; diff --git a/server/src/repositories/logger.repository.ts b/server/src/repositories/logger.repository.ts index 4f1d3cac22..c4f0f91e15 100644 --- a/server/src/repositories/logger.repository.ts +++ b/server/src/repositories/logger.repository.ts @@ -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 { ClsService } from 'nestjs-cls'; import { Telemetry } from 'src/decorators'; import { LogLevel } from 'src/enum'; -import { IConfigRepository } from 'src/interfaces/config.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]; @@ -25,7 +25,7 @@ export class LoggerRepository extends ConsoleLogger implements ILoggerRepository constructor( private cls: ClsService, - @Inject(IConfigRepository) configRepository: IConfigRepository, + configRepository: ConfigRepository, ) { super(LoggerRepository.name); diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index 00870e78eb..4f6e78487d 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -11,7 +11,6 @@ import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db'; import { AssetEntity, withExif } from 'src/entities/asset.entity'; import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity'; import { LogLevel, SystemMetadataKey } from 'src/enum'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { GeoPoint, @@ -21,6 +20,7 @@ import { ReverseGeocodeResult, } from 'src/interfaces/map.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { ConfigRepository } from 'src/repositories/config.repository'; interface MapDB extends DB { geodata_places_tmp: GeodataPlaces; @@ -30,7 +30,7 @@ interface MapDB extends DB { @Injectable() export class MapRepository implements IMapRepository { constructor( - @Inject(IConfigRepository) private configRepository: IConfigRepository, + private configRepository: ConfigRepository, @Inject(ISystemMetadataRepository) private metadataRepository: ISystemMetadataRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @InjectKysely() private db: Kysely, diff --git a/server/src/repositories/server-info.repository.ts b/server/src/repositories/server-info.repository.ts index b4a4652871..13423d82b9 100644 --- a/server/src/repositories/server-info.repository.ts +++ b/server/src/repositories/server-info.repository.ts @@ -4,9 +4,9 @@ import { exec as execCallback } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { promisify } from 'node:util'; import sharp from 'sharp'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { GitHubRelease, IServerInfoRepository, ServerBuildVersions } from 'src/interfaces/server-info.interface'; +import { ConfigRepository } from 'src/repositories/config.repository'; const exec = promisify(execCallback); const maybeFirstLine = async (command: string): Promise => { @@ -36,7 +36,7 @@ const getLockfileVersion = (name: string, lockfile?: BuildLockfile) => { @Injectable() export class ServerInfoRepository implements IServerInfoRepository { constructor( - @Inject(IConfigRepository) private configRepository: IConfigRepository, + private configRepository: ConfigRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(ServerInfoRepository.name); diff --git a/server/src/repositories/telemetry.repository.ts b/server/src/repositories/telemetry.repository.ts index 2510460967..c4401c9da3 100644 --- a/server/src/repositories/telemetry.repository.ts +++ b/server/src/repositories/telemetry.repository.ts @@ -15,9 +15,9 @@ import { MetricService } from 'nestjs-otel'; import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils'; import { serverVersion } from 'src/constants'; import { ImmichTelemetry, MetadataKey } from 'src/enum'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMetricGroupRepository, ITelemetryRepository, MetricGroupOptions } from 'src/interfaces/telemetry.interface'; +import { ConfigRepository } from 'src/repositories/config.repository'; class MetricGroupRepository implements IMetricGroupRepository { private enabled = false; @@ -95,7 +95,7 @@ export class TelemetryRepository implements ITelemetryRepository { constructor( private metricService: MetricService, private reflect: Reflector, - @Inject(IConfigRepository) private configRepository: IConfigRepository, + private configRepository: ConfigRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { const { telemetry } = this.configRepository.getEnv(); diff --git a/server/src/services/api.service.ts b/server/src/services/api.service.ts index 66f8061d3c..3fec0dbc0a 100644 --- a/server/src/services/api.service.ts +++ b/server/src/services/api.service.ts @@ -3,8 +3,8 @@ import { Cron, CronExpression, Interval } from '@nestjs/schedule'; import { NextFunction, Request, Response } from 'express'; import { readFileSync } from 'node:fs'; import { ONE_HOUR } from 'src/constants'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { AuthService } from 'src/services/auth.service'; import { JobService } from 'src/services/job.service'; import { SharedLinkService } from 'src/services/shared-link.service'; @@ -38,7 +38,7 @@ export class ApiService { private jobService: JobService, private sharedLinkService: SharedLinkService, private versionService: VersionService, - @Inject(IConfigRepository) private configRepository: IConfigRepository, + private configRepository: ConfigRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(ApiService.name); diff --git a/server/src/services/audit.service.spec.ts b/server/src/services/audit.service.spec.ts index c7a51565af..dd853042fb 100644 --- a/server/src/services/audit.service.spec.ts +++ b/server/src/services/audit.service.spec.ts @@ -2,12 +2,12 @@ import { BadRequestException } from '@nestjs/common'; import { FileReportItemDto } from 'src/dtos/audit.dto'; import { AssetFileType, AssetPathType, DatabaseAction, EntityType, PersonPathType, UserPathType } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IAuditRepository } from 'src/interfaces/audit.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { JobStatus } from 'src/interfaces/job.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AuditService } from 'src/services/audit.service'; +import { IAuditRepository } from 'src/types'; import { auditStub } from 'test/fixtures/audit.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { newTestService } from 'test/utils'; diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts index 41ba7c2153..29adf9d8e1 100644 --- a/server/src/services/backup.service.spec.ts +++ b/server/src/services/backup.service.spec.ts @@ -2,7 +2,6 @@ import { PassThrough } from 'node:stream'; import { defaults, SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; import { ImmichWorker, StorageFolder } from 'src/enum'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { ICronRepository } from 'src/interfaces/cron.interface'; import { IDatabaseRepository } from 'src/interfaces/database.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 { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { BackupService } from 'src/services/backup.service'; +import { IConfigRepository } from 'src/types'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { mockSpawn, newTestService } from 'test/utils'; import { describe, Mocked } from 'vitest'; diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index adcddd8d66..054cc2acfa 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -9,8 +9,6 @@ import { UserEntity } from 'src/entities/user.entity'; import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumRepository } from 'src/interfaces/album.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 { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; @@ -45,6 +43,8 @@ import { IViewRepository } from 'src/interfaces/view.interface'; import { AccessRepository } from 'src/repositories/access.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 { AccessRequest, checkAccess, requireAccess } from 'src/utils/access'; import { getConfig, updateConfig } from 'src/utils/config'; @@ -55,11 +55,11 @@ export class BaseService { @Inject(ILoggerRepository) protected logger: ILoggerRepository, protected accessRepository: AccessRepository, protected activityRepository: ActivityRepository, - @Inject(IAuditRepository) protected auditRepository: IAuditRepository, + protected auditRepository: AuditRepository, @Inject(IAlbumRepository) protected albumRepository: IAlbumRepository, @Inject(IAlbumUserRepository) protected albumUserRepository: IAlbumUserRepository, @Inject(IAssetRepository) protected assetRepository: IAssetRepository, - @Inject(IConfigRepository) protected configRepository: IConfigRepository, + protected configRepository: ConfigRepository, @Inject(ICronRepository) protected cronRepository: ICronRepository, @Inject(ICryptoRepository) protected cryptoRepository: ICryptoRepository, @Inject(IDatabaseRepository) protected databaseRepository: IDatabaseRepository, diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index ef60415402..9458ba768b 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -1,5 +1,4 @@ import { PostgresJSDialect } from 'kysely-postgres-js'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { DatabaseExtension, EXTENSION_NAMES, @@ -8,6 +7,7 @@ import { } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { DatabaseService } from 'src/services/database.service'; +import { IConfigRepository } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index a23b05073c..5714f7fdd5 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -2,11 +2,11 @@ import { BadRequestException } from '@nestjs/common'; import { defaults, SystemConfig } from 'src/config'; import { ImmichWorker } from 'src/enum'; 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 { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ITelemetryRepository } from 'src/interfaces/telemetry.interface'; import { JobService } from 'src/services/job.service'; +import { IConfigRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index a08cb108a5..e2d805b865 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -5,7 +5,6 @@ import { mapLibrary } from 'src/dtos/library.dto'; import { UserEntity } from 'src/entities/user.entity'; import { AssetType, ImmichWorker } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { ICronRepository } from 'src/interfaces/cron.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { @@ -19,6 +18,7 @@ import { import { ILibraryRepository } from 'src/interfaces/library.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { LibraryService } from 'src/services/library.service'; +import { IConfigRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { libraryStub } from 'test/fixtures/library.stub'; diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 24d2b0e17f..6617ec9e24 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -7,7 +7,6 @@ import { ExifEntity } from 'src/entities/exif.entity'; import { AssetType, ExifOrientation, ImmichWorker, SourceType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IEventRepository } from 'src/interfaces/event.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 { IUserRepository } from 'src/interfaces/user.interface'; import { MetadataService } from 'src/services/metadata.service'; +import { IConfigRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { probeStub } from 'test/fixtures/media.stub'; diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index d485f4244b..ff0dcc3160 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,13 +1,13 @@ import { SystemConfig } from 'src/config'; import { ImmichWorker } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SmartInfoService } from 'src/services/smart-info.service'; +import { IConfigRepository } from 'src/types'; import { getCLIPModelInfo } from 'src/utils/misc'; import { assetStub } from 'test/fixtures/asset.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index dd97a063ae..7b5b2384e4 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -1,9 +1,9 @@ import { SystemMetadataKey } from 'src/enum'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { StorageService } from 'src/services/storage.service'; +import { IConfigRepository } from 'src/types'; import { ImmichStartupError } from 'src/utils/misc'; import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTestService } from 'test/utils'; diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts index 8dc270d020..3bedd13d8f 100644 --- a/server/src/services/sync.service.spec.ts +++ b/server/src/services/sync.service.spec.ts @@ -1,9 +1,9 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IAuditRepository } from 'src/interfaces/audit.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { SyncService } from 'src/services/sync.service'; +import { IAuditRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 2a20f32933..87991fc6c7 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -12,12 +12,12 @@ import { VideoCodec, VideoContainer, } from 'src/enum'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { QueueName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SystemConfigService } from 'src/services/system-config.service'; +import { IConfigRepository } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTestService } from 'test/utils'; import { DeepPartial } from 'typeorm'; diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 46f8f620c4..a49f721355 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -2,7 +2,6 @@ import { DateTime } from 'luxon'; import { SemVer } from 'semver'; import { serverVersion } from 'src/constants'; import { ImmichEnvironment, SystemMetadataKey } from 'src/enum'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; @@ -10,6 +9,7 @@ import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; import { VersionService } from 'src/services/version.service'; +import { IConfigRepository } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; diff --git a/server/src/types.ts b/server/src/types.ts index dd1fea710f..f6c1ae46dd 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -3,6 +3,8 @@ import { Permission } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.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'; export type AuthApiKey = { id: string; @@ -16,6 +18,8 @@ export type RepositoryInterface = Pick; export type IActivityRepository = RepositoryInterface; export type IAccessRepository = { [K in keyof AccessRepository]: RepositoryInterface }; export type IApiKeyRepository = RepositoryInterface; +export type IAuditRepository = RepositoryInterface; +export type IConfigRepository = RepositoryInterface; export type ActivityItem = | Awaited> diff --git a/server/src/utils/config.ts b/server/src/utils/config.ts index ce8a2da839..645d0fe093 100644 --- a/server/src/utils/config.ts +++ b/server/src/utils/config.ts @@ -6,10 +6,10 @@ import * as _ from 'lodash'; import { SystemConfig, defaults } from 'src/config'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { SystemMetadataKey } from 'src/enum'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { DatabaseLock } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { IConfigRepository } from 'src/types'; import { getKeysDeep, unsetDeep } from 'src/utils/misc'; import { DeepPartial } from 'typeorm'; diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index efc705deaf..14786497c8 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -7,7 +7,6 @@ import sirv from 'sirv'; import { ApiModule } from 'src/app.module'; import { excludePaths, serverVersion } from 'src/constants'; import { ImmichEnvironment } from 'src/enum'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; import { ConfigRepository } from 'src/repositories/config.repository'; @@ -25,7 +24,7 @@ async function bootstrap() { const app = await NestFactory.create(ApiModule, { bufferLogs: true }); const logger = await app.resolve(ILoggerRepository); - const configRepository = app.get(IConfigRepository); + const configRepository = app.get(ConfigRepository); const { environment, host, port, resourcePaths } = configRepository.getEnv(); const isDev = environment === ImmichEnvironment.DEVELOPMENT; diff --git a/server/src/workers/microservices.ts b/server/src/workers/microservices.ts index 0fa056d5d4..34ad41ae26 100644 --- a/server/src/workers/microservices.ts +++ b/server/src/workers/microservices.ts @@ -2,7 +2,6 @@ import { NestFactory } from '@nestjs/core'; import { isMainThread } from 'node:worker_threads'; import { MicroservicesModule } from 'src/app.module'; import { serverVersion } from 'src/constants'; -import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; import { ConfigRepository } from 'src/repositories/config.repository'; @@ -23,7 +22,7 @@ export async function bootstrap() { await app.listen(0); - const configRepository = app.get(IConfigRepository); + const configRepository = app.get(ConfigRepository); const { environment } = configRepository.getEnv(); logger.log(`Immich Microservices is running [v${serverVersion}] [${environment}] `); } diff --git a/server/test/repositories/audit.repository.mock.ts b/server/test/repositories/audit.repository.mock.ts index 13af834ce9..96fe407c96 100644 --- a/server/test/repositories/audit.repository.mock.ts +++ b/server/test/repositories/audit.repository.mock.ts @@ -1,4 +1,4 @@ -import { IAuditRepository } from 'src/interfaces/audit.interface'; +import { IAuditRepository } from 'src/types'; import { Mocked, vitest } from 'vitest'; export const newAuditRepositoryMock = (): Mocked => { diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index 00cca308a7..ab8731ea4d 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -1,8 +1,9 @@ import { PostgresJSDialect } from 'kysely-postgres-js'; import postgres from 'postgres'; import { ImmichEnvironment, ImmichWorker } from 'src/enum'; -import { EnvData, IConfigRepository } from 'src/interfaces/config.interface'; import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { EnvData } from 'src/repositories/config.repository'; +import { IConfigRepository } from 'src/types'; import { Mocked, vitest } from 'vitest'; const envData: EnvData = { diff --git a/server/test/utils.ts b/server/test/utils.ts index df8ca96c09..363f2fcda7 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -6,8 +6,9 @@ import { IMetadataRepository } from 'src/interfaces/metadata.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; +import { AuditRepository } from 'src/repositories/audit.repository'; import { BaseService } from 'src/services/base.service'; -import { IAccessRepository, IActivityRepository, IApiKeyRepository } from 'src/types'; +import { IAccessRepository, IActivityRepository, IApiKeyRepository, IAuditRepository } from 'src/types'; import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newActivityRepositoryMock } from 'test/repositories/activity.repository.mock'; import { newAlbumUserRepositoryMock } from 'test/repositories/album-user.repository.mock'; @@ -109,7 +110,7 @@ export const newTestService = ( loggerMock, accessMock as IAccessRepository as AccessRepository, activityMock as IActivityRepository as ActivityRepository, - auditMock, + auditMock as IAuditRepository as AuditRepository, albumMock, albumUserMock, assetMock, From 5171630b982d415d08b7fca20c645918edd58bd5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Jan 2025 13:17:55 -0500 Subject: [PATCH 18/24] fix(deps): update machine-learning (#15494) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- machine-learning/poetry.lock | 58 ++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index c7944b3f51..6287ff82c7 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -1649,8 +1649,8 @@ psutil = ">=5.9.1" pywin32 = {version = "*", markers = "sys_platform == \"win32\""} pyzmq = ">=25.0.0" 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.26.0", markers = "python_full_version <= \"3.11.0\""}, ] setuptools = ">=70.0.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} @@ -2165,26 +2165,26 @@ sympy = "*" [[package]] name = "opencv-python-headless" -version = "4.10.0.84" +version = "4.11.0.86" description = "Wrapper package for OpenCV python bindings." optional = false python-versions = ">=3.6" files = [ - {file = "opencv-python-headless-4.10.0.84.tar.gz", hash = "sha256:f2017c6101d7c2ef8d7bc3b414c37ff7f54d64413a1847d89970b6b7069b4e1a"}, - {file = "opencv_python_headless-4.10.0.84-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:a4f4bcb07d8f8a7704d9c8564c224c8b064c63f430e95b61ac0bffaa374d330e"}, - {file = "opencv_python_headless-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", hash = "sha256:5ae454ebac0eb0a0b932e3406370aaf4212e6a3fdb5038cc86c7aea15a6851da"}, - {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.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:377d08a7e48a1405b5e84afcbe4798464ce7ee17081c1c23619c8b398ff18295"}, - {file = "opencv_python_headless-4.10.0.84-cp37-abi3-win32.whl", hash = "sha256:9092404b65458ed87ce932f613ffbb1106ed2c843577501e5768912360fc50ec"}, - {file = "opencv_python_headless-4.10.0.84-cp37-abi3-win_amd64.whl", hash = "sha256:afcf28bd1209dd58810d33defb622b325d3cbe49dcd7a43a902982c33e5fad05"}, + {file = "opencv-python-headless-4.11.0.86.tar.gz", hash = "sha256:996eb282ca4b43ec6a3972414de0e2331f5d9cda2b41091a49739c19fb843798"}, + {file = "opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:48128188ade4a7e517237c8e1e11a9cdf5c282761473383e77beb875bb1e61ca"}, + {file = "opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:a66c1b286a9de872c343ee7c3553b084244299714ebb50fbdcd76f07ebbe6c81"}, + {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.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e0a27c19dd1f40ddff94976cfe43066fbbe9dfbb2ec1907d66c19caef42a57b"}, + {file = "opencv_python_headless-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:f447d8acbb0b6f2808da71fddd29c1cdd448d2bc98f72d9bb78a7a898fc9621b"}, + {file = "opencv_python_headless-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:6c304df9caa7a6a5710b91709dd4786bf20a74d57672b3c31f7033cc638174ca"}, ] [package.dependencies] 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.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.26.0", markers = "python_version >= \"3.12\""}, ] [[package]] @@ -3049,29 +3049,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.9.1" +version = "0.9.2" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.9.1-py3-none-linux_armv6l.whl", hash = "sha256:84330dda7abcc270e6055551aca93fdde1b0685fc4fd358f26410f9349cf1743"}, - {file = "ruff-0.9.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3cae39ba5d137054b0e5b472aee3b78a7c884e61591b100aeb544bcd1fc38d4f"}, - {file = "ruff-0.9.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:50c647ff96f4ba288db0ad87048257753733763b409b2faf2ea78b45c8bb7fcb"}, - {file = "ruff-0.9.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0c8b149e9c7353cace7d698e1656ffcf1e36e50f8ea3b5d5f7f87ff9986a7ca"}, - {file = "ruff-0.9.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:beb3298604540c884d8b282fe7625651378e1986c25df51dec5b2f60cafc31ce"}, - {file = "ruff-0.9.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39d0174ccc45c439093971cc06ed3ac4dc545f5e8bdacf9f067adf879544d969"}, - {file = "ruff-0.9.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:69572926c0f0c9912288915214ca9b2809525ea263603370b9e00bed2ba56dbd"}, - {file = "ruff-0.9.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:937267afce0c9170d6d29f01fcd1f4378172dec6760a9f4dface48cdabf9610a"}, - {file = "ruff-0.9.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:186c2313de946f2c22bdf5954b8dd083e124bcfb685732cfb0beae0c47233d9b"}, - {file = "ruff-0.9.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f94942a3bb767675d9a051867c036655fe9f6c8a491539156a6f7e6b5f31831"}, - {file = "ruff-0.9.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:728d791b769cc28c05f12c280f99e8896932e9833fef1dd8756a6af2261fd1ab"}, - {file = "ruff-0.9.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2f312c86fb40c5c02b44a29a750ee3b21002bd813b5233facdaf63a51d9a85e1"}, - {file = "ruff-0.9.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ae017c3a29bee341ba584f3823f805abbe5fe9cd97f87ed07ecbf533c4c88366"}, - {file = "ruff-0.9.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5dc40a378a0e21b4cfe2b8a0f1812a6572fc7b230ef12cd9fac9161aa91d807f"}, - {file = "ruff-0.9.1-py3-none-win32.whl", hash = "sha256:46ebf5cc106cf7e7378ca3c28ce4293b61b449cd121b98699be727d40b79ba72"}, - {file = "ruff-0.9.1-py3-none-win_amd64.whl", hash = "sha256:342a824b46ddbcdddd3abfbb332fa7fcaac5488bf18073e841236aadf4ad5c19"}, - {file = "ruff-0.9.1-py3-none-win_arm64.whl", hash = "sha256:1cd76c7f9c679e6e8f2af8f778367dca82b95009bc7b1a85a47f1521ae524fa7"}, - {file = "ruff-0.9.1.tar.gz", hash = "sha256:fd2b25ecaf907d6458fa842675382c8597b3c746a2dde6717fe3415425df0c17"}, + {file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"}, + {file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"}, + {file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"}, + {file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"}, + {file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"}, + {file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"}, + {file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"}, + {file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"}, + {file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"}, + {file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"}, + {file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"}, + {file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"}, ] [[package]] From ccf6d71c3c0a14d5def98ac884e7155286b9dd34 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 21 Jan 2025 13:26:13 -0500 Subject: [PATCH 19/24] refactor: view repository (#15496) --- server/src/interfaces/view.interface.ts | 8 -------- server/src/repositories/index.ts | 3 +-- server/src/repositories/view-repository.ts | 11 +++++------ server/src/services/base.service.ts | 4 ++-- server/src/services/view.service.spec.ts | 4 ++-- server/src/services/view.service.ts | 3 ++- server/src/types.ts | 2 ++ server/test/repositories/view.repository.mock.ts | 2 +- server/test/utils.ts | 11 +++++++++-- 9 files changed, 24 insertions(+), 24 deletions(-) delete mode 100644 server/src/interfaces/view.interface.ts diff --git a/server/src/interfaces/view.interface.ts b/server/src/interfaces/view.interface.ts deleted file mode 100644 index f819160002..0000000000 --- a/server/src/interfaces/view.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { AssetEntity } from 'src/entities/asset.entity'; - -export const IViewRepository = 'IViewRepository'; - -export interface IViewRepository { - getAssetsByOriginalPath(userId: string, partialPath: string): Promise; - getUniqueOriginalPaths(userId: string): Promise; -} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 68c79fbe98..a041cfdb0b 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -31,7 +31,6 @@ import { ITelemetryRepository } from 'src/interfaces/telemetry.interface'; import { ITrashRepository } from 'src/interfaces/trash.interface'; import { IUserRepository } from 'src/interfaces/user.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 { AlbumUserRepository } from 'src/repositories/album-user.repository'; @@ -79,6 +78,7 @@ export const repositories = [ AuditRepository, ApiKeyRepository, ConfigRepository, + ViewRepository, ]; export const providers = [ @@ -115,5 +115,4 @@ export const providers = [ { provide: ITrashRepository, useClass: TrashRepository }, { provide: IUserRepository, useClass: UserRepository }, { provide: IVersionHistoryRepository, useClass: VersionHistoryRepository }, - { provide: IViewRepository, useClass: ViewRepository }, ]; diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts index 13a042a174..f24b1bac6e 100644 --- a/server/src/repositories/view-repository.ts +++ b/server/src/repositories/view-repository.ts @@ -2,15 +2,14 @@ import { Kysely } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DB } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { AssetEntity, withExif } from 'src/entities/asset.entity'; -import { IViewRepository } from 'src/interfaces/view.interface'; +import { withExif } from 'src/entities/asset.entity'; import { asUuid } from 'src/utils/database'; -export class ViewRepository implements IViewRepository { +export class ViewRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID] }) - async getUniqueOriginalPaths(userId: string): Promise { + async getUniqueOriginalPaths(userId: string) { const results = await this.db .selectFrom('assets') .select((eb) => eb.fn('substring', ['assets.originalPath', eb.val('^(.*/)[^/]*$')]).as('directoryPath')) @@ -25,7 +24,7 @@ export class ViewRepository implements IViewRepository { } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async getAssetsByOriginalPath(userId: string, partialPath: string): Promise { + async getAssetsByOriginalPath(userId: string, partialPath: string) { const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, ''); 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`)]), 'asc', ) - .execute() as any as Promise; + .execute(); } } diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 054cc2acfa..0ab7979c14 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -39,12 +39,12 @@ import { ITelemetryRepository } from 'src/interfaces/telemetry.interface'; import { ITrashRepository } from 'src/interfaces/trash.interface'; import { IUserRepository } from 'src/interfaces/user.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 { 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 { getConfig, updateConfig } from 'src/utils/config'; @@ -90,7 +90,7 @@ export class BaseService { @Inject(ITrashRepository) protected trashRepository: ITrashRepository, @Inject(IUserRepository) protected userRepository: IUserRepository, @Inject(IVersionHistoryRepository) protected versionRepository: IVersionHistoryRepository, - @Inject(IViewRepository) protected viewRepository: IViewRepository, + protected viewRepository: ViewRepository, ) { this.logger.setContext(this.constructor.name); this.storageCore = StorageCore.create( diff --git a/server/src/services/view.service.spec.ts b/server/src/services/view.service.spec.ts index e9373ce66f..e033ec0dc8 100644 --- a/server/src/services/view.service.spec.ts +++ b/server/src/services/view.service.spec.ts @@ -1,6 +1,6 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; -import { IViewRepository } from 'src/interfaces/view.interface'; import { ViewService } from 'src/services/view.service'; +import { IViewRepository } from 'src/types'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { newTestService } from 'test/utils'; @@ -42,7 +42,7 @@ describe(ViewService.name, () => { const mockAssetReponseDto = mockAssets.map((a) => mapAsset(a, { auth: authStub.admin })); - viewMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets); + viewMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets as any); const result = await sut.getAssetsByOriginalPath(authStub.admin, path); expect(result).toEqual(mockAssetReponseDto); diff --git a/server/src/services/view.service.ts b/server/src/services/view.service.ts index cb80536870..f1ef40a810 100644 --- a/server/src/services/view.service.ts +++ b/server/src/services/view.service.ts @@ -1,5 +1,6 @@ import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetEntity } from 'src/entities/asset.entity'; import { BaseService } from 'src/services/base.service'; export class ViewService extends BaseService { @@ -9,6 +10,6 @@ export class ViewService extends BaseService { async getAssetsByOriginalPath(auth: AuthDto, path: string): Promise { const assets = await this.viewRepository.getAssetsByOriginalPath(auth.user.id, path); - return assets.map((asset) => mapAsset(asset, { auth })); + return assets.map((asset) => mapAsset(asset as unknown as AssetEntity, { auth })); } } diff --git a/server/src/types.ts b/server/src/types.ts index f6c1ae46dd..55e19c8aee 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -5,6 +5,7 @@ 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'; export type AuthApiKey = { id: string; @@ -20,6 +21,7 @@ export type IAccessRepository = { [K in keyof AccessRepository]: RepositoryInter export type IApiKeyRepository = RepositoryInterface; export type IAuditRepository = RepositoryInterface; export type IConfigRepository = RepositoryInterface; +export type IViewRepository = RepositoryInterface; export type ActivityItem = | Awaited> diff --git a/server/test/repositories/view.repository.mock.ts b/server/test/repositories/view.repository.mock.ts index a002362ae7..bb58fda8a3 100644 --- a/server/test/repositories/view.repository.mock.ts +++ b/server/test/repositories/view.repository.mock.ts @@ -1,4 +1,4 @@ -import { IViewRepository } from 'src/interfaces/view.interface'; +import { IViewRepository } from 'src/types'; import { Mocked, vitest } from 'vitest'; export const newViewRepositoryMock = (): Mocked => { diff --git a/server/test/utils.ts b/server/test/utils.ts index 363f2fcda7..a5537dcc2d 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -7,8 +7,15 @@ import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; +import { ViewRepository } from 'src/repositories/view-repository'; import { BaseService } from 'src/services/base.service'; -import { IAccessRepository, IActivityRepository, IApiKeyRepository, IAuditRepository } from 'src/types'; +import { + IAccessRepository, + IActivityRepository, + IApiKeyRepository, + IAuditRepository, + IViewRepository, +} from 'src/types'; import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newActivityRepositoryMock } from 'test/repositories/activity.repository.mock'; import { newAlbumUserRepositoryMock } from 'test/repositories/album-user.repository.mock'; @@ -145,7 +152,7 @@ export const newTestService = ( trashMock, userMock, versionHistoryMock, - viewMock, + viewMock as IViewRepository as ViewRepository, ); return { From 3da17da7b42c4f9371488d143dab82c7fe6f22cf Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Tue, 21 Jan 2025 13:59:13 -0500 Subject: [PATCH 20/24] fix(docs): remove old attribution (#15501) update --- docs/docs/guides/custom-locations.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/docs/guides/custom-locations.md b/docs/docs/guides/custom-locations.md index 514008611d..08f75b3e9d 100644 --- a/docs/docs/guides/custom-locations.md +++ b/docs/docs/guides/custom-locations.md @@ -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. ::: - -Thanks to [Jrasm91](https://github.com/immich-app/immich/discussions/2110#discussioncomment-5477767) for writing the guide. From 8440f146e254579ed1ee34290140b3a63270b80b Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Tue, 21 Jan 2025 13:59:30 -0500 Subject: [PATCH 21/24] feat(docs): CIFS/Samba in-Docker example (#15502) * CIFS * quotes * quote 2 * quote 3, lol --- docs/docs/FAQ.mdx | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index 71ddcf0d33..c605c564cd 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -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. +### 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 From 36058b9b597ed606987db084267fed1abfc4fc52 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 21 Jan 2025 16:47:48 -0500 Subject: [PATCH 22/24] chore: remove unused code (#15499) --- server/src/entities/system-metadata.entity.ts | 3 +- server/src/entities/user-metadata.entity.ts | 3 +- .../services/system-config.service.spec.ts | 3 +- server/src/types.ts | 2 + server/src/utils/config.ts | 3 +- server/src/utils/pagination.ts | 49 +------------------ server/src/utils/preferences.ts | 2 +- server/test/fixtures/system-config.stub.ts | 2 +- 8 files changed, 11 insertions(+), 56 deletions(-) diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts index 0a03a55403..678b8f701a 100644 --- a/server/src/entities/system-metadata.entity.ts +++ b/server/src/entities/system-metadata.entity.ts @@ -1,6 +1,7 @@ import { SystemConfig } from 'src/config'; 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') export class SystemMetadataEntity { diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index c342cb71f8..2c901426c3 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -1,7 +1,8 @@ import { UserEntity } from 'src/entities/user.entity'; import { UserAvatarColor, UserMetadataKey } from 'src/enum'; +import { DeepPartial } from 'src/types'; import { HumanReadableSize } from 'src/utils/bytes'; -import { Column, DeepPartial, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; +import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; @Entity('user_metadata') export class UserMetadataEntity { diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 87991fc6c7..bba6201334 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -17,10 +17,9 @@ import { QueueName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SystemConfigService } from 'src/services/system-config.service'; -import { IConfigRepository } from 'src/types'; +import { DeepPartial, IConfigRepository } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTestService } from 'test/utils'; -import { DeepPartial } from 'typeorm'; import { Mocked } from 'vitest'; const partialConfig = { diff --git a/server/src/types.ts b/server/src/types.ts index 55e19c8aee..63bd8f8f05 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -7,6 +7,8 @@ import { AuditRepository } from 'src/repositories/audit.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { ViewRepository } from 'src/repositories/view-repository'; +export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial } : T; + export type AuthApiKey = { id: string; key: string; diff --git a/server/src/utils/config.ts b/server/src/utils/config.ts index 645d0fe093..317f41d120 100644 --- a/server/src/utils/config.ts +++ b/server/src/utils/config.ts @@ -9,9 +9,8 @@ import { SystemMetadataKey } from 'src/enum'; import { DatabaseLock } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IConfigRepository } from 'src/types'; +import { DeepPartial, IConfigRepository } from 'src/types'; import { getKeysDeep, unsetDeep } from 'src/utils/misc'; -import { DeepPartial } from 'typeorm'; export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise; diff --git a/server/src/utils/pagination.ts b/server/src/utils/pagination.ts index 4f1bd1a7f8..7cb31d1e04 100644 --- a/server/src/utils/pagination.ts +++ b/server/src/utils/pagination.ts @@ -1,18 +1,8 @@ -import _ from 'lodash'; -import { PaginationMode } from 'src/enum'; -import { FindManyOptions, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm'; - export interface PaginationOptions { take: number; skip?: number; } -export interface PaginatedBuilderOptions { - take: number; - skip?: number; - mode?: PaginationMode; -} - export interface PaginationResult { items: T[]; hasNextPage: boolean; @@ -33,46 +23,9 @@ export async function* usePagination( } } -export function paginationHelper( - items: Entity[], - take: number, -): PaginationResult { +export function paginationHelper(items: Entity[], take: number): PaginationResult { const hasNextPage = items.length > take; items.splice(take); return { items, hasNextPage }; } - -export async function paginate( - repository: Repository, - { take, skip }: PaginationOptions, - searchOptions?: FindManyOptions, -): Paginated { - const items = await repository.find( - _.omitBy( - { - ...searchOptions, - // Take one more item to check if there's a next page - take: take + 1, - skip, - }, - _.isUndefined, - ), - ); - - return paginationHelper(items, take); -} - -export async function paginatedBuilder( - qb: SelectQueryBuilder, - { take, skip, mode }: PaginatedBuilderOptions, -): Paginated { - if (mode === PaginationMode.LIMIT_OFFSET) { - qb.limit(take + 1).offset(skip); - } else { - qb.take(take + 1).skip(skip); - } - - const items = await qb.getMany(); - return paginationHelper(items, take); -} diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index beaeb472ec..ed9b5f2b83 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -3,8 +3,8 @@ import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { UserMetadataKey } from 'src/enum'; +import { DeepPartial } from 'src/types'; import { getKeysDeep } from 'src/utils/misc'; -import { DeepPartial } from 'typeorm'; export const getPreferences = (user: UserEntity) => { const preferences = getDefaultPreferences(user); diff --git a/server/test/fixtures/system-config.stub.ts b/server/test/fixtures/system-config.stub.ts index ed8cc8694a..89828d781f 100644 --- a/server/test/fixtures/system-config.stub.ts +++ b/server/test/fixtures/system-config.stub.ts @@ -1,5 +1,5 @@ import { SystemConfig } from 'src/config'; -import { DeepPartial } from 'typeorm'; +import { DeepPartial } from 'src/types'; export const systemConfigStub = { enabled: { From 58a75d59bdaaea96de3f333ef561b2dcb50a806a Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 21 Jan 2025 16:16:26 -0600 Subject: [PATCH 23/24] chore: update ui 14.1 (#15498) --- web/package-lock.json | 10 +++++----- web/package.json | 2 +- web/src/routes/admin/user-management/+page.svelte | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 24dd96623f..4e25c347e1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.13.0", + "@immich/ui": "^0.14.1", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", @@ -81,7 +81,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.10.5", + "@types/node": "^22.10.7", "typescript": "^5.3.3" } }, @@ -1305,9 +1305,9 @@ "link": true }, "node_modules/@immich/ui": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.13.0.tgz", - "integrity": "sha512-kO6uDbO+UpwRdzDI4FSyXkB7UXNDcnMo86gyLfdjZj6on9fy5Eam9KpJlt/zvVDNAqyGQzrBmdQSQl6n+S1JuA==", + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.14.1.tgz", + "integrity": "sha512-s5HGT35Odu6PrjO49xJ1Kpe7v1K53iMV03tv6MePVIdIM9sZHA04+o0tJgMm2KqLPZrkU+jhKPSfSy0PqkoEyQ==", "license": "GNU Affero General Public License version 3", "dependencies": { "@mdi/js": "^7.4.47", diff --git a/web/package.json b/web/package.json index 2a00be03d1..82665239ff 100644 --- a/web/package.json +++ b/web/package.json @@ -67,7 +67,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.13.0", + "@immich/ui": "^0.14.1", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index 0ce8a1d018..1ad56644f5 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -253,7 +253,7 @@ {/if} - + From c8abe9a2fd1cfc7bc65777591e629fc2fe682b79 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Jan 2025 16:16:46 -0600 Subject: [PATCH 24/24] chore(deps): update node.js to v22.13.1 (#15503) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/.nvmrc | 2 +- cli/package.json | 2 +- docs/.nvmrc | 2 +- docs/package.json | 2 +- e2e/.nvmrc | 2 +- e2e/package.json | 2 +- open-api/typescript-sdk/.nvmrc | 2 +- open-api/typescript-sdk/package.json | 2 +- server/.nvmrc | 2 +- server/package.json | 2 +- web/.nvmrc | 2 +- web/package.json | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cli/.nvmrc b/cli/.nvmrc index 6fa8dec4cd..d5b283a3ac 100644 --- a/cli/.nvmrc +++ b/cli/.nvmrc @@ -1 +1 @@ -22.13.0 +22.13.1 diff --git a/cli/package.json b/cli/package.json index dcdd3acdb8..d2ba17ea7c 100644 --- a/cli/package.json +++ b/cli/package.json @@ -67,6 +67,6 @@ "lodash-es": "^4.17.21" }, "volta": { - "node": "22.13.0" + "node": "22.13.1" } } diff --git a/docs/.nvmrc b/docs/.nvmrc index 6fa8dec4cd..d5b283a3ac 100644 --- a/docs/.nvmrc +++ b/docs/.nvmrc @@ -1 +1 @@ -22.13.0 +22.13.1 diff --git a/docs/package.json b/docs/package.json index a77a41656a..0574e83c6f 100644 --- a/docs/package.json +++ b/docs/package.json @@ -55,6 +55,6 @@ "node": ">=20" }, "volta": { - "node": "22.13.0" + "node": "22.13.1" } } diff --git a/e2e/.nvmrc b/e2e/.nvmrc index 6fa8dec4cd..d5b283a3ac 100644 --- a/e2e/.nvmrc +++ b/e2e/.nvmrc @@ -1 +1 @@ -22.13.0 +22.13.1 diff --git a/e2e/package.json b/e2e/package.json index 7a590c2323..7779f86467 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -53,6 +53,6 @@ "vitest": "^2.0.5" }, "volta": { - "node": "22.13.0" + "node": "22.13.1" } } diff --git a/open-api/typescript-sdk/.nvmrc b/open-api/typescript-sdk/.nvmrc index 6fa8dec4cd..d5b283a3ac 100644 --- a/open-api/typescript-sdk/.nvmrc +++ b/open-api/typescript-sdk/.nvmrc @@ -1 +1 @@ -22.13.0 +22.13.1 diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 7dc053f4fb..91c3c22e14 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -28,6 +28,6 @@ "directory": "open-api/typescript-sdk" }, "volta": { - "node": "22.13.0" + "node": "22.13.1" } } diff --git a/server/.nvmrc b/server/.nvmrc index 6fa8dec4cd..d5b283a3ac 100644 --- a/server/.nvmrc +++ b/server/.nvmrc @@ -1 +1 @@ -22.13.0 +22.13.1 diff --git a/server/package.json b/server/package.json index ebde6a4159..981c031386 100644 --- a/server/package.json +++ b/server/package.json @@ -145,6 +145,6 @@ "vitest": "^2.0.5" }, "volta": { - "node": "22.13.0" + "node": "22.13.1" } } diff --git a/web/.nvmrc b/web/.nvmrc index 6fa8dec4cd..d5b283a3ac 100644 --- a/web/.nvmrc +++ b/web/.nvmrc @@ -1 +1 @@ -22.13.0 +22.13.1 diff --git a/web/package.json b/web/package.json index 82665239ff..eb6d9f3139 100644 --- a/web/package.json +++ b/web/package.json @@ -88,6 +88,6 @@ "thumbhash": "^0.1.1" }, "volta": { - "node": "22.13.0" + "node": "22.13.1" } }