From a0b2c69b99edbeb108cabf9e362fbf0a261fa78e Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 20 Jan 2025 07:25:43 -0600 Subject: [PATCH 01/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 02/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 03/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 04/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 05/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 06/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 07/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 08/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 09/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 10/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 11/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 12/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 13/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 14/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 15/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 16/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 17/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 18/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 19/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 20/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 21/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 22/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" } } From 8d6cbb51e26117ba68cddc5abd6d161b0d16f6f4 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 22 Jan 2025 13:13:09 -0500 Subject: [PATCH 23/24] fix: get asset by id for stacks (#15522) --- e2e/src/api/specs/stack.e2e-spec.ts | 124 +++++++------------- server/src/repositories/asset.repository.ts | 18 ++- 2 files changed, 61 insertions(+), 81 deletions(-) diff --git a/e2e/src/api/specs/stack.e2e-spec.ts b/e2e/src/api/specs/stack.e2e-spec.ts index bf34369ee3..36b600bec9 100644 --- a/e2e/src/api/specs/stack.e2e-spec.ts +++ b/e2e/src/api/specs/stack.e2e-spec.ts @@ -119,93 +119,57 @@ describe('/stacks', () => { const stacksAfter = await searchStacks({}, { headers: asBearerAuth(user1.accessToken) }); expect(stacksAfter.length).toBe(stacksBefore.length); }); - - // it('should require a valid parent id', async () => { - // const { status, body } = await request(app) - // .put('/assets') - // .set('Authorization', `Bearer ${user1.accessToken}`) - // .send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] }); - - // expect(status).toBe(400); - // expect(body).toEqual(errorDto.badRequest(['stackParentId must be a UUID'])); - // }); }); - // it('should require access to the parent', async () => { - // const { status, body } = await request(app) - // .put('/assets') - // .set('Authorization', `Bearer ${user1.accessToken}`) - // .send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] }); + describe('GET /assets/:id', () => { + it('should include stack details for the primary asset', async () => { + const [asset1, asset2] = await Promise.all([ + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + ]); - // expect(status).toBe(400); - // expect(body).toEqual(errorDto.noPermission); - // }); + await utils.createStack(user1.accessToken, [asset1.id, asset2.id]); - // it('should add stack children', async () => { - // const { status } = await request(app) - // .put('/assets') - // .set('Authorization', `Bearer ${stackUser.accessToken}`) - // .send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] }); + const { status, body } = await request(app) + .get(`/assets/${asset1.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`); - // expect(status).toBe(204); + expect(status).toBe(200); + expect(body).toEqual( + expect.objectContaining({ + id: asset1.id, + stack: { + id: expect.any(String), + assetCount: 2, + primaryAssetId: asset1.id, + }, + }), + ); + }); - // const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - // expect(asset.stack).not.toBeUndefined(); - // expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: stackAssets[3].id })])); - // }); + it('should include stack details for a non-primary asset', async () => { + const [asset1, asset2] = await Promise.all([ + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + ]); - // it('should remove stack children', async () => { - // const { status } = await request(app) - // .put('/assets') - // .set('Authorization', `Bearer ${stackUser.accessToken}`) - // .send({ removeParent: true, ids: [stackAssets[1].id] }); + await utils.createStack(user1.accessToken, [asset1.id, asset2.id]); - // expect(status).toBe(204); + const { status, body } = await request(app) + .get(`/assets/${asset2.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`); - // const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - // expect(asset.stack).not.toBeUndefined(); - // expect(asset.stack).toEqual( - // expect.arrayContaining([ - // expect.objectContaining({ id: stackAssets[2].id }), - // expect.objectContaining({ id: stackAssets[3].id }), - // ]), - // ); - // }); - - // it('should remove all stack children', async () => { - // const { status } = await request(app) - // .put('/assets') - // .set('Authorization', `Bearer ${stackUser.accessToken}`) - // .send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] }); - - // expect(status).toBe(204); - - // const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - // expect(asset.stack).toBeUndefined(); - // }); - - // it('should merge stack children', async () => { - // // create stack after previous test removed stack children - // await updateAssets( - // { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, - // { headers: asBearerAuth(stackUser.accessToken) }, - // ); - - // const { status } = await request(app) - // .put('/assets') - // .set('Authorization', `Bearer ${stackUser.accessToken}`) - // .send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] }); - - // expect(status).toBe(204); - - // const asset = await getAssetInfo({ id: stackAssets[3].id }, { headers: asBearerAuth(stackUser.accessToken) }); - // expect(asset.stack).not.toBeUndefined(); - // expect(asset.stack).toEqual( - // expect.arrayContaining([ - // expect.objectContaining({ id: stackAssets[0].id }), - // expect.objectContaining({ id: stackAssets[1].id }), - // expect.objectContaining({ id: stackAssets[2].id }), - // ]), - // ); - // }); + expect(status).toBe(200); + expect(body).toEqual( + expect.objectContaining({ + id: asset2.id, + stack: { + id: expect.any(String), + assetCount: 2, + primaryAssetId: asset1.id, + }, + }), + ); + }); + }); }); diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index ef581d4766..ed1ef73a08 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -284,7 +284,23 @@ export class AssetRepository implements IAssetRepository { .$if(!!library, (qb) => qb.select(withLibrary)) .$if(!!owner, (qb) => qb.select(withOwner)) .$if(!!smartSearch, withSmartSearch) - .$if(!!stack, (qb) => withStack(qb, { assets: !!stack!.assets, count: false })) + .$if(!!stack, (qb) => + qb + .leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId') + .leftJoinLateral( + (eb) => + eb + .selectFrom('assets as stacked') + .selectAll('asset_stack') + .select((eb) => eb.fn('array_agg', [eb.table('stacked')]).as('assets')) + .where('stacked.deletedAt', 'is', null) + .whereRef('stacked.id', '!=', 'asset_stack.primaryAssetId') + .whereRef('stacked.stackId', '=', 'asset_stack.id') + .as('stacked_assets'), + (join) => join.on('asset_stack.id', 'is not', null), + ) + .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')), + ) .$if(!!files, (qb) => qb.select(withFiles)) .$if(!!tags, (qb) => qb.select(withTags)) .limit(1) From 443aad5794fe2308b9c1d28a52883093950daddb Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 22 Jan 2025 19:28:13 +0100 Subject: [PATCH 24/24] chore(web): update translations (#15335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translate-URL: https://hosted.weblate.org/projects/immich/immich/ar/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ca/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/cv/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/da/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/de/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/fi/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/fil/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/he/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/lt/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/nb_NO/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/nn/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ro/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sr_Cyrl/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sr_Latn/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sv/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/tr/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_Hant/ Translation: Immich/immich Co-authored-by: Andreas Johansen Co-authored-by: Andrius Cimakevicius Co-authored-by: FDS Co-authored-by: Fredrik Rambris Co-authored-by: Kaspar Brygger Co-authored-by: Lauri Koo Co-authored-by: Ramy Co-authored-by: Rico Sonntag Co-authored-by: Sedat Albayrak Co-authored-by: Torin Wu Co-authored-by: ValinRo Co-authored-by: Xo Co-authored-by: anton garcias Co-authored-by: pyorot Co-authored-by: Øyvind Hovden Co-authored-by: Ümit Solmz Co-authored-by: Мĕтри Сантăр ывалĕ Упа-Миччи --- i18n/ar.json | 2 +- i18n/ca.json | 1 + i18n/cv.json | 13 +++ i18n/da.json | 10 +- i18n/de.json | 28 ++--- i18n/fi.json | 19 +++- i18n/fil.json | 29 ++++- i18n/he.json | 30 +++--- i18n/lt.json | 65 ++++++++---- i18n/nb_NO.json | 143 ++++++++++++++++++++++++- i18n/nn.json | 262 +++++++++++++++++++++++++++++++++++++++++++++- i18n/ro.json | 13 ++- i18n/sr_Cyrl.json | 4 +- i18n/sr_Latn.json | 4 +- i18n/sv.json | 9 +- i18n/tr.json | 11 +- i18n/zh_Hant.json | 14 ++- 17 files changed, 581 insertions(+), 76 deletions(-) diff --git a/i18n/ar.json b/i18n/ar.json index f99f23fa6f..5c2b1a9506 100644 --- a/i18n/ar.json +++ b/i18n/ar.json @@ -1,5 +1,5 @@ { - "about": "حول", + "about": "من نحن", "account": "الحساب", "account_settings": "إعدادات الحساب", "acknowledge": "أُدرك ذلك", diff --git a/i18n/ca.json b/i18n/ca.json index 7480f06664..7d0a538f5a 100644 --- a/i18n/ca.json +++ b/i18n/ca.json @@ -525,6 +525,7 @@ "deduplicate_all": "Desduplica-ho tot", "deduplication_criteria_1": "Mida d'imatge en bytes", "deduplication_criteria_2": "Quantitat de dades EXIF", + "deduplication_info": "Informació de deduplicació", "deduplication_info_description": "Per preseleccionar recursos automàticament i eliminar els duplicats de manera massiva, ens fixem en:", "default_locale": "Localització predeterminada", "default_locale_description": "Format de dates i números segons la configuració del navegador", diff --git a/i18n/cv.json b/i18n/cv.json index 5b71434ef8..19a7a86ae4 100644 --- a/i18n/cv.json +++ b/i18n/cv.json @@ -50,8 +50,16 @@ "map_gps_settings_description": "Карттӑпа GPS (каялла геоюмлани) ӗнерленисене йӗркелесе тӑрӑр", "map_settings": "Карттӑ" }, + "albums": "Албумсем", + "albums_count": "{count, plural, one {{count, number} албум} other {{count, number} албумсем}}", + "all": "Пурте", + "all_albums": "Пурте албумсем", "explore": "Тишкер", "explorer": "Тишкерӳҫӗ", + "favorite": "Юратнӑ", + "favorite_or_unfavorite_photo": "Юратнӑ е юратман сӑнӳкерчӗк", + "favorites": "Юратнисем", + "feature_photo_updated": "Уйрӑм сӑнӳкерчӗк ҫӗнетнӗ", "manage_sharing_with_partners": "Партнерсемпе пайланассине йӗркелесе пырӑр", "map": "Карттӑ", "map_marker_for_images": "{city}, {country} ҫинче ӳкернӗ ӳкерчӗксем валли карттӑ маркерӗ", @@ -60,10 +68,15 @@ "no_explore_results_message": "Хӑвӑр коллекципе киленмешкӗн сӑнӳкерчӗксем ытларах тийӗр.", "open_in_openstreetmap": "OpenStreetMap-па уҫ", "partner_sharing": "Партнер пайланӑвӗ", + "people": "Ҫынсем", "photos": "Сӑнӳкерчӗксем", "photos_and_videos": "Сӑнӳкерчӗксем тете Видеосем", "photos_count": "{count, plural, one {{count, number} Сӑнӳкерчӗк} other {{count, number} Сӑнӳкерчӗксем}}", "photos_from_previous_years": "Иртнӗ ҫулсенчи сӑнӳкерчӗксем", + "place": "Тӗл", + "places": "Тӗлсем", + "play": "Выля", + "play_memories": "Асаилӳсем выля", "search_your_photos": "Сӑнӳкерчӗксене шырӑр", "select_photos": "Сӑнӳкерчӗксем суйлӑр", "sharing": "Пайлани", diff --git a/i18n/da.json b/i18n/da.json index fea3eb78ab..b1fd80ff0a 100644 --- a/i18n/da.json +++ b/i18n/da.json @@ -523,6 +523,10 @@ "date_range": "Datointerval", "day": "Dag", "deduplicate_all": "Dedupliker alle", + "deduplication_criteria_1": "Billedstørrelse i bytes", + "deduplication_criteria_2": "Antal EXIF-data", + "deduplication_info": "Deduplikerings info", + "deduplication_info_description": "For automatisk at forudvælge emner og fjerne dubletter i bulk ser vi på:", "default_locale": "Standardlokalitet", "default_locale_description": "Formatér datoer og tal", "delete": "Slet", @@ -644,6 +648,7 @@ "unable_to_add_partners": "Ikke i stand til at tilføje partnere", "unable_to_add_remove_archive": "Kan Ikke {archived, select, true {fjerne aktiv fra} other {tilføje aktiv til}} Arkiv", "unable_to_add_remove_favorites": "Kan ikke {favorite, select, true {tilføje aktiv til} other {fjerne aktiv fra}} favoritter", + "unable_to_archive_unarchive": "Ude af stand til at {arkiveret, vælg, sand {arkiv} andet {arkiv}}", "unable_to_change_album_user_role": "Ikke i stand til at ændre albumbrugerens rolle", "unable_to_change_date": "Ikke i stand til at ændre dato", "unable_to_change_favorite": "Kan ikke ændre favorit for aktiv", @@ -730,6 +735,7 @@ "expired": "Udløbet", "expires_date": "Udløber {date}", "explore": "Udforsk", + "explorer": "Udforske", "export": "Eksportér", "export_as_json": "Eksportér som JSON", "extension": "Udvidelse", @@ -917,6 +923,7 @@ "offline_paths_description": "Disse resultater kan være på grund af manuel sletning af filer, som ikke er en del af et eksternt bibliotek.", "ok": "Ok", "oldest_first": "Ældste først", + "onboarding": "Onboarding", "onboarding_privacy_description": "Følgende (valgfrie) funktioner er afhængige af eksterne tjenester, og kan til enhver tid deaktiveres i administrationsindstillingerne.", "onboarding_theme_description": "Vælg et farvetema til din forekomst. Du kan ændre dette senere i dine indstillinger.", "onboarding_welcome_description": "Lad os få din instans sat op med nogle almindelige indstillinger.", @@ -1249,6 +1256,7 @@ "to_change_password": "Skift adgangskode", "to_favorite": "Gør til favorit", "to_login": "Login", + "to_parent": "Gå op", "to_trash": "Papirkurv", "toggle_settings": "Slå indstillinger til eller fra", "toggle_theme": "Slå mørkt tema til eller fra", @@ -1334,7 +1342,7 @@ "warning": "Advarsel", "week": "Uge", "welcome": "Velkommen", - "welcome_to_immich": "Velkommen til immich", + "welcome_to_immich": "Velkommen til Immich", "year": "År", "years_ago": "{years, plural, one {# år} other {# år}} siden", "yes": "Ja", diff --git a/i18n/de.json b/i18n/de.json index 45d5212ea0..3490d2c7df 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -34,7 +34,7 @@ "authentication_settings_description": "Passwort-, OAuth- und sonstigen Authentifizierungseinstellungen verwalten", "authentication_settings_disable_all": "Bist du sicher, dass du alle Anmeldemethoden deaktivieren willst? Die Anmeldung wird vollständig deaktiviert.", "authentication_settings_reenable": "Nutze einen Server-Befehl zur Reaktivierung.", - "background_task_job": "Hintergrund-Aufgaben", + "background_task_job": "Hintergrundaufgaben", "backup_database": "Datenbank sichern", "backup_database_enable_description": "Sicherung der Datenbank aktivieren", "backup_keep_last_amount": "Anzahl der aufzubewahrenden früheren Sicherungen", @@ -83,9 +83,9 @@ "job_concurrency": "{job} (Anzahl gleichzeitiger Prozesse)", "job_created": "Aufgabe erstellt", "job_not_concurrency_safe": "Diese Aufgabe ist nicht parallelisierungssicher.", - "job_settings": "Aufgaben-Einstellungen", - "job_settings_description": "Gleichzeitige Aufgaben-Prozesse verwalten", - "job_status": "Aufgaben-Status", + "job_settings": "Aufgabeneinstellungen", + "job_settings_description": "Die gleichzeitige Ausführung von Aufgaben verwalten", + "job_status": "Aufgabenstatus", "jobs_delayed": "{jobCount, plural, other {# verzögert}}", "jobs_failed": "{jobCount, plural, other {# fehlgeschlagen}}", "library_created": "Bibliothek erstellt: {library}", @@ -211,7 +211,7 @@ "quota_size_gib": "Kontingent (GiB)", "refreshing_all_libraries": "Alle Bibliotheken aktualisieren", "registration": "Admin-Registrierung", - "registration_description": "Da du der erste Benutzer im System bist, wirst du als Admin zugewiesen und bist für administrative Aufgaben zuständig. Weitere Benutzer werden von dir erstellt.", + "registration_description": "Da du der erste Benutzer im System bist, wird dir die Rolle des Administrators zugewiesen, womit du für die Verwaltungsaufgaben verantwortlich bist. Weitere Benutzer werden von dir erstellt.", "repair_all": "Alle reparieren", "repair_matched_items": "{count, plural, one {# Eintrag} other {# Einträge}} gefunden", "repaired_items": "{count, plural, one {# Eintrag} other {# Einträge}} repariert", @@ -287,7 +287,7 @@ "transcoding_constant_quality_mode": "Modus für konstante Qualität", "transcoding_constant_quality_mode_description": "ICQ ist besser als CQP, aber einige Hardware-Beschleunigungsgeräte unterstützen diesen Modus nicht. Wenn diese Option gesetzt wird, wird der angegebene Modus bevorzugt, sobald qualitätsbasierte Kodierung verwendet wird. Wird von NVENC ignoriert, da es ICQ nicht unterstützt.", "transcoding_constant_rate_factor": "Faktor der konstanten Rate (-crf)", - "transcoding_constant_rate_factor_description": "Video-Qualitätsstufe. Typische Werte sind 23 für H.264, 28 für HEVC, 31 für VP9 und 35 für AV1. Ein niedrigerer Wert ist besser, erzeugt aber größere Dateien.", + "transcoding_constant_rate_factor_description": "Videoqualitätsstufe. Typische Werte sind 23 für H.264, 28 für HEVC, 31 für VP9 und 35 für AV1. Ein niedrigerer Wert ist besser, erzeugt aber größere Dateien.", "transcoding_disabled_description": "Videos nicht transkodieren, dies kann die Wiedergabe auf manchen Geräten beeinträchtigen", "transcoding_encoding_options": "Kodierungsoptionen", "transcoding_encoding_options_description": "Setze Codec, Auflösung, Qualität und andere Optionen für kodierte Videos", @@ -312,14 +312,14 @@ "transcoding_reference_frames": "Referenz-Frames", "transcoding_reference_frames_description": "Die Anzahl der Bilder, auf die bei der Komprimierung eines bestimmten Bildes Bezug genommen wird. Höhere Werte verbessern die Komprimierungseffizienz, verlangsamen aber die Kodierung. 0 setzt diesen Wert automatisch.", "transcoding_required_description": "Nur Videos in einem nicht akzeptierten Format", - "transcoding_settings": "Video-Transkodierungseinstellungen", + "transcoding_settings": "Einstellungen für die Videotranskodierung", "transcoding_settings_description": "Verwalten welche Videos transkodiert werden und wie diese verarbeitet werden", "transcoding_target_resolution": "Ziel-Auflösung", "transcoding_target_resolution_description": "Höhere Auflösungen können mehr Details erhalten, benötigen aber mehr Zeit für die Codierung, haben größere Dateigrößen und können die Reaktionszeit der Anwendung beeinträchtigen.", "transcoding_temporal_aq": "Temporäre AQ", "transcoding_temporal_aq_description": "Gilt nur für NVENC. Verbessert die Qualität von Szenen mit hohem Detailreichtum und geringen Bewegungen. Dies ist möglicherweise nicht mit älteren Geräten kompatibel.", "transcoding_threads": "Threads", - "transcoding_threads_description": "Höhere Werte führen zu einer schnelleren Codierung, lassen dem Server aber weniger Spielraum für die Verarbeitung anderer Aufgaben, solange dies aktiv ist. Dieser Wert sollte nicht höher sein als die Anzahl der CPU-Kerne. Nutzt die maximale Auslastung, wenn der Wert auf 0 gesetzt ist.", + "transcoding_threads_description": "Höhere Werte führen zu einer schnelleren Kodierung, lassen dem Server jedoch weniger Spielraum für die Verarbeitung anderer Aufgaben im aktiven Zustand. Dieser Wert sollte nicht höher sein als die Anzahl der CPU-Kerne. Maximiert die Auslastung, wenn der Wert auf 0 gesetzt wird.", "transcoding_tone_mapping": "Farbton-Mapping", "transcoding_tone_mapping_description": "Versucht, das Aussehen von HDR-Videos bei der Konvertierung in SDR beizubehalten. Jeder Algorithmus geht unterschiedliche Kompromisse bei Farbe, Details und Helligkeit ein. Hable bewahrt Details, Mobius bewahrt die Farbe und Reinhard bewahrt die Helligkeit.", "transcoding_transcode_policy": "Transcodierungsrichtlinie", @@ -328,11 +328,11 @@ "transcoding_two_pass_encoding_setting_description": "Führt eine Transkodierung in zwei Durchgängen durch, um besser kodierte Videos zu erzeugen. Wenn die maximale Bitrate aktiviert ist (erforderlich für die Verwendung mit H.264 und HEVC), verwendet dieser Modus einen Bitratenbereich, der auf der maximalen Bitrate basiert, und ignoriert CRF. Für VP9 kann CRF verwendet werden, wenn die maximale Bitrate deaktiviert ist.", "transcoding_video_codec": "Video-Codec", "transcoding_video_codec_description": "VP9 hat eine hohe Effizienz und Webkompatibilität, braucht aber länger für die Transkodierung. HEVC bietet eine ähnliche Leistung, ist aber weniger web-kompatibel. H.264 ist weitgehend kompatibel und lässt sich schnell transkodieren, erzeugt aber viel größere Dateien. AV1 ist der effizienteste Codec, wird aber von älteren Geräten nicht unterstützt.", - "trash_enabled_description": "Papierkorb-Funktionen aktivieren", + "trash_enabled_description": "Papierkorbfunktionen aktivieren", "trash_number_of_days": "Anzahl der Tage", "trash_number_of_days_description": "Anzahl der Tage, welche die Objekte im Papierkorb verbleiben, bevor sie endgültig entfernt werden", - "trash_settings": "Papierkorb-Einstellungen", - "trash_settings_description": "Papierkorb-Einstellungen verwalten", + "trash_settings": "Papierkorbeinstellungen", + "trash_settings_description": "Papierkorbeinstellungen verwalten", "untracked_files": "Unverfolgte Dateien", "untracked_files_description": "Diese Dateien werden nicht von der Anwendung getrackt. Sie können das Ergebnis fehlgeschlagener Verschiebungen, unterbrochener Uploads oder aufgrund eines Fehlers sein", "user_cleanup_job": "Benutzer aufräumen", @@ -346,8 +346,8 @@ "user_password_reset_description": "Bitte gib dem Benutzer das temporäre Passwort und informiere ihn, dass das Passwort beim nächsten Login geändert werden muss.", "user_restore_description": "Das Konto von {user} wird wiederhergestellt.", "user_restore_scheduled_removal": "Wiederherstellung des Benutzers - geplante Entfernung am {date, date, long}", - "user_settings": "Benutzer-Einstellungen", - "user_settings_description": "Benutzer-Einstellungen verwalten", + "user_settings": "Benutzereinstellungen", + "user_settings_description": "Benutzereinstellungen verwalten", "user_successfully_removed": "Benutzer {email} wurde erfolgreich entfernt.", "version_check_enabled_description": "Versionsprüfung aktivieren", "version_check_implications": "Die Funktion zur Versionsprüfung basiert auf regelmäßiger Kommunikation mit GitHub.com", @@ -1324,7 +1324,7 @@ "version_history_item": "{version} am {date} installiert", "video": "Video", "video_hover_setting": "Videovorschau beim Hovern abspielen", - "video_hover_setting_description": "Video-Miniaturansicht wiedergeben, wenn der Mauszeiger über dem Element verweilt. Auch wenn diese Funktion deaktiviert ist, kann die Wiedergabe gestartet werden, indem der Mauszeiger auf das Wiedergabesymbol bewegt wird.", + "video_hover_setting_description": "Spiele die Miniaturansicht des Videos ab, wenn sich die Maus über dem Element befindet. Auch wenn die Funktion deaktiviert ist, kann die Wiedergabe gestartet werden, indem du mit der Maus über das Wiedergabesymbol fährst.", "videos": "Videos", "videos_count": "{count, plural, one {# Video} other {# Videos}}", "view": "Ansicht", diff --git a/i18n/fi.json b/i18n/fi.json index 1ae71f6c81..e67178782c 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -182,7 +182,7 @@ "oauth_auto_register_description": "Rekisteröi uudet OAuth:lla kirjautuvat käyttäjät automaattisesti", "oauth_button_text": "Painikkeen teksti", "oauth_client_id": "Client ID", - "oauth_client_secret": "Client Secret", + "oauth_client_secret": "Asiakassalaisuusavain", "oauth_enable_description": "Kirjaudu käyttäen OAuthia", "oauth_issuer_url": "Toimitsijan URL", "oauth_mobile_redirect_uri": "Mobiilin uudellenohjaus-URI", @@ -289,11 +289,13 @@ "transcoding_constant_rate_factor": "Vakionopeustekijä", "transcoding_constant_rate_factor_description": "Videon laatu. Yleisimmät arvot ovat 23 H.264:lle, 28 HEVC:lle, 31 VP9:lle ja 35 AV1:lle. Matalampi arvo on parempi, mutta tekee isompia tiedostoja.", "transcoding_disabled_description": "Älä muunna videoita. Voi joissakin päätelaitteissa aiheuttaa videotoiston toimimattomuutta", + "transcoding_encoding_options": "Enkoodausasetukset", + "transcoding_encoding_options_description": "Aseta koodekit, tarkkuus, laatu ja muut asetukset enkoodatuille videoille", "transcoding_hardware_acceleration": "Laitteistokiihdytys", "transcoding_hardware_acceleration_description": "Kokeellinen. Paljon nopeampi, mutta huonompaa laatua samalla bittinopeudella", "transcoding_hardware_decoding": "Laitteiston dekoodaus", "transcoding_hardware_decoding_setting_description": "Ottaa käyttöön end-to-end kiihdytyksen pelkän muuntamisen sijasta. Ei välttämättä toimi kaikissa videoissa.", - "transcoding_hevc_codec": "HEVC koodekki", + "transcoding_hevc_codec": "HEVC-koodekki", "transcoding_max_b_frames": "B-kehysten enimmäismäärä", "transcoding_max_b_frames_description": "Korkeampi arvo parantaa pakkausta, mutta hidastaa enkoodausta. Ei välttämättä ole yhteensopiva vanhempien laitteiden kanssa. 0 poistaa B-kehykset käytöstä, -1 määrittää arvon automaattisesti.", "transcoding_max_bitrate": "Suurin bittinopeus", @@ -301,6 +303,8 @@ "transcoding_max_keyframe_interval": "Suurin avainkehysten väli", "transcoding_max_keyframe_interval_description": "Asettaa avainkehysten välin maksimiarvon. Alempi arvo huonontaa pakkauksen tehoa, mutta parantaa hakuaikoja ja voi parantaa laatua nopealiikkeisissä kohtauksissa. 0 asettaa arvon automaattisesti.", "transcoding_optimal_description": "Videot, joiden resoluutio on korkeampi kuin kohteen, tai ei hyväksytyssä formaatissa", + "transcoding_policy": "Transkoodauskäytäntö", + "transcoding_policy_description": "Aseta milloin video transkoodataan", "transcoding_preferred_hardware_device": "Ensisijainen laite", "transcoding_preferred_hardware_device_description": "On voimassa vain VAAPI ja QSV -määritteille. Asettaa laitteistokoodauksessa käytetyn DRI noodin.", "transcoding_preset_preset": "Esiasetus (-asetus)", @@ -309,7 +313,7 @@ "transcoding_reference_frames_description": "Viittaavien kehysten määrä kun tiettyä kehystä pakataan. Korkeampi arvo parantaa pakkausta mutta hidastaa enkoodausta. 0 määrittää arvon automaattisesti.", "transcoding_required_description": "Vain videoille, jotka eivät ole hyväksytyssä muodossa", "transcoding_settings": "Videoiden transkoodausasetukset", - "transcoding_settings_description": "Hallitse videoiden resoluutiota ja koodaustietueita", + "transcoding_settings_description": "Hallitse, mitkä videot transkoodataan ja miten niitä käsitellään", "transcoding_target_resolution": "Kohderesoluutio", "transcoding_target_resolution_description": "Korkeampi resoluutio on tarkempi, mutta kestää kauemmin enkoodata, vie enemmän tilaa ja voi hidastaa sovelluksen responsiivisuutta.", "transcoding_temporal_aq": "Temporal AQ", @@ -519,6 +523,10 @@ "date_range": "Päivämäärän rajaus", "day": "Päivä", "deduplicate_all": "Poista kaikkien kaksoiskappaleet", + "deduplication_criteria_1": "Kuvan koko tavuina", + "deduplication_criteria_2": "EXIF-datan määrä", + "deduplication_info": "Deduplikaatiotieto", + "deduplication_info_description": "Jotta voimme automaattisesti esivalita aineistot ja poistaa duplikaatit suurina erinä, tarkastelemme:", "default_locale": "Oletuskieliasetus", "default_locale_description": "Muotoile päivämäärät ja numerot selaimesi kielen mukaan", "delete": "Poista", @@ -532,7 +540,7 @@ "delete_shared_link": "Poista jaettu linkki", "delete_tag": "Poista tunniste", "delete_tag_confirmation_prompt": "Haluatko varmasti poistaa tunnisteen {tagName}?", - "delete_user": "Poista käyttäjä", + "delete_user": "Poista käyttäjä pysyvästi", "deleted_shared_link": "Jaettu linkki poistettu", "deletes_missing_assets": "Poistaa levyltä puuttuvat resurssit", "description": "Kuvaus", @@ -755,6 +763,7 @@ "get_help": "Hae apua", "getting_started": "Aloittaminen", "go_back": "Palaa", + "go_to_folder": "Mene kansioon", "go_to_search": "Siirry hakuun", "group_albums_by": "Ryhmitä albumi...", "group_no": "Ei ryhmitystä", @@ -1141,6 +1150,7 @@ "server_version": "Palvelimen versio", "set": "Aseta", "set_as_album_cover": "Aseta albumin kanneksi", + "set_as_featured_photo": "Käytä esittelykuvana", "set_as_profile_picture": "Aseta profiilikuvaksi", "set_date_of_birth": "Aseta syntymäaika", "set_profile_picture": "Aseta profiilikuva", @@ -1196,6 +1206,7 @@ "sort_items": "Tietueiden määrä", "sort_modified": "Muokkauspäivä", "sort_oldest": "Vanhin kuva", + "sort_people_by_similarity": "Lajittele ihmiset samankaltaisuuden mukaan", "sort_recent": "Tuorein kuva", "sort_title": "Otsikko", "source": "Lähdekoodi", diff --git a/i18n/fil.json b/i18n/fil.json index ca2e1fadd0..4b5ba5bb7b 100644 --- a/i18n/fil.json +++ b/i18n/fil.json @@ -1,5 +1,5 @@ { - "about": "I-refresh", + "about": "Tungkol sa app na ito", "account": "Account", "account_settings": "Mga Setting ng Account", "acknowledge": "Tanggapin", @@ -24,9 +24,15 @@ "added_to_favorites_count": "Idinagdag ang {count, number} sa mga paborito", "admin": { "asset_offline_description": "Ang external library asset na ito ay hindi na makikita sa disk at nailipat na sa trash. Kung ang file ay nailipat sa loob ng library, tignan ang iyong timeline para sa kaukulang asset. Para maibalik ang asset na ito, siguraduhin na ang file path ay maa-access ng Immich para iscan ang library.", + "authentication_settings_disable_all": "Sigurado ka bang gusto mo patayin lahat ng paraan ng pag-login? Ang pag-login ay ganap na idi-disable.", "authentication_settings_reenable": "Para i-enable ulit, gamitin ang Server Command.", + "cleared_jobs": "Lahat nang mga trabaho para sa {job} ay tinanggal na", + "confirm_delete_library": "Sigurado ka na gusto mo tanggalin ang {library} library?", + "confirm_email_below": "Para isigurado, i-type ito sa baba: \"{email}\"", + "confirm_user_password_reset": "Sigurado ka na gusto mo i-reset ang password ni {user}?", "disable_login": "I-disable ang login", - "force_delete_user_warning": "BABALA:", + "force_delete_user_warning": "BABALA: Tatanggalin itong user at lahat ng asset nila, Hindi ito mababawi at ang kanilang files ay hindi na mababalik", + "image_format": "Format", "library_import_path_description": "Tukuyin ang folder na i-import. Ang folder na ito, kasama ang subfolders, ay mag sa-scan para sa mga imahe at mga videos.", "note_cannot_be_changed_later": "TANDAAN: Hindi na ito pwede baguhin sa susunod!", "repair_all": "Ayusin lahat", @@ -40,5 +46,22 @@ "are_these_the_same_person": "Itong tao na ito ay parehas?", "asset_adding_to_album": "Dinadagdag sa album...", "asset_filename_is_offline": "Offline ang asset {filename}", - "asset_uploading": "Ina-upload..." + "asset_uploading": "Ina-upload...", + "discord": "Discord", + "documentation": "Dokumentasyion", + "done": "Tapos na", + "download": "I-download", + "edit": "I-edit", + "edited": "Inedit", + "editor_close_without_save_title": "Isara ang editor?", + "email": "Email", + "exif": "Exif", + "explore": "I-explore", + "export": "I-export", + "has_quota": "May quota", + "hour": "Oras", + "jobs": "Mga trabaho", + "language": "Wika", + "leave": "Umalis", + "no_results": "Walang resulta" } diff --git a/i18n/he.json b/i18n/he.json index 9993d6cf48..daf7b1aa39 100644 --- a/i18n/he.json +++ b/i18n/he.json @@ -529,20 +529,20 @@ "deduplication_info_description": "כדי לבחור מראש נכסים באופן אוטומטי ולהסיר כפילויות בכמות גדולה, אנו מסתכלים על:", "default_locale": "שפת ברירת מחדל", "default_locale_description": "פורמט תאריכים ומספרים מבוסס שפת הדפדפן שלך", - "delete": "הסרה", - "delete_album": "הסרת אלבום", - "delete_api_key_prompt": "האם ברצונך למחוק מפתח ה-API הזה?", - "delete_duplicates_confirmation": "האם ברצונך להסיר לצמיתות את הכפילויות האלה?", - "delete_key": "הסרת מפתח", - "delete_library": "הסרת ספרייה", - "delete_link": "הסרת קישור", - "delete_others": "הסרת אחרים", - "delete_shared_link": "הסרת קישור משותף", - "delete_tag": "הסרת תג", - "delete_tag_confirmation_prompt": "האם ברצונך להסיר תג {tagName}?", - "delete_user": "הסרת משתמש", - "deleted_shared_link": "קישור משותף הוסר", - "deletes_missing_assets": "מסיר נכסים שחסרים בדיסק", + "delete": "מחק", + "delete_album": "מחק אלבום", + "delete_api_key_prompt": "האם את/ה בטוח/ה שברצונך למחוק מפתח ה-API הזה?", + "delete_duplicates_confirmation": "האם את/ה בטוח/ה שברצונך למחוק לצמיתות את הכפילויות האלה?", + "delete_key": "מחק מפתח", + "delete_library": "מחק ספרייה", + "delete_link": "מחק קישור", + "delete_others": "מחק אחרים", + "delete_shared_link": "מחק קישור משותף", + "delete_tag": "מחק תג", + "delete_tag_confirmation_prompt": "האם את/ה בטוח/ה שברצונך למחוק תג {tagName}?", + "delete_user": "מחק משתמש", + "deleted_shared_link": "קישור משותף נמחק", + "deletes_missing_assets": "מוחק נכסים שחסרים בדיסק", "description": "תיאור", "details": "פרטים", "direction": "כיוון", @@ -553,7 +553,7 @@ "dismiss_all_errors": "התעלמות מכל השגיאות", "dismiss_error": "התעלמות מהשגיאה", "display_options": "הצגת אפשרויות", - "display_order": "סידור תצוגה", + "display_order": "סדר תצוגה", "display_original_photos": "הצגת תמונות מקוריות", "display_original_photos_setting_description": "העדף להציג את התמונה המקורית בעת צפיית נכס במקום תמונות ממוזערות כאשר הנכס המקורי תומך בתצוגה בדפדפן. זה עלול לגרום לתמונות להיות מוצגות באיטיות.", "do_not_show_again": "אל תציג את ההודעה הזאת שוב", diff --git a/i18n/lt.json b/i18n/lt.json index 7e24eedc74..d998d33e01 100644 --- a/i18n/lt.json +++ b/i18n/lt.json @@ -30,7 +30,7 @@ "admin": { "asset_offline_description": "Šis išorinės bibliotekos elementas nebepasiekiamas diske ir buvo perkeltas į šiukšliadėžę. Jei failas buvo perkeltas toje pačioje bibliotekoje, laiko skalėje rasite naują atitinkamą elementą. Jei norite šį elementą atkurti, įsitikinkite, kad Immich gali pasiekti failą žemiau nurodytu adresu, ir suvykdykite bibliotekos skanavimą.", "authentication_settings": "Autentifikavimo nustatymai", - "authentication_settings_description": "Tvarkyti slaptažodžių, OAuth ir kitus autentifikavimo parametrus", + "authentication_settings_description": "Tvarkyti slaptažodžių, OAuth ir kitus autentifikavimo nustatymus", "authentication_settings_disable_all": "Ar tikrai norite išjungti visus prisijungimo būdus? Prisijungimas bus visiškai išjungtas.", "authentication_settings_reenable": "Norėdami vėl įjungti, naudokite Serverio komandą.", "background_task_job": "Foninės užduotys", @@ -38,14 +38,20 @@ "backup_database_enable_description": "Įgalinti duomenų bazės atsarginė kopijas", "backup_keep_last_amount": "Išsaugomų ankstesnių atsarginių duomenų bazės kopijų skaičius", "backup_settings": "Atsarginės kopijos nustatymai", + "backup_settings_description": "Tvarkyti duomenų bazės atsarginės kopijos nustatymus", "check_all": "Pažymėti viską", - "config_set_by_file": "Konfigūracija dabar nustatyta konfigūracinio failo", + "cleared_jobs": "Išvalyti darbai: {job}", + "config_set_by_file": "Konfigūracija nustatyta pagal konfigūracinį failą", "confirm_delete_library": "Ar tikrai norite ištrinti {library} biblioteką?", + "confirm_delete_library_assets": "Ar tikrai norite ištrinti šią biblioteką? Šis veiksmas ištrins {count, plural, one {# contained asset} other {all # contained assets}} iš Immich ir negali būti grąžintas. Failai liks diske.", "confirm_email_below": "Patvirtinimui įveskite \"{email}\" žemiau", "confirm_reprocess_all_faces": "Ar tikrai norite iš naujo apdoroti visus veidus? Tai taip pat ištrins įvardytus asmenis.", "confirm_user_password_reset": "Ar tikrai norite iš naujo nustatyti {user} slaptažodį?", + "create_job": "Sukurti darbą", + "cron_expression": "Cron išraiška", + "cron_expression_description": "Nustatyti skanavimo intervalą naudojant cron formatą. Norėdami gauti daugiau informacijos žiūrėkite Crontab Guru", "disable_login": "Išjungti prisijungimą", - "duplicate_detection_job_description": "Vykdykite mašininį mokymąsi panašių vaizdų aptikimui. Priklauso nuo išmaniosios paieškos.", + "duplicate_detection_job_description": "Vykdykite mašininį mokymąsi panašių vaizdų aptikimui. Priklauso nuo išmaniosios paieškos", "exclusion_pattern_description": "Išimčių šablonai leidžia nepaisyti failų ir aplankų skenuojant jūsų biblioteką. Tai yra naudinga, jei turite aplankų su failais, kurių nenorite importuoti, pavyzdžiui, RAW failai.", "external_library_created_at": "Išorinė biblioteka (sukurta {date})", "external_library_management": "Išorinių bibliotekų tvarkymas", @@ -61,11 +67,19 @@ "image_prefer_embedded_preview_setting_description": "", "image_prefer_wide_gamut": "Teikti pirmenybę plačiai gamai", "image_prefer_wide_gamut_setting_description": "", + "image_preview_description": "Vidutinio dydžio vaizdas su išvalytais metaduomenimis, naudojamas kai žiūrimas vienas objektas arba mašininiam mokymuisi", + "image_preview_quality_description": "Peržiūros kokybė nuo 1-100. Aukštesnės reikšmės yra geriau, bet sukuriami didesni failai gali sumažinti programos reagavimo laiką. Mažos vertės nustatymas gali paveikti mašininio mokymo kokybę.", + "image_preview_title": "Peržiūros nustatymai", "image_quality": "Kokybė", "image_resolution": "Rezoliucija", + "image_resolution_description": "Didesnės rezoliucijos gali išsaugoti daugiau detalių, bet ilgiau užtrunka užkoduoti, failai yra didesni ir programos reagavimo laikas gali sumažėti.", "image_settings": "Nuotraukos nustatymai", "image_settings_description": "Keisti sugeneruotų nuotraukų kokybę ir rezoliuciją", + "image_thumbnail_description": "Maža miniatiūra su išvalytais metaduomenimis, naudojama kai žiūrima nuotraukų grupės, kaip pagrindinėje laiko juostoje", + "image_thumbnail_quality_description": "Miniatiūros kokybė nuo 1-100. Aukštesnės reikšmės yra geriau, bet pagaminami didesni failai ir gali būti sulėtintas programos reagavimo greitis.", + "image_thumbnail_title": "Miniatiūros nustatymai", "job_concurrency": "{job} lygiagretumas", + "job_created": "Darbas sukurtas", "job_not_concurrency_safe": "Šis darbas nėra saugus apdoroti lygiagrečiai.", "job_settings": "Darbų nustatymai", "job_settings_description": "Keisti darbų lygiagretumą", @@ -79,17 +93,17 @@ "library_settings": "Išorinė biblioteka", "library_settings_description": "Tvarkyti išorinės bibliotekos parametrus", "library_tasks_description": "Atlikit bibliotekos užduotis", - "library_watching_enable_description": "", - "library_watching_settings": "", - "library_watching_settings_description": "", - "logging_enable_description": "", - "logging_level_description": "", - "logging_settings": "", + "library_watching_enable_description": "Stebėti išorines bibliotekas dėl failų pakeitimų", + "library_watching_settings": "Bibliotekų stebėjimas (EKSPERIMENTINIS)", + "library_watching_settings_description": "Automatiškai stebėti dėl pakeistų failų", + "logging_enable_description": "Įjungti žurnalo vedimą", + "logging_level_description": "Įjungus, kokį žurnalo vedimo lygį naudot.", + "logging_settings": "Žurnalo vedimas", "machine_learning_clip_model": "CLIP modelis", "machine_learning_duplicate_detection": "Dublikatų aptikimas", "machine_learning_duplicate_detection_enabled": "Įjungti dublikatų aptikimą", "machine_learning_duplicate_detection_enabled_description": "", - "machine_learning_duplicate_detection_setting_description": "", + "machine_learning_duplicate_detection_setting_description": "Naudoti CLIP įterpimus, norint rasti galimus duplikatus", "machine_learning_enabled": "Įgalinti mašininį mokymąsi", "machine_learning_enabled_description": "Jei išjungta, visos „ML“ funkcijos bus išjungtos, nepaisant toliau pateiktų nustatymų.", "machine_learning_facial_recognition": "Veidų atpažinimas", @@ -97,28 +111,29 @@ "machine_learning_facial_recognition_model": "Veidų atpažinimo modelis", "machine_learning_facial_recognition_model_description": "", "machine_learning_facial_recognition_setting": "Įgalinti veidų atpažinimą", - "machine_learning_facial_recognition_setting_description": "", + "machine_learning_facial_recognition_setting_description": "Išjungus, vaizdai nebus užšifruoti veidų atpažinimui ir nebus naudojami Žmonių sekcijoje Naršymo puslapyje.", "machine_learning_max_detection_distance": "Maksimalus aptikimo atstumas", "machine_learning_max_detection_distance_description": "Didžiausias atstumas tarp dviejų vaizdų, kad jie būtų laikomi dublikatais, svyruoja nuo 0,001 iki 0,1. Didesnės vertės aptiks daugiau dublikatų, tačiau gali būti klaidingai teigiami.", "machine_learning_max_recognition_distance": "Maksimalus atpažinimo atstumas", "machine_learning_max_recognition_distance_description": "", - "machine_learning_min_detection_score": "", + "machine_learning_min_detection_score": "Minimalus aptikimo balas", "machine_learning_min_detection_score_description": "", "machine_learning_min_recognized_faces": "Mažiausias atpažintų veidų skaičius", "machine_learning_min_recognized_faces_description": "Mažiausias atpažintų veidų skaičius asmeniui, kurį reikia sukurti. Tai padidinus, veido atpažinimas tampa tikslesnis, bet padidėja tikimybė, kad veidas žmogui nepriskirtas.", "machine_learning_settings": "Mašininio mokymosi nustatymai", "machine_learning_settings_description": "Tvarkyti mašininio mokymosi funkcijas ir nustatymus", "machine_learning_smart_search": "Išmanioji paieška", - "machine_learning_smart_search_description": "", + "machine_learning_smart_search_description": "Semantiškai ieškoti vaizdų naudojant CLIP įtarpius", "machine_learning_smart_search_enabled": "Įjungti išmaniąją paiešką", "machine_learning_smart_search_enabled_description": "Jei išjungta, vaizdai nebus užkoduoti išmaniajai paieškai.", "machine_learning_url_description": "Mašininio mokymosi serverio URL. Jei pateikta daugiau nei vienas URL, serveriai bus bandomi eilės tvarka nuo pirmo iki paskutinio tol, kol bus rastas vienas veikiantis serveris.", "manage_concurrency": "Tvarkyti lygiagretumą", - "manage_log_settings": "", + "manage_log_settings": "Valdyti žurnalo nuostatas", "map_dark_style": "Tamsioji tema", "map_enable_description": "Įgalinti žemėlapio funkcijas", "map_gps_settings": "Žemėlapio ir GPS nustatymai", "map_gps_settings_description": "Tvarkyti žemėlapio ir GPS (atvirkštinio geokodavimo) nustatymus", + "map_implications": "Žemėlapio funkcija naudojasi išoriniu plytelių servisu (tiles.immich.cloud)", "map_light_style": "Šviesioji tema", "map_manage_reverse_geocoding_settings": "Tvarkyti atvirkštinio geokodavimo nustatymus", "map_reverse_geocoding": "Atvirkštinis geokodavimas", @@ -126,29 +141,33 @@ "map_reverse_geocoding_settings": "Atvirkštinio geokodavimo nustatymai", "map_settings": "Žemėlapis", "map_settings_description": "Tvarkyti žemėlapio parametrus", - "map_style_description": "", + "map_style_description": "URL į style.json žemėlapio temą", "metadata_extraction_job": "Metaduomenų nuskaitymas", "metadata_extraction_job_description": "Kiekvieno bibliotekos elemento metaduomenų nuskaitymas, tokių kaip GPS koordinatės, veidai ar rezoliucija", + "metadata_faces_import_setting": "Įjungti veidų importą", + "metadata_faces_import_setting_description": "Importuoti veidus iš vaizdo EXIF duomenų ir papildomų failų", "metadata_settings": "Metaduomenų nustatymai", "metadata_settings_description": "Tvarkyti metaduomenų nustatymus", "migration_job": "Migracija", "migration_job_description": "", "no_paths_added": "Keliai nepridėti", "no_pattern_added": "Šablonas nepridėtas", + "note_apply_storage_label_previous_assets": "Pastaba: norėdami pritaikyti saugyklos etiketę seniau įkeltiems ištekliams, paleiskite", "note_cannot_be_changed_later": "PASTABA: Vėliau to pakeisti negalima!", - "notification_email_from_address": "", - "notification_email_from_address_description": "", - "notification_email_host_description": "", + "note_unlimited_quota": "Pastaba: įveskite 0 norint neribotos kvotos", + "notification_email_from_address": "Iš adreso", + "notification_email_from_address_description": "Siuntėjo elektroninis adresas, pavyzdžiui: \"Immich Photo Server \"", + "notification_email_host_description": "Elektroninio pašto serverio savininkas (pvz. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Nepaisyti sertifikatų klaidų", "notification_email_ignore_certificate_errors_description": "Nepaisyti TLS sertifikato patvirtinimo klaidų (nerekomenduojama)", - "notification_email_password_description": "", + "notification_email_password_description": "Slaptažodis, naudojant autentikacijai su elektroninio pašto serveriu", "notification_email_port_description": "El. pašto serverio prievadas (pvz. 25, 465 arba 587)", "notification_email_sent_test_email_button": "Siųsti bandomąjį el. laišką ir išsaugoti", "notification_email_setting_description": "El. pašto pranešimų siuntimo nustatymai", "notification_email_test_email": "Išsiųsti bandomąjį el. laišką", "notification_email_test_email_failed": "Nepavyko išsiųsti bandomojo el. laiško, patikrinkite savo nustatymus", "notification_email_test_email_sent": "Bandomasis el. laiškas buvo išsiųstas į {email}. Patikrinkite savo pašto dėžutę.", - "notification_email_username_description": "", + "notification_email_username_description": "Vartotojo vardas, naudojant autentikacijai su elektroninio pašto serveriu", "notification_enable_email_notifications": "Įgalinti el. pašto pranešimus", "notification_settings": "Pranešimų nustatymai", "notification_settings_description": "Tvarkyti pranešimų nustatymus, įskaitant el. pašto", @@ -164,7 +183,9 @@ "oauth_mobile_redirect_uri": "Mobiliojo peradresavimo URI", "oauth_mobile_redirect_uri_override": "Mobiliojo peradresavimo URI pakeitimas", "oauth_mobile_redirect_uri_override_description": "Įjunkite, kai OAuth teikėjas nepalaiko mobiliojo URI, tokio kaip '{callback}'", - "oauth_scope": "", + "oauth_profile_signing_algorithm": "Profilio registracijos algoritmas", + "oauth_profile_signing_algorithm_description": "Algoritmas naudojamas vartotojo profilio registracijai.", + "oauth_scope": "Apimtis", "oauth_settings": "OAuth", "oauth_settings_description": "Tvarkyti OAuth prisijungimo nustatymus", "oauth_settings_more_details": "Detaliau apie šią funkciją galite paskaityti dokumentacijoje.", @@ -602,7 +623,7 @@ "external": "Išorinis", "external_libraries": "Išorinės bibliotekos", "face_unassigned": "Nepriskirta", - "favorite": "Mėgstamiausi", + "favorite": "Mėgstamiausias", "favorite_or_unfavorite_photo": "Įtraukti prie arba pašalinti iš mėgstamiausių", "favorites": "Mėgstamiausi", "feature_photo_updated": "", diff --git a/i18n/nb_NO.json b/i18n/nb_NO.json index f0bc9d4f6d..e8c63a696a 100644 --- a/i18n/nb_NO.json +++ b/i18n/nb_NO.json @@ -471,7 +471,7 @@ "clear_value": "Fjern verdi", "clockwise": "Med urviseren", "close": "Lukk", - "collapse": "Slå sammen", + "collapse": "Trekk sammen", "collapse_all": "Kollaps alt", "color": "Farge", "color_theme": "Fargetema", @@ -582,7 +582,7 @@ "edit_key": "Rediger nøkkel", "edit_link": "Endre lenke", "edit_location": "Endre lokasjon", - "edit_name": "Endre navn", + "edit_name": "Redigere navn", "edit_people": "Rediger personer", "edit_tag": "Rediger tag", "edit_title": "Rediger Tittel", @@ -1020,75 +1020,137 @@ "purchase_lifetime_description": "Kjøp for livstid", "purchase_option_title": "KJØPSVALG", "purchase_panel_info_1": "Å lage Immich tar mye tid og energi, og nå har vi en fulltidsansatt utvikler som jobber med å gjøre produktet så godt vi kan. Vårt oppdrag er for åpen-kildekode programvare og etisk virksomhets praktisk å kunne bli bærekraftig inntekt for utviklere og for å lage privat repekterte økesystem med mulighet for å tilby skytjeneste.", + "purchase_panel_info_2": "Siden har forpliktet oss ikke å legge til betalingsmurer, vil dette kjøpet ikke gi deg noen tilleggsfunksjoner i Immich. Vi er avhengige av brukere som deg for å støtte Immichs pågående utvikling.", + "purchase_panel_title": "Hjelp prosjektet", + "purchase_per_server": "For hver server", + "purchase_per_user": "For hver bruker", + "purchase_remove_product_key": "Ta bor Produktnøkkel", + "purchase_remove_product_key_prompt": "Er du sikker på at du vil ta bort produktnøkkelen?", + "purchase_remove_server_product_key": "Ta bort Server Produktnøkkel", + "purchase_remove_server_product_key_prompt": "Er du sikker på at du vil ta bort Server Produktnøkkelen?", + "purchase_server_description_1": "For hele serveren", + "purchase_server_description_2": "Støttespiller status", + "purchase_server_title": "Server", + "purchase_settings_server_activated": "Produktnøkkel for server er administrert av administratoren", + "rating": "Stjernevurdering", + "rating_clear": "Slett vurdering", + "rating_count": "{count, plural, one {# star} other {# stars}}", + "rating_description": "Hvis EXIF vurdering i informasjons panelet", "reaction_options": "Reaksjonsalternativer", "read_changelog": "Les endringslogg", + "reassign": "Tilordne på nytt", + "reassigned_assets_to_existing_person": "Tildelt på nytt {count, plural, one {# asset} other {# assets}} to {name, select, null {an existing person} other {{name}}}", + "reassigned_assets_to_new_person": "Tildelt på nytt {count, plural, one {# asset} other {# assets}} til en ny person", + "reassing_hint": "Tilordne valgte eiendeler til en eksisterende person", "recent": "Nylig", + "recent-albums": "Nylige album", "recent_searches": "Nylige søk", "refresh": "Oppdater", + "refresh_encoded_videos": "Oppdater kodete videoer", + "refresh_faces": "Oppdater ansikter", + "refresh_metadata": "Oppdater metadata", + "refresh_thumbnails": "Oppdater miniatyrbilder", "refreshed": "Oppdatert", "refreshes_every_file": "Oppdaterer alle filer", + "refreshing_encoded_video": "Oppdaterer kodete video", + "refreshing_faces": "Oppdaterer ansikter", + "refreshing_metadata": "Oppdaterer matadata", + "regenerating_thumbnails": "Regenererer miniatyrbilder", "remove": "Fjern", + "remove_assets_album_confirmation": "Er du sikker på at du fil slette {count, plural, one {# asset} other {# assets}} fra albumet?", + "remove_assets_shared_link_confirmation": "Er du sikker på at du vil slette {count, plural, one {# asset} other {# assets}} fra den delte lenken?", + "remove_assets_title": "Vil du fjerne eiendeler?", + "remove_custom_date_range": "Fjern egendefinert datoperiode", "remove_deleted_assets": "Fjern fra frakoblede filer", "remove_from_album": "Fjern fra album", "remove_from_favorites": "Fjern fra favoritter", "remove_from_shared_link": "Fjern fra delt lenke", + "remove_url": "Fjern URL", + "remove_user": "Fjern bruker", "removed_api_key": "Fjernet API-nøkkel: {name}", + "removed_from_archive": "Fjernet fra arkivet", + "removed_from_favorites": "Fjernet fra favoritter", + "removed_from_favorites_count": "{count, plural, other {Removed #}} fra favoritter", + "removed_tagged_assets": "Fjern tag fra {count, plural, one {# asset} other {# assets}}", "rename": "Gi nytt navn", "repair": "Reparer", "repair_no_results_message": "Usporrede og savnede filer vil vises her", "replace_with_upload": "Erstatte med opplasting", + "repository": "Depot", "require_password": "Krev passord", "require_user_to_change_password_on_first_login": "Krev at brukeren endrer passord ved første pålogging", "reset": "Tilbakestill", "reset_password": "Tilbakestill passord", "reset_people_visibility": "Tilbakestill personsynlighet", + "reset_to_default": "Tilbakestill til standard", + "resolve_duplicates": "Løs duplikater", "resolved_all_duplicates": "Løste alle duplikater", "restore": "Gjenopprett", "restore_all": "Gjenopprett alle", "restore_user": "Gjenopprett bruker", + "restored_asset": "Gjenopprettet ressurs", "resume": "Fortsett", "retry_upload": "Prøv opplasting på nytt", "review_duplicates": "Gjennomgå duplikater", "role": "Rolle", + "role_editor": "Editor", + "role_viewer": "Visning", "save": "Lagre", "saved_api_key": "Lagret API-nøkkel", "saved_profile": "Lagret profil", "saved_settings": "Lagret instillinger", "say_something": "Si noe", "scan_all_libraries": "Skann alle biblioteker", + "scan_library": "Skann", "scan_settings": "Skanneinnstillinger", + "scanning_for_album": "Skanner etter album...", "search": "Søk", "search_albums": "Søk i album", "search_by_context": "Søk etter kontekst", + "search_by_filename": "Søk etter filnavn og filtype", + "search_by_filename_example": "f.eks. IMG_1234.JPG eller PNG", "search_camera_make": "Søk etter kameramerke...", "search_camera_model": "Søk etter kamera modell...", "search_city": "Søk etter by...", "search_country": "Søk etter land...", "search_for_existing_person": "Søk etter eksisterende person", + "search_no_people": "Ingen personer", + "search_no_people_named": "Ingen personer med navnet \"{name}\"", + "search_options": "Søke alternativer", "search_people": "Søk personer", "search_places": "Søk steder", + "search_settings": "Søke instillinger", "search_state": "Søk etter stat...", + "search_tags": "Søk tags...", "search_timezone": "Søk etter tidssone....", "search_type": "Søk etter type", "search_your_photos": "Søk i dine bilder", "searching_locales": "Søker lokaler...", "second": "Sekund", + "see_all_people": "Vis alle mennesker", "select_album_cover": "Velg albumomslag", "select_all": "Velg alle", + "select_all_duplicates": "Velg alle duplikater", "select_avatar_color": "Velg avatarfarge", "select_face": "Velg ansikt", "select_featured_photo": "Velg fremhevet bilde", + "select_from_computer": "Velg fra datamaskin", "select_keep_all": "Velg beholde alle", "select_library_owner": "Velg bibliotekseier", "select_new_face": "Velg nytt ansikt", "select_photos": "Velg bilder", "select_trash_all": "Velg å flytte alt til papirkurven", "selected": "Valgt", + "selected_count": "{count, plural, other {# selected}}", "send_message": "Send melding", "send_welcome_email": "Send velkomstmelding", + "server_offline": "Server frakoblet", + "server_online": "Server tilkoblet", "server_stats": "Server Statistikk", + "server_version": "Server Versjon", "set": "Sett", "set_as_album_cover": "Sett som albumomslag", + "set_as_featured_photo": "Angi som fremhevet bilde", "set_as_profile_picture": "Sett som profilbilde", "set_date_of_birth": "Sett fødselsdato", "set_profile_picture": "Sett profilbilde", @@ -1098,14 +1160,20 @@ "share": "Del", "shared": "Delt", "shared_by": "Delt av", + "shared_by_user": "Delt av {user}", "shared_by_you": "Delt av deg", "shared_from_partner": "Bilder fra {partner}", + "shared_link_options": "Alternativer for delte lenke", "shared_links": "Delte linker", "shared_photos_and_videos_count": "{assetCount, plural, other {# delte bilder og videoer.}}", "shared_with_partner": "Delt med {partner}", "sharing": "Deling", + "sharing_enter_password": "Vennligst skriv inn passordet for å se denne siden.", "sharing_sidebar_description": "Vis en lenke til Deling i sidepanelet", + "shift_to_permanent_delete": "trykk ⇧ for å slette eiendeler permanent", "show_album_options": "Vis albumalternativer", + "show_albums": "Vis album", + "show_all_people": "Vis alle mennesker", "show_and_hide_people": "Vis og skjul personer", "show_file_location": "Vis filplassering", "show_gallery": "Vis galleri", @@ -1119,16 +1187,34 @@ "show_person_options": "Vis personalternativer", "show_progress_bar": "Vis fremdriftslinje", "show_search_options": "Vis søkealternativer", + "show_slideshow_transition": "Vis overgang til lysbildefremvisning", + "show_supporter_badge": "Supportermerke", + "show_supporter_badge_description": "Vis et supportermerke", "shuffle": "Bland", + "sidebar": "Sidefelt", + "sidebar_display_description": "Vis en lenke for visningen i sidefeltet", "sign_out": "Logg ut", "sign_up": "Registrer deg", "size": "Størrelse", "skip_to_content": "Gå til innhold", + "skip_to_folders": "Hopp til mapper", + "skip_to_tags": "Hopp til tagger", "slideshow": "Lysbildefremvisning", "slideshow_settings": "Lysbildefremvisning innstillinger", "sort_albums_by": "Sorter album etter...", + "sort_created": "Dato opprettet", + "sort_items": "Antall enheter", + "sort_modified": "Dato modifisert", + "sort_oldest": "Eldste bilde", + "sort_people_by_similarity": "Sorter folk etter likhet", + "sort_recent": "Nyeste bilde", + "sort_title": "Tittel", + "source": "Kilde", "stack": "Stable", + "stack_duplicates": "Stable duplikater", + "stack_select_one_photo": "Velg hovedbilde for bildestabbel", "stack_selected_photos": "Stable valgte bilder", + "stacked_assets_count": "Stable {count, plural, one {# asset} other {# assets}}", "stacktrace": "Stakkspor", "start": "Start", "start_date": "Startdato", @@ -1144,68 +1230,121 @@ "submit": "Send inn", "suggestions": "Forslag", "sunrise_on_the_beach": "Soloppgang på stranden", + "support": "Støtte", + "support_and_feedback": "Støtte og Tilbakemelding", + "support_third_party_description": "Immich-installasjonen din ble pakket av en tredjepart. Problemer du opplever kan være forårsaket av den pakken, så vennligst ta opp problemer med dem i første omgang ved å bruke koblingene nedenfor.", "swap_merge_direction": "Bytt retning på sammenslåingen", "sync": "Synkroniser", + "tag": "Tagg", + "tag_assets": "Merk ressurser", + "tag_created": "Lag merke: {tag}", + "tag_feature_description": "Bla gjennom bilder og videoer gruppert etter logiske merke-emner", + "tag_not_found_question": "Finner du ikke en merke? Opprett en nytt merke.", + "tag_updated": "Oppdater merke: {tag}", + "tagged_assets": "Merket {count, plural, one {# asset} other {# assets}}", + "tags": "Merker", "template": "Mal", "theme": "Tema", "theme_selection": "Temavalg", "theme_selection_description": "Automatisk sett tema til lys eller mørk basert på nettleserens systeminnstilling", + "they_will_be_merged_together": "De vil bli slått sammen", + "third_party_resources": "Tredjeparts Ressurser", "time_based_memories": "Tidsbaserte minner", + "timeline": "Tidslinje", "timezone": "Tidssone", "to_archive": "Arkiv", + "to_change_password": "Endre passord", "to_favorite": "Favoritt", + "to_login": "Logg inn", + "to_parent": "Gå til overodnet", "to_trash": "Papirkurv", "toggle_settings": "Bytt innstillinger", "toggle_theme": "Bytt tema", + "total": "Total", "total_usage": "Totalt brukt", "trash": "Papirkurv", "trash_all": "Slett alt", + "trash_count": "Slett {count, number}", + "trash_delete_asset": "Slett ressurs", "trash_no_results_message": "Her vises bilder og videoer som er flyttet til papirkurven.", "trashed_items_will_be_permanently_deleted_after": "Elementer i papirkurven vil bli permanent slettet etter {days, plural, one {# dag} other {# dager}}.", "type": "Type", "unarchive": "Fjern fra arkiv", + "unarchived_count": "{count, plural, other {Unarchived #}}", "unfavorite": "Fjern favoritt", "unhide_person": "Vis person", "unknown": "Ukjent", "unknown_year": "Ukjent År", "unlimited": "Ubegrenset", + "unlink_motion_video": "Koble fra bevegelsesvideo", "unlink_oauth": "Fjern kobling til OAuth", "unlinked_oauth_account": "Koblet fra OAuth-konto", "unnamed_album": "Navnløst album", + "unnamed_album_delete_confirmation": "Er du sikker på at du vil slette dette albumet?", + "unnamed_share": "Deling uten navn", + "unsaved_change": "Ulagrede endringer", "unselect_all": "Fjern alle valg", + "unselect_all_duplicates": "Fjern markeringen av alle duplikater", "unstack": "avstable", + "unstacked_assets_count": "Ikke stablet {count, plural, one {# asset} other {# assets}}", "untracked_files": "Usporede Filer", "untracked_files_decription": "Disse filene er ikke sporet av applikasjonen. De kan være resultatet av mislykkede flyttinger, avbrutte opplastinger eller etterlatt på grunn av en feil", "up_next": "Neste", "updated_password": "Passord oppdatert", "upload": "Last opp", "upload_concurrency": "Samtidig opplastning", + "upload_errors": "Opplasting fullført med {count, plural, one {# error} other {# errors}}, oppdater siden for å se nye opplastingsressurser.", + "upload_progress": "Gjenstående {remaining, number} – behandlet {processed, number}/{total, number}", + "upload_skipped_duplicates": "Hoppet over {count, plural, one {# duplicate asset} other {# duplicate assets}}", + "upload_status_duplicates": "Duplikater", + "upload_status_errors": "Feil", + "upload_status_uploaded": "Opplastet", + "upload_success": "Opplasting vellykket, oppdater siden for å se nye opplastninger.", "url": "URL", "usage": "Bruk", + "use_custom_date_range": "Bruk egendefinert datoperiode i stedet", "user": "Bruker", "user_id": "Bruker ID", + "user_liked": "{user} likte {type, select, photo {this photo} video {this video} asset {this asset} other {it}}", + "user_purchase_settings": "Kjøpe", + "user_purchase_settings_description": "Administrer dine kjøp", + "user_role_set": "Sett {user} som {role}", "user_usage_detail": "Detaljer av brukers forbruk", + "user_usage_stats": "Kontobruksstatistikk", + "user_usage_stats_description": "Vis kontobruksstatistikk", "username": "Brukernavn", "users": "Brukere", "utilities": "Verktøy", "validate": "Valider", "variables": "Variabler", "version": "Versjon", + "version_announcement_closing": "Din venn, Alex", + "version_announcement_message": "Hei! En ny versjon av Immich er tilgjengelig. Vennligst ta deg tid til å lese utgivelsesnotatene for å sikre at oppsettet ditt er oppdatert for å forhindre feilkonfigurasjoner, spesielt hvis du bruker WatchTower eller en annen mekanisme som håndterer oppdatering av Immich-forekomsten din automatisk.", + "version_history": "Verson Historie", + "version_history_item": "Installert {version} den {date}", "video": "Video", "video_hover_setting": "Spill av forhåndsvisining mens du holder over musepekeren", "video_hover_setting_description": "Spill av forhåndsvisning mens en musepeker er over elementet. Selv når den er deaktivert, kan avspilling startes ved å holde musepekeren over avspillingsikonet.", "videos": "Videoer", "videos_count": "{count, plural, one {# Video} other {# Videoer}}", + "view": "Vis", + "view_album": "Vis Album", "view_all": "Vis alle", "view_all_users": "Vis alle brukere", + "view_in_timeline": "Vis i tidslinje", "view_links": "Vis lenker", + "view_name": "Vis", "view_next_asset": "Vis neste fil", "view_previous_asset": "Vis forrige fil", + "view_stack": "Vis Stabbel", + "visibility_changed": "Synlighet endret for {count, plural, one {# person} other {# people}}", "waiting": "Venter", + "warning": "Advarsel", "week": "Uke", "welcome": "Velkommen", "welcome_to_immich": "Velkommen til Immich", "year": "År", + "years_ago": "{years, plural, one {# year} other {# years}} siden", "yes": "Ja", "you_dont_have_any_shared_links": "Du har ingen delte lenker", "zoom_image": "Zoom Bilde" diff --git a/i18n/nn.json b/i18n/nn.json index bfbb2dc2ac..e5a912e203 100644 --- a/i18n/nn.json +++ b/i18n/nn.json @@ -28,6 +28,264 @@ "added_to_favorites": "Lagt til favorittar", "added_to_favorites_count": "Lagt {count, number} til favorittar", "admin": { - "confirm_delete_library": "Er du sikker på at du vil slette biblioteket {library}?" - } + "asset_offline_description": "Denne eksterne bibliotekressursen finst ikkje lenger på disk og har blitt flytta til papirkurven. Om fila blei flytta innad i biblioteket, sjekk tidslinja di for den tilsvarande ressursen. For å gjenopprette ressursen, vennligst sørg for at filstien under er tilgjengeleg for Immich og skann biblioteket.", + "backup_settings": "Backupinnstillingar", + "check_all": "Sjekk alle", + "confirm_delete_library": "Er du sikker på at du vil slette biblioteket {library}?", + "create_job": "Lag jobb", + "disable_login": "Deaktiver innlogging", + "face_detection": "Ansiktsdeteksjon", + "image_format": "Format", + "image_preview_title": "Forhandsvis innstillingar", + "image_quality": "Kvalitet", + "image_resolution": "Oppløysing", + "image_thumbnail_description": "Lite miniatyrbilete med fjerna metadata, brukt når ein ser på grupper av bilete som hovudtidslinja", + "job_created": "Jobb laga", + "job_settings": "Jobbinnstillingar", + "job_status": "Jobbstatus", + "library_deleted": "Bibliotek sletta", + "library_scanning": "Periodisk skanning", + "library_settings": "Eksternt Bibliotek", + "logging_settings": "Logging", + "machine_learning_duplicate_detection": "Duplikatdeteksjon", + "machine_learning_facial_recognition": "Ansiktsgjenkjenning", + "machine_learning_smart_search": "Smart Søk", + "map_dark_style": "Mørk modus", + "map_light_style": "Lys modus", + "map_settings": "Kart", + "metadata_extraction_job": "Hent ut metadata", + "metadata_settings": "Metadata Innstillinger", + "migration_job": "Migrasjon", + "notification_email_from_address": "Frå adresse", + "notification_settings": "Varselinnstillingar", + "oauth_auto_launch": "Autostart", + "oauth_button_text": "Tekst på knapp", + "password_settings": "Passord innlogging", + "person_cleanup_job": "Personopprydding", + "registration": "Administrator registrering", + "registration_description": "Sidan du er den første brukaren på systemet, vil du bli utnevnt til administrator og ha ansvar for administrative oppgåver. Du vil òg opprette eventuelle nye brukarar.", + "repair_all": "Reparer alle", + "repair_matched_items": "Samsvarte med {count, plural, one {# element} other {# elementer}}", + "repaired_items": "Reparerte {count, plural, one {# item} other {# items}}", + "require_password_change_on_login": "Krev at brukaren endrar passord ved første pålogging", + "reset_settings_to_default": "Tilbakestill innstillingar til standard", + "reset_settings_to_recent_saved": "Tilbakestill innstillingane til de nyleg lagra innstillingane", + "scanning_library": "Skann bibliotek", + "search_jobs": "Søk etter jobbar", + "send_welcome_email": "Send velkomst-e-post", + "server_external_domain_settings": "Eksternt domene", + "server_external_domain_settings_description": "Domene for offentlege delingslenkjer, inkludert http(s)://", + "server_public_users": "Offentlege brukarar", + "server_public_users_description": "Alle brukarar (namn og epost) blir vist når ein brukar blir lagt til eit delt album. Når deaktivert, vil brukarane berre bli synlege for administratorar.", + "server_settings": "Serverinstillingar", + "server_settings_description": "Administrer serverinnstillingar", + "server_welcome_message": "Velkomstmelding", + "server_welcome_message_description": "Ei melding som synast på innloggingssida.", + "template_email_preview": "Førehandsvisning" + }, + "administration": "Administrasjon", + "advanced": "Avansert", + "album_with_link_access": "Lat kven som helst med lenka sjå bilete og folk i dette albumet.", + "albums": "Album", + "all": "Alle", + "anti_clockwise": "Mot klokka", + "archive": "Arkiv", + "asset_skipped": "Hoppa over", + "asset_uploaded": "Opplasta", + "asset_uploading": "Lastar opp...", + "back": "Tilbake", + "backward": "Bakover", + "camera": "Kamera", + "cancel": "Avbryt", + "city": "By", + "clear": "Fjern", + "clockwise": "Med klokka", + "close": "Lukk", + "color": "Farge", + "confirm": "Bekreft", + "contain": "Inneheld", + "continue": "Hald fram", + "country": "Land", + "cover": "Dekk", + "covers": "Dekker", + "create": "Opprett", + "created": "Oppretta", + "dark": "Mørk", + "day": "Dag", + "delete": "Slett", + "description": "Beskrivelse", + "details": "Detaljer", + "direction": "Retning", + "discover": "Oppdag", + "display_original_photos": "Vis originale bilete", + "display_original_photos_setting_description": "Føretrekk å vise det originale biletet når ein ser på eit aktivum i staden for miniatyrbilete når det originale aktivumet er nettkompatibelt. Dette kan føre til tregare biletvisingshastigheiter.", + "documentation": "Dokumentasjon", + "done": "Ferdig", + "download": "Last ned", + "download_include_embedded_motion_videos_description": "Inkluder videoar innebygd i rørslefoto som ein eigen fil", + "download_settings": "Last ned", + "downloading": "Laster ned", + "duplicates": "Duplikat", + "duration": "Lengde", + "edit": "Rediger", + "edited": "Redigert", + "editor": "Redigeringsverktøy", + "explore": "Utforsk", + "explorer": "Utforsker", + "folders_feature_description": "Bla gjennom mappe for bileta og videoane på filsystemet", + "hour": "Time", + "image": "Bilde", + "info": "Info", + "jobs": "Oppgåver", + "keep": "Behald", + "language": "Språk", + "latitude": "Lengdegrad", + "leave": "Forlat", + "level": "Nivå", + "library": "Bibliotek", + "light": "Lys", + "list": "Liste", + "loading": "Lastar", + "login": "Login", + "longitude": "Lengdegrad", + "look": "Utsjånad", + "make": "Produsent", + "map": "Kart", + "matches": "Treff", + "memories": "Minner", + "memory": "Minne", + "menu": "Meny", + "merge": "Slå saman", + "minimize": "Minimere", + "minute": "Minutt", + "missing": "Mangler", + "model": "Modell", + "month": "Månad", + "more": "Meir", + "name": "Namn", + "never": "Aldri", + "next": "Neste", + "no": "Nei", + "no_albums_message": "Lag eit album for å organisere bileta og videoane dine.", + "no_archived_assets_message": "Arkiver bilder og videoar for å skjule dei frå bileta dine", + "no_explore_results_message": "Last opp fleire bilete for å utforske samlinga di.", + "no_libraries_message": "Lag eit eksternt bibliotek for å sjå bileta og videoane dine", + "no_shared_albums_message": "Lag eit album for å dele bilete og videoar med folk i nettverket ditt", + "notes": "Noter", + "notifications": "Varsel", + "ok": "Ok", + "options": "Val", + "or": "eller", + "original": "original", + "other": "Anna", + "owner": "Eigar", + "partner": "Partner", + "partner_can_access_assets": "Alle bileta og videoane dine unntatt dei i Arkivert og Sletta", + "partner_can_access_location": "Staden der bileta dine vart tekne", + "password": "Passord", + "path": "Sti", + "pattern": "Mønster", + "pause": "Pause", + "paused": "Pausa", + "pending": "Ventar", + "people": "Folk", + "people_feature_description": "Bla gjennom foto og videoar gruppert etter folk", + "person": "Person", + "photo_shared_all_users": "Ser ut som du delte bileta dine med alle brukarar eller at du ikkje har nokon brukar å dele med.", + "photos": "Bilete", + "photos_and_videos": "Foto og Video", + "photos_from_previous_years": "Bilete frå tidlegare år", + "place": "Stad", + "places": "Stad", + "play": "Spel av", + "port": "Port", + "preview": "Førehandsvisning", + "previous": "Forrige", + "primary": "Hoved", + "privacy": "Personvern", + "purchase_button_activate": "Aktiver", + "purchase_button_buy": "Kjøp", + "purchase_button_select": "Vel", + "purchase_individual_title": "Induviduell", + "purchase_server_title": "Server", + "reassign": "Vel på nytt", + "recent": "Nyleg", + "refresh": "Oppdater", + "refreshed": "Oppdatert", + "remove": "Fjern", + "rename": "Endre namn", + "repair": "Reparasjon", + "reset": "Tilbakestill", + "restore": "Tilbakestill", + "resume": "Fortsett", + "role": "Rolle", + "save": "Lagre", + "scan_library": "Skann", + "search": "Søk", + "search_your_photos": "Søk i dine bilete", + "second": "Sekund", + "selected": "Valgt", + "set": "Sett", + "settings": "Innstillingar", + "share": "Del", + "shared": "Delt", + "shared_from_partner": "Bilete frå {partner}", + "sharing": "Deling", + "show_in_timeline_setting_description": "Vis bilete og videoar frå denne brukaren i tidslinja di", + "sidebar": "Sidebar", + "size": "Størrelse", + "slideshow": "Lysbildeframvisning", + "sort_title": "Tittel", + "source": "Kjelde", + "stack": "Stabel", + "start": "Start", + "state": "Region", + "status": "Status", + "stop_photo_sharing": "Stopp å dele bileta dine?", + "stop_photo_sharing_description": "{partner} vil ikkje lenger kunne få tilgang til bileta dine.", + "stop_sharing_photos_with_user": "Stopp å dele bileta dine med denne brukaren", + "storage": "Lagringsplass", + "submit": "Send inn", + "suggestions": "Forslag", + "support": "Support", + "sync": "Synk", + "tag": "Tag", + "tag_feature_description": "Bla gjennom bilete og videoar gruppert etter logiske tag-tema", + "tags": "Tags", + "theme": "Tema", + "timeline": "Tidslinje", + "timezone": "Tidssone", + "to_archive": "Arkiv", + "to_favorite": "Favoritt", + "to_login": "Innlogging", + "to_trash": "Søppel", + "total": "Total", + "trash": "Søppel", + "trash_no_results_message": "Sletta foto og videoar vil dukke opp her.", + "type": "Type", + "unfavorite": "Fjern favoritt", + "unknown": "Ukjent", + "unlimited": "Ubegrensa", + "upload": "Last opp", + "upload_status_duplicates": "Duplikater", + "upload_status_errors": "Feil", + "upload_status_uploaded": "Opplasta", + "url": "URL", + "usage": "Bruk", + "user": "Brukar", + "user_purchase_settings": "Kjøp", + "username": "Brukarnamn", + "users": "Brukarar", + "utilities": "Verktøy", + "validate": "Validere", + "variables": "Variablar", + "version": "Versjon", + "video": "Video", + "videos": "Videoar", + "waiting": "Ventar", + "warning": "Advarsel", + "week": "Veke", + "welcome": "Velkomen", + "year": "År", + "yes": "Ja" } diff --git a/i18n/ro.json b/i18n/ro.json index 1d4a598b52..d4205e331c 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -289,6 +289,8 @@ "transcoding_constant_rate_factor": "Factor de rată constantă (-crf)", "transcoding_constant_rate_factor_description": "Nivelul de calitate al videoclipului. Valorile tipice sunt 23 pentru H.264, 28 pentru HEVC, 31 pentru VP9 și 35 pentru AV1. Cu cât valoarea este mai mică, cu atât calitatea este mai bună, dar se generează fișiere mai mari.", "transcoding_disabled_description": "Nu transcodifică niciun videoclip; acest lucru poate afecta redarea pe anumite dispozitive", + "transcoding_encoding_options": "Opțiuni codificare", + "transcoding_encoding_options_description": "Setează codecuri , calitatea, rezoluția și alte opțiuni pentru videoclipuri codificare", "transcoding_hardware_acceleration": "Accelerare Hardware", "transcoding_hardware_acceleration_description": "Experimental; mult mai rapid, dar va avea o calitate mai scăzută la același bitrate", "transcoding_hardware_decoding": "Decodare hardware", @@ -301,6 +303,8 @@ "transcoding_max_keyframe_interval": "Interval maxim între cadre cheie", "transcoding_max_keyframe_interval_description": "Setează distanța maximă între cadrele cheie. Valorile mai mici reduc eficiența compresiei, dar îmbunătățesc timpii de căutare și pot îmbunătăți calitatea în scenele cu mișcare rapidă. 0 setează această valoare automat.", "transcoding_optimal_description": "Videoclipuri cu rezoluție mai mare decât cea țintă sau care nu sunt într-un format acceptat", + "transcoding_policy": "Politică de transcodare", + "transcoding_policy_description": "Setează când un video va fi transcodat", "transcoding_preferred_hardware_device": "Dispozitiv hardware preferat", "transcoding_preferred_hardware_device_description": "Se aplică doar la VAAPI și QSV. Setează nodul DRI utilizat pentru transcodarea hardware.", "transcoding_preset_preset": "Presetare (-preset)", @@ -309,7 +313,7 @@ "transcoding_reference_frames_description": "Numărul de cadre de referință atunci când se comprimă un cadru dat. Valorile mai mari îmbunătățesc eficiența compresiei, dar încetinesc codarea. 0 setează această valoare automat.", "transcoding_required_description": "Numai videoclipuri care nu sunt într-un format acceptat", "transcoding_settings": "Setări de Transcodare Video", - "transcoding_settings_description": "Gestionează rezoluția și informațiile de codare ale fișierelor video", + "transcoding_settings_description": "Gestionează care videoclipuri să transcodam și cum să le procesam", "transcoding_target_resolution": "Rezoluția țintă", "transcoding_target_resolution_description": "Rezoluțiile mai mari pot păstra mai multe detalii, dar necesită mai mult timp pentru codare, au dimensiuni mai mari ale fișierelor și pot reduce răspunsul aplicației.", "transcoding_temporal_aq": "AQ temporal", @@ -322,7 +326,7 @@ "transcoding_transcode_policy_description": "Politica pentru momentul când un videoclip ar trebui să fie transcodificat. Videoclipurile HDR vor fi întotdeauna transcodificate (cu excepția cazului în care transcodarea este dezactivată).", "transcoding_two_pass_encoding": "Codare în doi pași", "transcoding_two_pass_encoding_setting_description": "Transcodificare în două treceri pentru a produce videoclipuri codificate mai bine. Când rata maximă de biți este activată (necesară pentru a funcționa cu H.264 și HEVC), acest mod utilizează un interval de rată de biți bazat pe rata maximă de biți și ignoră CRF. Pentru VP9, CRF poate fi utilizat dacă rata maximă de biți este dezactivată.", - "transcoding_video_codec": "Codec Video", + "transcoding_video_codec": "Codec video", "transcoding_video_codec_description": "VP9 are eficiențǎ mare și compatibilitate web, însǎ transcodarea este de duratǎ mai mare. HEVC se comportǎ asemǎnǎtor, însǎ are compatibilitate web mai micǎ. H.264 este foarte compatibil și rapid în transcodare, însǎ genereazǎ fișiere mult mai mari. AV1 este cel mai eficient codec dar nu este compatibil cu dispozitivele mai vechi.", "trash_enabled_description": "Activează funcțiile Coșului de Gunoi", "trash_number_of_days": "Numǎr de zile", @@ -519,6 +523,10 @@ "date_range": "Interval de date", "day": "Zi", "deduplicate_all": "Deduplicați Toate", + "deduplication_criteria_1": "Marimea imagini în octeți", + "deduplication_criteria_2": "Numărul de date EXIF", + "deduplication_info": "Informați despre deduplicare", + "deduplication_info_description": "Ca să preselecționăm activele și să scoatem duplicatele în vrac , ne uităm la:", "default_locale": "Setare Regională Implicită", "default_locale_description": "Formatați datele și numerele în funcție de regiunea browserului dvs", "delete": "Ștergere", @@ -755,6 +763,7 @@ "get_help": "Obțineți Ajutor", "getting_started": "Noțiuni de Bază", "go_back": "Întoarcere", + "go_to_folder": "Accesați folderul", "go_to_search": "Spre căutare", "group_albums_by": "Grupați albume de...", "group_no": "Fără grupare", diff --git a/i18n/sr_Cyrl.json b/i18n/sr_Cyrl.json index d9b095467b..56662c8d53 100644 --- a/i18n/sr_Cyrl.json +++ b/i18n/sr_Cyrl.json @@ -13,7 +13,7 @@ "add_a_location": "Додај Локацију", "add_a_name": "Додај име", "add_a_title": "Додај наслов", - "add_exclusion_pattern": "Додајте образац изузимања", + "add_exclusion_pattern": "Додај образац изузимања", "add_import_path": "Додај путању за преузимање", "add_location": "Додај локацију", "add_more_users": "Додај кориснике", @@ -23,7 +23,7 @@ "add_to": "Додај у...", "add_to_album": "Додај у албум", "add_to_shared_album": "Додај у дељен албум", - "add_url": "Додајте URL", + "add_url": "Додај URL", "added_to_archive": "Додато у архиву", "added_to_favorites": "Додато у фаворите", "added_to_favorites_count": "Додато {count, number} у фаворите", diff --git a/i18n/sr_Latn.json b/i18n/sr_Latn.json index 4253676768..b5993cc9a6 100644 --- a/i18n/sr_Latn.json +++ b/i18n/sr_Latn.json @@ -13,7 +13,7 @@ "add_a_location": "Dodaj Lokaciju", "add_a_name": "Dodaj ime", "add_a_title": "Dodaj naslov", - "add_exclusion_pattern": "Dodajte obrazac izuzimanja", + "add_exclusion_pattern": "Dodaj obrazac izuzimanja", "add_import_path": "Dodaj putanju za preuzimanje", "add_location": "Dodaj lokaciju", "add_more_users": "Dodaj korisnike", @@ -23,7 +23,7 @@ "add_to": "Dodaj u...", "add_to_album": "Dodaj u album", "add_to_shared_album": "Dodaj u deljen album", - "add_url": "Dodajte URL", + "add_url": "Dodaj URL", "added_to_archive": "Dodato u arhivu", "added_to_favorites": "Dodato u favorite", "added_to_favorites_count": "Dodato {count, number} u favorite", diff --git a/i18n/sv.json b/i18n/sv.json index 73d9ff51cb..80f2687b7d 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -909,12 +909,14 @@ "no_results_description": "Pröva en synonym eller ett annat mer allmänt sökord", "no_shared_albums_message": "Skapa ett album för att dela bilder och videor med andra personer", "not_in_any_album": "Inte i något album", + "note_apply_storage_label_to_previously_uploaded assets": "Obs: Om du vill använda lagringsetiketten på tidigare uppladdade tillgångar kör du", "note_unlimited_quota": "Notera: Ange 0 för obegränsad mängd", "notes": "Notera", "notification_toggle_setting_description": "Aktivera e-postaviseringar", "notifications": "Notifikationer", "notifications_setting_description": "Hantera aviseringar", "oauth": "OAuth", + "official_immich_resources": "Officiella Immich-resurser", "offline": "Frånkopplad", "offline_paths": "Offlinevägar", "offline_paths_description": "Dessa resultat kan bero på att filer som ej ingår i ett externt bibliotek har tagits bort manuellt.", @@ -941,11 +943,12 @@ "owner": "Ägare", "partner": "Partner", "partner_can_access": "{partner} har åtkomst", + "partner_can_access_assets": "Alla dina foton och videoklipp förutom de i Arkiverade och Raderade", "partner_can_access_location": "Platsen där dina foton togs", "partner_sharing": "Partnerdelning", "partners": "Partners", "password": "Lösenord", - "password_does_not_match": "", + "password_does_not_match": "Lösenorden stämmer inte överens", "password_required": "Lösenord krävs", "password_reset_success": "Lösenord återställt", "past_durations": { @@ -960,14 +963,14 @@ "paused": "Pausad", "pending": "Väntande", "people": "Personer", - "people_edits_count": "Redigerad {count, plural, one {# person} other {# people}}", + "people_edits_count": "Redigerad {count, plural, one {# person} other {# personer}}", "people_feature_description": "Visar foton och videor grupperade efter personer", "people_sidebar_description": "Visa en länk till Personer i sidopanelen", "permanent_deletion_warning": "Varning om permanent radering", "permanent_deletion_warning_setting_description": "Visa en varning när tillgångar raderas permanent", "permanently_delete": "Radera permanent", "permanently_delete_assets_count": "Radera {count, plural, one {asset} other {assets}} permanent", - "permanently_deleted_asset": "", + "permanently_deleted_asset": "Permanent raderad tillgång", "person": "Person", "photos": "Foton", "photos_and_videos": "Foton & videor", diff --git a/i18n/tr.json b/i18n/tr.json index 7c667901c2..10bc00cbf4 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -289,6 +289,8 @@ "transcoding_constant_rate_factor": "Sabit oran faktörü (-SOF)", "transcoding_constant_rate_factor_description": "Video kalite seviyesi. Tipik değerler H.264 için 23, HEVC için 28, VP9 için 31 ve AV1 için 35'tir. Daha düşük değerler daha iyi kalite sağlar, ancak daha büyük dosyalar üretir.", "transcoding_disabled_description": "Videoları dönüştürmeyin, bazı istemcilerde oynatma bozulabilir", + "transcoding_encoding_options": "Kodlama Seçenekleri", + "transcoding_encoding_options_description": "Kodlanmış videolar için kodekleri, çözünürlüğü, kaliteyi ve diğer seçenekleri ayarlayın", "transcoding_hardware_acceleration": "Donanım Hızlandırma", "transcoding_hardware_acceleration_description": "Deneysel; daha hızlı, fakat aynı bitrate ayarlarında daha düşük kaliteye sahip", "transcoding_hardware_decoding": "Donanım çözücü", @@ -301,6 +303,8 @@ "transcoding_max_keyframe_interval": "Maksimum ana kare aralığı", "transcoding_max_keyframe_interval_description": "Ana kareler arasındaki maksimum kare mesafesini ayarlar. Düşük değerler sıkıştırma verimliliğini kötüleştirir, ancak arama sürelerini iyileştirir ve hızlı hareket içeren sahnelerde kaliteyi artırabilir. 0 bu değeri otomatik olarak ayarlar.", "transcoding_optimal_description": "Hedef çözünürlükten yüksek veya kabul edilen formatta olmayan videolar", + "transcoding_policy": "Kod Dönüştürme Politikası", + "transcoding_policy_description": "Bir videonun ne zaman kod dönüştürüleceğini ayarlama", "transcoding_preferred_hardware_device": "Tercih edilen donanım cihazı", "transcoding_preferred_hardware_device_description": "Sadece VAAPI ve QSV için uygulanır. Donanım kod çevrimi için DRI Node ayarlar.", "transcoding_preset_preset": "Ön ayar (-ön)", @@ -309,7 +313,7 @@ "transcoding_reference_frames_description": "Belirli bir kareyi sıkıştırırken referans alınacak kare sayısı. Daha yüksek değerler sıkıştırma verimliliğini artırır, ancak kodlamayı yavaşlatır. 0 bu değeri otomatik olarak ayarlar.", "transcoding_required_description": "Yalnızca kabul edilen formatta olmayan videolar", "transcoding_settings": "Video Dönüştürme Ayarları", - "transcoding_settings_description": "Video dosyalarının çözünürlük ve kodlama bilgilerini yönetir", + "transcoding_settings_description": "Hangi videoların dönüştürüleceğini ve nasıl işleneceğini yönetin", "transcoding_target_resolution": "Hedef çözünürlük", "transcoding_target_resolution_description": "Daha yüksek çözünürlükler daha fazla detayı koruyabilir fakat işlemesi daha uzun sürer, dosya boyutu daha yüksek olur ve uygulamanın akıcılığını etkileyebilir.", "transcoding_temporal_aq": "Zamansal AQ", @@ -519,6 +523,10 @@ "date_range": "Tarih aralığı", "day": "Gün", "deduplicate_all": "Tüm kopyaları kaldır", + "deduplication_criteria_1": "Resim boyutu (bayt olarak)", + "deduplication_criteria_2": "EXIF veri sayısı", + "deduplication_info": "Tekilleştirme Bilgileri", + "deduplication_info_description": "Varlıkları otomatik olarak önceden seçmek ve yinelenenleri toplu olarak kaldırmak için şunlara bakıyoruz:", "default_locale": "Varsayılan Yerel Ayar", "default_locale_description": "Tarihleri ve sayıları tarayıcınızın yerel ayarına göre biçimlendirin", "delete": "Sil", @@ -755,6 +763,7 @@ "get_help": "Yardım Al", "getting_started": "Başlarken", "go_back": "Geri git", + "go_to_folder": "Klasöre git", "go_to_search": "Aramaya git", "group_albums_by": "Albümleri gruplandır...", "group_no": "Gruplama yok", diff --git a/i18n/zh_Hant.json b/i18n/zh_Hant.json index 0cdbb09216..51705d11e6 100644 --- a/i18n/zh_Hant.json +++ b/i18n/zh_Hant.json @@ -24,7 +24,7 @@ "add_to_album": "加入到相簿", "add_to_shared_album": "加到共享相簿", "add_url": "新增URL", - "added_to_archive": "封存", + "added_to_archive": "已新增至封存", "added_to_favorites": "加入收藏", "added_to_favorites_count": "將 {count, number} 個項目加入收藏", "admin": { @@ -289,6 +289,8 @@ "transcoding_constant_rate_factor": "恆定速率因子(-crf)", "transcoding_constant_rate_factor_description": "視頻質量級別。典型值為 H.264 的 23、HEVC 的 28、VP9 的 31 和 AV1 的 35。數值越低,質量越高,但會產生較大的文件。", "transcoding_disabled_description": "不轉碼影片,可能會讓某些客戶端無法正常播放", + "transcoding_encoding_options": "編碼選項", + "transcoding_encoding_options_description": "設定編碼影片的編解碼器、解析度、品質和其他選項", "transcoding_hardware_acceleration": "硬體加速", "transcoding_hardware_acceleration_description": "實驗性功能;速度更快,但在相同比特率下質量較低", "transcoding_hardware_decoding": "硬體解碼", @@ -301,6 +303,8 @@ "transcoding_max_keyframe_interval": "最大關鍵幀間隔", "transcoding_max_keyframe_interval_description": "設置關鍵幀之間的最大幀距。較低的值會降低壓縮效率,但可以改善尋找時間,並可能改善快速運動場景中的質量。0 會自動設置此值。", "transcoding_optimal_description": "高於目標解析度或格式不被支援的影片", + "transcoding_policy": "轉碼策略", + "transcoding_policy_description": "設定影片進行轉碼的條件", "transcoding_preferred_hardware_device": "首選硬件設備", "transcoding_preferred_hardware_device_description": "僅適用於 VAAPI 和 QSV。設置用於硬件轉碼的 DRI 節點。", "transcoding_preset_preset": "預設值(-preset)", @@ -519,6 +523,10 @@ "date_range": "日期範圍", "day": "日", "deduplicate_all": "刪除所有重複項目", + "deduplication_criteria_1": "圖像大小(以位元組為單位)", + "deduplication_criteria_2": "EXIF 資料數量", + "deduplication_info": "重複資料刪除資訊", + "deduplication_info_description": "為了自動預選資產並大量刪除重複項,我們查看:", "default_locale": "預設區域", "default_locale_description": "依瀏覽器區域設定日期和數字格式", "delete": "刪除", @@ -755,6 +763,7 @@ "get_help": "線上求助", "getting_started": "開始使用", "go_back": "返回", + "go_to_folder": "轉至資料夾", "go_to_search": "前往搜尋", "group_albums_by": "分類群組的方式...", "group_no": "無分組", @@ -1141,6 +1150,7 @@ "server_version": "目前版本", "set": "設定", "set_as_album_cover": "設爲相簿封面", + "set_as_featured_photo": "設為特色照片", "set_as_profile_picture": "設為個人資料圖片", "set_date_of_birth": "設定出生日期", "set_profile_picture": "設置個人資料圖片", @@ -1191,7 +1201,7 @@ "skip_to_tags": "跳轉到標籤", "slideshow": "幻燈片", "slideshow_settings": "幻燈片設定", - "sort_albums_by": "排序相簿", + "sort_albums_by": "相簿排序依據...", "sort_created": "建立日期", "sort_items": "項目數量", "sort_modified": "日期已修改",