From 8c3c3357fe4729c3f5a40851bbf6a425397702a9 Mon Sep 17 00:00:00 2001 From: Carsten Otto Date: Mon, 9 Sep 2024 21:26:21 +0200 Subject: [PATCH 01/16] feat(web): select the EXIF timezone (if it exists) in dropdown (#12495) --- web/src/lib/components/asset-viewer/detail-panel.svelte | 2 ++ web/src/lib/components/shared-components/change-date.svelte | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 04f4476e07635..205eb26699115 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -331,8 +331,10 @@ locale: $locale, }) : DateTime.now()} + {@const assetTimeZoneOriginal = asset.exifInfo?.timeZone ?? ''} handleConfirmChangeDate(date)} on:cancel={() => (isShowChangeDate = false)} /> diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte index 306ba46b4afd1..80eaa3d819aa7 100644 --- a/web/src/lib/components/shared-components/change-date.svelte +++ b/web/src/lib/components/shared-components/change-date.svelte @@ -7,6 +7,7 @@ import { t } from 'svelte-i18n'; export let initialDate: DateTime = DateTime.now(); + export let initialTimeZone: string = ''; type ZoneOption = { /** @@ -76,7 +77,7 @@ } /* - * Find the time zone to select for a given time, date, and offset (e.g. +02:00). + * If the time zone is not given, find the timezone to select for a given time, date, and offset (e.g. +02:00). * * This is done so that the list shown to the user includes more helpful names like "Europe/Berlin (+02:00)" * instead of just the raw offset or something like "UTC+02:00". @@ -97,6 +98,7 @@ ) { const offset = date.offset; const previousSelection = timezones.find((item) => item.value === selectedOption?.value); + const fromInitialTimeZone = timezones.find((item) => item.value === initialTimeZone); const sameAsUserTimeZone = timezones.find((item) => item.offsetMinutes === offset && item.value === userTimeZone); const firstWithSameOffset = timezones.find((item) => item.offsetMinutes === offset); const utcFallback = { @@ -105,7 +107,7 @@ value: 'UTC', valid: true, }; - return previousSelection ?? sameAsUserTimeZone ?? firstWithSameOffset ?? utcFallback; + return previousSelection ?? fromInitialTimeZone ?? sameAsUserTimeZone ?? firstWithSameOffset ?? utcFallback; } function sortTwoZones(zoneA: ZoneOption, zoneB: ZoneOption) { From d39917a4db412c694f18d57393d233b2b4c4ecb3 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 9 Sep 2024 16:03:17 -0400 Subject: [PATCH 02/16] fix(web): show trash indicator (#12521) --- .../model/asset_bulk_upload_check_result.dart | 19 ++++++++++++++++++- open-api/immich-openapi-specs.json | 3 +++ open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/dtos/asset-media-response.dto.ts | 1 + server/src/queries/asset.repository.sql | 1 + server/src/queries/metadata.repository.sql | 10 +++++----- server/src/repositories/asset.repository.ts | 1 + .../src/repositories/metadata.repository.ts | 10 +++++----- .../src/services/asset-media.service.spec.ts | 16 ++++++++++++++-- server/src/services/asset-media.service.ts | 11 +++++------ .../upload-asset-preview.svelte | 13 +++++++++++-- web/src/lib/i18n/en.json | 1 + web/src/lib/models/upload-asset.ts | 1 + web/src/lib/utils/file-uploader.ts | 13 ++++++++++--- 14 files changed, 77 insertions(+), 24 deletions(-) diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart index 737186e5898ee..a016b357e7e6b 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart @@ -16,6 +16,7 @@ class AssetBulkUploadCheckResult { required this.action, this.assetId, required this.id, + this.isTrashed, this.reason, }); @@ -31,6 +32,14 @@ class AssetBulkUploadCheckResult { String id; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isTrashed; + AssetBulkUploadCheckResultReasonEnum? reason; @override @@ -38,6 +47,7 @@ class AssetBulkUploadCheckResult { other.action == action && other.assetId == assetId && other.id == id && + other.isTrashed == isTrashed && other.reason == reason; @override @@ -46,10 +56,11 @@ class AssetBulkUploadCheckResult { (action.hashCode) + (assetId == null ? 0 : assetId!.hashCode) + (id.hashCode) + + (isTrashed == null ? 0 : isTrashed!.hashCode) + (reason == null ? 0 : reason!.hashCode); @override - String toString() => 'AssetBulkUploadCheckResult[action=$action, assetId=$assetId, id=$id, reason=$reason]'; + String toString() => 'AssetBulkUploadCheckResult[action=$action, assetId=$assetId, id=$id, isTrashed=$isTrashed, reason=$reason]'; Map toJson() { final json = {}; @@ -60,6 +71,11 @@ class AssetBulkUploadCheckResult { // json[r'assetId'] = null; } json[r'id'] = this.id; + if (this.isTrashed != null) { + json[r'isTrashed'] = this.isTrashed; + } else { + // json[r'isTrashed'] = null; + } if (this.reason != null) { json[r'reason'] = this.reason; } else { @@ -79,6 +95,7 @@ class AssetBulkUploadCheckResult { action: AssetBulkUploadCheckResultActionEnum.fromJson(json[r'action'])!, assetId: mapValueOfType(json, r'assetId'), id: mapValueOfType(json, r'id')!, + isTrashed: mapValueOfType(json, r'isTrashed'), reason: AssetBulkUploadCheckResultReasonEnum.fromJson(json[r'reason']), ); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2325f24ee59d4..19d6b5055660b 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7928,6 +7928,9 @@ "id": { "type": "string" }, + "isTrashed": { + "type": "boolean" + }, "reason": { "enum": [ "duplicate", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 43777552c59bf..2afdf083433b2 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -395,6 +395,7 @@ export type AssetBulkUploadCheckResult = { action: Action; assetId?: string; id: string; + isTrashed?: boolean; reason?: Reason; }; export type AssetBulkUploadCheckResponseDto = { diff --git a/server/src/dtos/asset-media-response.dto.ts b/server/src/dtos/asset-media-response.dto.ts index 33fa080bc1607..5cd9b7e7d9b53 100644 --- a/server/src/dtos/asset-media-response.dto.ts +++ b/server/src/dtos/asset-media-response.dto.ts @@ -26,6 +26,7 @@ export class AssetBulkUploadCheckResult { action!: AssetUploadAction; reason?: AssetRejectReason; assetId?: string; + isTrashed?: boolean; } export class AssetBulkUploadCheckResponseDto { diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 9b4b17425c409..da5ec1d4d1d53 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -493,6 +493,7 @@ LIMIT -- AssetRepository.getByChecksums SELECT "AssetEntity"."id" AS "AssetEntity_id", + "AssetEntity"."deletedAt" AS "AssetEntity_deletedAt", "AssetEntity"."checksum" AS "AssetEntity_checksum" FROM "assets" "AssetEntity" diff --git a/server/src/queries/metadata.repository.sql b/server/src/queries/metadata.repository.sql index 077b4644b824d..212527432072b 100644 --- a/server/src/queries/metadata.repository.sql +++ b/server/src/queries/metadata.repository.sql @@ -8,7 +8,7 @@ FROM LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" AND ("asset"."deletedAt" IS NULL) WHERE - "asset"."ownerId" = $1 + "asset"."ownerId" IN ($1) -- MetadataRepository.getStates SELECT DISTINCT @@ -18,7 +18,7 @@ FROM LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" AND ("asset"."deletedAt" IS NULL) WHERE - "asset"."ownerId" = $1 + "asset"."ownerId" IN ($1) AND "exif"."country" = $2 -- MetadataRepository.getCities @@ -29,7 +29,7 @@ FROM LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" AND ("asset"."deletedAt" IS NULL) WHERE - "asset"."ownerId" = $1 + "asset"."ownerId" IN ($1) AND "exif"."country" = $2 AND "exif"."state" = $3 @@ -41,7 +41,7 @@ FROM LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" AND ("asset"."deletedAt" IS NULL) WHERE - "asset"."ownerId" = $1 + "asset"."ownerId" IN ($1) AND "exif"."model" = $2 -- MetadataRepository.getCameraModels @@ -52,5 +52,5 @@ FROM LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" AND ("asset"."deletedAt" IS NULL) WHERE - "asset"."ownerId" = $1 + "asset"."ownerId" IN ($1) AND "exif"."make" = $2 diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 3763cccd53c5d..059a05f9e770d 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -338,6 +338,7 @@ export class AssetRepository implements IAssetRepository { select: { id: true, checksum: true, + deletedAt: true, }, where: { ownerId, diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 9902f04d9bfcf..f5933915ce241 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -55,7 +55,7 @@ export class MetadataRepository implements IMetadataRepository { } } - @GenerateSql({ params: [DummyValue.UUID] }) + @GenerateSql({ params: [[DummyValue.UUID]] }) async getCountries(userIds: string[]): Promise { const results = await this.exifRepository .createQueryBuilder('exif') @@ -68,7 +68,7 @@ export class MetadataRepository implements IMetadataRepository { return results.map(({ country }) => country).filter((item) => item !== ''); } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) async getStates(userIds: string[], country: string | undefined): Promise { const query = this.exifRepository .createQueryBuilder('exif') @@ -86,7 +86,7 @@ export class MetadataRepository implements IMetadataRepository { return result.map(({ state }) => state).filter((item) => item !== ''); } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, DummyValue.STRING] }) + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] }) async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise { const query = this.exifRepository .createQueryBuilder('exif') @@ -108,7 +108,7 @@ export class MetadataRepository implements IMetadataRepository { return results.map(({ city }) => city).filter((item) => item !== ''); } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) async getCameraMakes(userIds: string[], model: string | undefined): Promise { const query = this.exifRepository .createQueryBuilder('exif') @@ -125,7 +125,7 @@ export class MetadataRepository implements IMetadataRepository { return results.map(({ make }) => make).filter((item) => item !== ''); } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) async getCameraModels(userIds: string[], make: string | undefined): Promise { const query = this.exifRepository .createQueryBuilder('exif') diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 2f5192d84fcf6..9d6f0ff9cf547 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -589,8 +589,20 @@ describe(AssetMediaService.name, () => { }), ).resolves.toEqual({ results: [ - { id: '1', assetId: 'asset-1', action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE }, - { id: '2', assetId: 'asset-2', action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE }, + { + id: '1', + assetId: 'asset-1', + action: AssetUploadAction.REJECT, + reason: AssetRejectReason.DUPLICATE, + isTrashed: false, + }, + { + id: '2', + assetId: 'asset-2', + action: AssetUploadAction.REJECT, + reason: AssetRejectReason.DUPLICATE, + isTrashed: false, + }, ], }); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 76c6b49716413..30fb878cd0fa5 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -289,10 +289,10 @@ export class AssetMediaService { async bulkUploadCheck(auth: AuthDto, dto: AssetBulkUploadCheckDto): Promise { const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum)); const results = await this.assetRepository.getByChecksums(auth.user.id, checksums); - const checksumMap: Record = {}; + const checksumMap: Record = {}; - for (const { id, checksum } of results) { - checksumMap[checksum.toString('hex')] = id; + for (const { id, deletedAt, checksum } of results) { + checksumMap[checksum.toString('hex')] = { id, isTrashed: !!deletedAt }; } return { @@ -301,14 +301,13 @@ export class AssetMediaService { if (duplicate) { return { id, - assetId: duplicate, action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE, + assetId: duplicate.id, + isTrashed: duplicate.isTrashed, }; } - // TODO mime-check - return { id, action: AssetUploadAction.ACCEPT, diff --git a/web/src/lib/components/shared-components/upload-asset-preview.svelte b/web/src/lib/components/shared-components/upload-asset-preview.svelte index a7ba3430a02ed..d0abf12ab560a 100644 --- a/web/src/lib/components/shared-components/upload-asset-preview.svelte +++ b/web/src/lib/components/shared-components/upload-asset-preview.svelte @@ -15,6 +15,7 @@ mdiLoading, mdiOpenInNew, mdiRestart, + mdiTrashCan, } from '@mdi/js'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -29,6 +30,10 @@ uploadAssetsStore.removeItem(uploadAsset.id); await fileUploadHandler([uploadAsset.file], uploadAsset.albumId); }; + + const asLink = (asset: UploadAsset) => { + return asset.isTrashed ? `${AppRoute.TRASH}/${asset.assetId}` : `${AppRoute.PHOTOS}/${uploadAsset.assetId}`; + };
{:else if uploadAsset.state === UploadState.DUPLICATED} - + {#if uploadAsset.isTrashed} + + {:else} + + {/if} {:else if uploadAsset.state === UploadState.DONE} {/if} @@ -56,7 +65,7 @@ {#if uploadAsset.state === UploadState.DUPLICATED && uploadAsset.assetId}
{ uploadAssetsStore.addItem({ id: 'asset-3', file: { name: 'asset3.jpg', size: 123_456 } as File }); uploadAssetsStore.updateItem('asset-3', { state: UploadState.DUPLICATED, assetId: 'asset-2' }); uploadAssetsStore.addItem({ id: 'asset-4', file: { name: 'asset3.jpg', size: 123_456 } as File }); - uploadAssetsStore.updateItem('asset-4', { state: UploadState.DONE }); + uploadAssetsStore.updateItem('asset-4', { state: UploadState.DUPLICATED, assetId: 'asset-2', isTrashed: true }); + uploadAssetsStore.addItem({ id: 'asset-10', file: { name: 'asset3.jpg', size: 123_456 } as File }); + uploadAssetsStore.updateItem('asset-10', { state: UploadState.DONE }); uploadAssetsStore.track('error'); uploadAssetsStore.track('success'); uploadAssetsStore.track('duplicate'); @@ -122,7 +124,7 @@ async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?: formData.append(key, value); } - let responseData: AssetMediaResponseDto | undefined; + let responseData: { id: string; status: AssetMediaStatus; isTrashed?: boolean } | undefined; const key = getKey(); if (crypto?.subtle?.digest && !key) { uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_hashing') }); @@ -138,7 +140,11 @@ async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?: results: [checkUploadResult], } = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: [{ id: assetFile.name, checksum }] } }); if (checkUploadResult.action === Action.Reject && checkUploadResult.assetId) { - responseData = { status: AssetMediaStatus.Duplicate, id: checkUploadResult.assetId }; + responseData = { + status: AssetMediaStatus.Duplicate, + id: checkUploadResult.assetId, + isTrashed: checkUploadResult.isTrashed, + }; } } catch (error) { console.error(`Error calculating sha1 file=${assetFile.name})`, error); @@ -185,6 +191,7 @@ async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?: uploadAssetsStore.updateItem(deviceAssetId, { state: responseData.status === AssetMediaStatus.Duplicate ? UploadState.DUPLICATED : UploadState.DONE, assetId: responseData.id, + isTrashed: responseData.isTrashed, }); if (responseData.status !== AssetMediaStatus.Duplicate) { From 8cf33690b8ddd8e36bdf5d968c3d5700bfcc2949 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 9 Sep 2024 16:03:30 -0400 Subject: [PATCH 03/16] fix(web): select partner assets from timeline (#12517) fix(web): add partner assets to album --- .../[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 239aa5bbb85f9..6e75273f3bc2c 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -128,7 +128,7 @@ const assetInteractionStore = createAssetInteractionStore(); const { isMultiSelectState, selectedAssets } = assetInteractionStore; - $: timelineStore = new AssetStore({ isArchived: false }, albumId); + $: timelineStore = new AssetStore({ isArchived: false, withPartners: true }, albumId); const timelineInteractionStore = createAssetInteractionStore(); const { selectedAssets: timelineSelected } = timelineInteractionStore; From 5c3283400f75ca7f863e8c2865cf5ca3aff76be5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Sep 2024 19:51:39 -0400 Subject: [PATCH 04/16] chore(deps): update dependency @faker-js/faker to v9 (#12519) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- web/package-lock.json | 13 +++++++------ web/package.json | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 0fe66f8832e7b..0bf82f26b7585 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -33,7 +33,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.8.0", - "@faker-js/faker": "^8.4.1", + "@faker-js/faker": "^9.0.0", "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/enhanced-img": "^0.3.0", @@ -746,9 +746,9 @@ } }, "node_modules/@faker-js/faker": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", - "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.0.0.tgz", + "integrity": "sha512-dTDHJSmz6c1OJ6HO7jiUiIb4sB20Dlkb3pxYsKm0qTXm2Bmj97rlXIhlvaFsW2rvCi+OLlwKLVSS6ZxFUVZvjQ==", "dev": true, "funding": [ { @@ -756,9 +756,10 @@ "url": "https://opencollective.com/fakerjs" } ], + "license": "MIT", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0", - "npm": ">=6.14.13" + "node": ">=18.0.0", + "npm": ">=9.0.0" } }, "node_modules/@formatjs/ecma402-abstract": { diff --git a/web/package.json b/web/package.json index 4dddc36e41c2e..b59835b80c7da 100644 --- a/web/package.json +++ b/web/package.json @@ -25,7 +25,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.8.0", - "@faker-js/faker": "^8.4.1", + "@faker-js/faker": "^9.0.0", "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/enhanced-img": "^0.3.0", From 0dd38c6ec1e6e6284082f543be24c23b10888fb9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 10 Sep 2024 00:40:11 +0000 Subject: [PATCH 05/16] chore(deps): update machine-learning (#12527) --- machine-learning/Dockerfile | 4 +-- machine-learning/export/Dockerfile | 2 +- machine-learning/poetry.lock | 52 +++++++++++++++--------------- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 12fb183c953d4..39a635c95f491 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:20c1819af5af3acba0b2b66074a2615e398ceee6842adf03cd7ad5f8d0ee3daf AS builder-cpu +FROM python:3.11-bookworm@sha256:401b3d8761ddcd1eb1dbb2137a46e3c6644bb49516d860ea455d264309d49d66 AS builder-cpu FROM builder-cpu AS builder-openvino @@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:ed4e985674f478c90ce879e9aa224fbb772c84e39b4aed5155b9e2280f131039 AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:3660ba2e7ed780bf14fdf5d859894ba94c69c987fb4104b3e72ee541e579c602 AS prod-cpu FROM prod-cpu AS prod-openvino diff --git a/machine-learning/export/Dockerfile b/machine-learning/export/Dockerfile index eaa35d14be0dd..85be083c3cebd 100644 --- a/machine-learning/export/Dockerfile +++ b/machine-learning/export/Dockerfile @@ -1,4 +1,4 @@ -FROM mambaorg/micromamba:bookworm-slim@sha256:29174348bd09352e5f1b1f6756cf1d00021487b8340fae040e91e4f98e954ce5 AS builder +FROM mambaorg/micromamba:bookworm-slim@sha256:b10f75974a30a6889b03519ac48d3e1510fd13d0689468c2c443033a15d84f1b AS builder ENV TRANSFORMERS_CACHE=/cache \ PYTHONDONTWRITEBYTECODE=1 \ diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index bd09bd8469e67..43e3e0ae362f1 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -1963,36 +1963,36 @@ reference = ["Pillow", "google-re2"] [[package]] name = "onnxruntime" -version = "1.19.0" +version = "1.19.2" description = "ONNX Runtime is a runtime accelerator for Machine Learning models" optional = false python-versions = "*" files = [ - {file = "onnxruntime-1.19.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6ce22a98dfec7b646ae305f52d0ce14a189a758b02ea501860ca719f4b0ae04b"}, - {file = "onnxruntime-1.19.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19019c72873f26927aa322c54cf2bf7312b23451b27451f39b88f57016c94f8b"}, - {file = "onnxruntime-1.19.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8eaa16df99171dc636e30108d15597aed8c4c2dd9dbfdd07cc464d57d73fb275"}, - {file = "onnxruntime-1.19.0-cp310-cp310-win32.whl", hash = "sha256:0eb0f8dbe596fd0f4737fe511fdbb17603853a7d204c5b2ca38d3c7808fc556b"}, - {file = "onnxruntime-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:616092d54ba8023b7bc0a5f6d900a07a37cc1cfcc631873c15f8c1d6e9e184d4"}, - {file = "onnxruntime-1.19.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a2b53b3c287cd933e5eb597273926e899082d8c84ab96e1b34035764a1627e17"}, - {file = "onnxruntime-1.19.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e94984663963e74fbb468bde9ec6f19dcf890b594b35e249c4dc8789d08993c5"}, - {file = "onnxruntime-1.19.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f379d1f050cfb55ce015d53727b78ee362febc065c38eed81512b22b757da73"}, - {file = "onnxruntime-1.19.0-cp311-cp311-win32.whl", hash = "sha256:4ccb48faea02503275ae7e79e351434fc43c294c4cb5c4d8bcb7479061396614"}, - {file = "onnxruntime-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:9cdc8d311289a84e77722de68bd22b8adfb94eea26f4be6f9e017350faac8b18"}, - {file = "onnxruntime-1.19.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1b59eaec1be9a8613c5fdeaafe67f73a062edce3ac03bbbdc9e2d98b58a30617"}, - {file = "onnxruntime-1.19.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be4144d014a4b25184e63ce7a463a2e7796e2f3df931fccc6a6aefa6f1365dc5"}, - {file = "onnxruntime-1.19.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10d7e7d4ca7021ce7f29a66dbc6071addf2de5839135339bd855c6d9c2bba371"}, - {file = "onnxruntime-1.19.0-cp312-cp312-win32.whl", hash = "sha256:87f2c58b577a1fb31dc5d92b647ecc588fd5f1ea0c3ad4526f5f80a113357c8d"}, - {file = "onnxruntime-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:8a1f50d49676d7b69566536ff039d9e4e95fc482a55673719f46528218ecbb94"}, - {file = "onnxruntime-1.19.0-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:71423c8c4b2d7a58956271534302ec72721c62a41efd0c4896343249b8399ab0"}, - {file = "onnxruntime-1.19.0-cp38-cp38-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d63630d45e9498f96e75bbeb7fd4a56acb10155de0de4d0e18d1b6cbb0b358a"}, - {file = "onnxruntime-1.19.0-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3bfd15db1e8794d379a86c1a9116889f47f2cca40cc82208fc4f7e8c38e8522"}, - {file = "onnxruntime-1.19.0-cp38-cp38-win32.whl", hash = "sha256:3b098003b6b4cb37cc84942e5f1fe27f945dd857cbd2829c824c26b0ba4a247e"}, - {file = "onnxruntime-1.19.0-cp38-cp38-win_amd64.whl", hash = "sha256:cea067a6541d6787d903ee6843401c5b1332a266585160d9700f9f0939443886"}, - {file = "onnxruntime-1.19.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:c4fcff12dc5ca963c5f76b9822bb404578fa4a98c281e8c666b429192799a099"}, - {file = "onnxruntime-1.19.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6dcad8a4db908fbe70b98c79cea1c8b6ac3316adf4ce93453136e33a524ac59"}, - {file = "onnxruntime-1.19.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bc449907c6e8d99eee5ae5cc9c8fdef273d801dcd195393d3f9ab8ad3f49522"}, - {file = "onnxruntime-1.19.0-cp39-cp39-win32.whl", hash = "sha256:947febd48405afcf526e45ccff97ff23b15e530434705f734870d22ae7fcf236"}, - {file = "onnxruntime-1.19.0-cp39-cp39-win_amd64.whl", hash = "sha256:f60be47eff5ee77fd28a466b0fd41d7debc42a32179d1ddb21e05d6067d7b48b"}, + {file = "onnxruntime-1.19.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:84fa57369c06cadd3c2a538ae2a26d76d583e7c34bdecd5769d71ca5c0fc750e"}, + {file = "onnxruntime-1.19.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdc471a66df0c1cdef774accef69e9f2ca168c851ab5e4f2f3341512c7ef4666"}, + {file = "onnxruntime-1.19.2-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e3a4ce906105d99ebbe817f536d50a91ed8a4d1592553f49b3c23c4be2560ae6"}, + {file = "onnxruntime-1.19.2-cp310-cp310-win32.whl", hash = "sha256:4b3d723cc154c8ddeb9f6d0a8c0d6243774c6b5930847cc83170bfe4678fafb3"}, + {file = "onnxruntime-1.19.2-cp310-cp310-win_amd64.whl", hash = "sha256:17ed7382d2c58d4b7354fb2b301ff30b9bf308a1c7eac9546449cd122d21cae5"}, + {file = "onnxruntime-1.19.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d863e8acdc7232d705d49e41087e10b274c42f09e259016a46f32c34e06dc4fd"}, + {file = "onnxruntime-1.19.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dfe4f660a71b31caa81fc298a25f9612815215a47b286236e61d540350d7b6"}, + {file = "onnxruntime-1.19.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a36511dc07c5c964b916697e42e366fa43c48cdb3d3503578d78cef30417cb84"}, + {file = "onnxruntime-1.19.2-cp311-cp311-win32.whl", hash = "sha256:50cbb8dc69d6befad4746a69760e5b00cc3ff0a59c6c3fb27f8afa20e2cab7e7"}, + {file = "onnxruntime-1.19.2-cp311-cp311-win_amd64.whl", hash = "sha256:1c3e5d415b78337fa0b1b75291e9ea9fb2a4c1f148eb5811e7212fed02cfffa8"}, + {file = "onnxruntime-1.19.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:68e7051bef9cfefcbb858d2d2646536829894d72a4130c24019219442b1dd2ed"}, + {file = "onnxruntime-1.19.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2d366fbcc205ce68a8a3bde2185fd15c604d9645888703785b61ef174265168"}, + {file = "onnxruntime-1.19.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:477b93df4db467e9cbf34051662a4b27c18e131fa1836e05974eae0d6e4cf29b"}, + {file = "onnxruntime-1.19.2-cp312-cp312-win32.whl", hash = "sha256:9a174073dc5608fad05f7cf7f320b52e8035e73d80b0a23c80f840e5a97c0147"}, + {file = "onnxruntime-1.19.2-cp312-cp312-win_amd64.whl", hash = "sha256:190103273ea4507638ffc31d66a980594b237874b65379e273125150eb044857"}, + {file = "onnxruntime-1.19.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:636bc1d4cc051d40bc52e1f9da87fbb9c57d9d47164695dfb1c41646ea51ea66"}, + {file = "onnxruntime-1.19.2-cp38-cp38-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bd8b875757ea941cbcfe01582970cc299893d1b65bd56731e326a8333f638a3"}, + {file = "onnxruntime-1.19.2-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2046fc9560f97947bbc1acbe4c6d48585ef0f12742744307d3364b131ac5778"}, + {file = "onnxruntime-1.19.2-cp38-cp38-win32.whl", hash = "sha256:31c12840b1cde4ac1f7d27d540c44e13e34f2345cf3642762d2a3333621abb6a"}, + {file = "onnxruntime-1.19.2-cp38-cp38-win_amd64.whl", hash = "sha256:016229660adea180e9a32ce218b95f8f84860a200f0f13b50070d7d90e92956c"}, + {file = "onnxruntime-1.19.2-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:006c8d326835c017a9e9f74c9c77ebb570a71174a1e89fe078b29a557d9c3848"}, + {file = "onnxruntime-1.19.2-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df2a94179a42d530b936f154615b54748239c2908ee44f0d722cb4df10670f68"}, + {file = "onnxruntime-1.19.2-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fae4b4de45894b9ce7ae418c5484cbf0341db6813effec01bb2216091c52f7fb"}, + {file = "onnxruntime-1.19.2-cp39-cp39-win32.whl", hash = "sha256:dc5430f473e8706fff837ae01323be9dcfddd3ea471c900a91fa7c9b807ec5d3"}, + {file = "onnxruntime-1.19.2-cp39-cp39-win_amd64.whl", hash = "sha256:38475e29a95c5f6c62c2c603d69fc7d4c6ccbf4df602bd567b86ae1138881c49"}, ] [package.dependencies] From 009a1402e64dde87bee1f74d3838b311d83be2e2 Mon Sep 17 00:00:00 2001 From: Ben <45583362+ben-basten@users.noreply.github.com> Date: Mon, 9 Sep 2024 23:37:53 -0400 Subject: [PATCH 06/16] fix(web): clip scrollbar overflow in modals (#12526) --- .../full-screen-modal.svelte | 30 +++++++++++-------- .../shared-components/modal-header.svelte | 2 +- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/web/src/lib/components/shared-components/full-screen-modal.svelte b/web/src/lib/components/shared-components/full-screen-modal.svelte index be407decded14..228d560cab4de 100644 --- a/web/src/lib/components/shared-components/full-screen-modal.svelte +++ b/web/src/lib/components/shared-components/full-screen-modal.svelte @@ -55,24 +55,28 @@ use:focusTrap >
- -
- -
- {#if isStickyBottom} -
- +
+ +
+
- {/if} + {#if isStickyBottom} +
+ +
+ {/if} +
diff --git a/web/src/lib/components/shared-components/modal-header.svelte b/web/src/lib/components/shared-components/modal-header.svelte index 59c62e0a97638..8c81cebe1a6ad 100644 --- a/web/src/lib/components/shared-components/modal-header.svelte +++ b/web/src/lib/components/shared-components/modal-header.svelte @@ -21,7 +21,7 @@ export let icon: string | undefined = undefined; -
+
{#if showLogo} From 6674d67abe7a821c4d4ed6b53bc8c6ed92cbd5a8 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 9 Sep 2024 23:49:41 -0400 Subject: [PATCH 07/16] docs: more cursed knowledge (#12529) --- docs/src/pages/cursed-knowledge.tsx | 51 ++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/src/pages/cursed-knowledge.tsx b/docs/src/pages/cursed-knowledge.tsx index 638868bec5324..55bb3d4cee193 100644 --- a/docs/src/pages/cursed-knowledge.tsx +++ b/docs/src/pages/cursed-knowledge.tsx @@ -1,16 +1,20 @@ import { + mdiBug, mdiCalendarToday, mdiCrosshairsOff, + mdiDatabase, mdiLeadPencil, mdiLockOff, mdiLockOutline, + mdiSecurity, mdiSpeedometerSlow, + mdiTrashCan, mdiWeb, mdiWrap, } from '@mdi/js'; import Layout from '@theme/Layout'; import React from 'react'; -import { Item as TimelineItem, Timeline } from '../components/timeline'; +import { Timeline, Item as TimelineItem } from '../components/timeline'; const withLanguage = (date: Date) => (language: string) => date.toLocaleDateString(language); @@ -96,6 +100,51 @@ const items: Item[] = [ link: { url: 'https://github.com/immich-app/immich/pull/6787', text: '#6787' }, date: new Date(2024, 0, 31), }, + { + icon: mdiBug, + iconColor: 'green', + title: 'ESM imports are cursed', + description: + 'Prior to Node.js v20.8 using --experimental-vm-modules in a CommonJS project that imported an ES module that imported a CommonJS modules would create a segfault and crash Node.js', + link: { + url: 'https://github.com/immich-app/immich/pull/6719', + text: '#6179', + }, + date: new Date(2024, 0, 9), + }, + { + icon: mdiDatabase, + iconColor: 'gray', + title: 'PostgreSQL parameters are cursed', + description: `PostgresSQL has a limit of ${Number(65535).toLocaleString()} parameters, so bulk inserts can fail with large datasets.`, + link: { + url: 'https://github.com/immich-app/immich/pull/6034', + text: '#6034', + }, + date: new Date(2023, 11, 28), + }, + { + icon: mdiSecurity, + iconColor: 'gold', + title: 'Secure contexts are cursed', + description: `Some web features like the clipboard API only work in "secure contexts" (ie. https or localhost)`, + link: { + url: 'https://github.com/immich-app/immich/issues/2981', + text: '#2981', + }, + date: new Date(2023, 5, 26), + }, + { + icon: mdiTrashCan, + iconColor: 'gray', + title: 'TypeORM deletes are cursed', + description: `The remove implementation in TypeORM mutates the input, deleting the id property from the original object.`, + link: { + url: 'https://github.com/typeorm/typeorm/issues/7024#issuecomment-948519328', + text: 'typeorm#6034', + }, + date: new Date(2023, 1, 23), + }, ]; export default function CursedKnowledgePage(): JSX.Element { From 710cbd694b4a69393e035af3624442553b9ea5d7 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 9 Sep 2024 23:49:56 -0400 Subject: [PATCH 08/16] fix(web): preserve search text (#12531) --- .../search-bar/search-filter-box.svelte | 18 +++++---- .../search-bar/search-text-section.svelte | 37 ++++--------------- 2 files changed, 19 insertions(+), 36 deletions(-) diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte index 4fd85fa9bdf4f..45e9393ed446f 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte @@ -10,8 +10,8 @@ } export type SearchFilter = { - context?: string; - filename?: string; + query: string; + queryType: 'smart' | 'metadata'; personIds: Set; location: SearchLocationFilter; camera: SearchCameraFilter; @@ -48,8 +48,8 @@ } let filter: SearchFilter = { - context: 'query' in searchQuery ? searchQuery.query : '', - filename: 'originalFileName' in searchQuery ? searchQuery.originalFileName : undefined, + query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '', + queryType: 'query' in searchQuery ? 'smart' : 'metadata', personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []), location: { country: withNullAsUndefined(searchQuery.country), @@ -79,6 +79,8 @@ const resetForm = () => { filter = { + query: '', + queryType: 'smart', personIds: new Set(), location: {}, camera: {}, @@ -96,9 +98,11 @@ type = AssetTypeEnum.Video; } + const query = filter.query || undefined; + let payload: SmartSearchDto | MetadataSearchDto = { - query: filter.context || undefined, - originalFileName: filter.filename, + query: filter.queryType === 'smart' ? query : undefined, + originalFileName: filter.queryType === 'metadata' ? query : undefined, country: filter.location.country, state: filter.location.state, city: filter.location.city, @@ -132,7 +136,7 @@ - + diff --git a/web/src/lib/components/shared-components/search-bar/search-text-section.svelte b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte index e2b74b8388fdb..c3145b2f0c16d 100644 --- a/web/src/lib/components/shared-components/search-bar/search-text-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte @@ -2,46 +2,25 @@ import RadioButton from '$lib/components/elements/radio-button.svelte'; import { t } from 'svelte-i18n'; - export let filename: string | undefined; - export let context: string | undefined; - - enum TextSearchOptions { - Context = 'context', - Filename = 'filename', - } - - let selectedOption = filename ? TextSearchOptions.Filename : TextSearchOptions.Context; - - $: { - if (selectedOption === TextSearchOptions.Context) { - filename = undefined; - } else { - context = undefined; - } - } + export let query: string | undefined; + export let queryType: 'smart' | 'metadata' = 'smart';
{$t('search_type')}
- +
-{#if selectedOption === TextSearchOptions.Context} +{#if queryType === 'smart'} {:else} @@ -59,7 +38,7 @@ id="file-name-input" name="file-name" placeholder={$t('search_by_filename_example')} - bind:value={filename} + bind:value={query} aria-labelledby="file-name-label" /> {/if} From 2c639d7fe4391b0ad03f5b62a24e44c331d73919 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 9 Sep 2024 23:50:09 -0400 Subject: [PATCH 09/16] fix(web): show upload error message on network error (#12533) --- web/src/lib/utils/file-uploader.ts | 7 +++---- web/src/lib/utils/handle-error.ts | 10 ++++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index d5b700fd6fdcb..2e31605e91605 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -15,7 +15,7 @@ import { import { tick } from 'svelte'; import { t } from 'svelte-i18n'; import { get } from 'svelte/store'; -import { getServerErrorMessage, handleError } from './handle-error'; +import { handleError } from './handle-error'; export const addDummyItems = () => { uploadAssetsStore.addItem({ id: 'asset-0', file: { name: 'asset0.jpg', size: 123_456 } as File }); @@ -202,10 +202,9 @@ async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?: return responseData.id; } catch (error) { - handleError(error, $t('errors.unable_to_upload_file')); - const reason = getServerErrorMessage(error) || error; + const errorMessage = handleError(error, $t('errors.unable_to_upload_file')); uploadAssetsStore.track('error'); - uploadAssetsStore.updateItem(deviceAssetId, { state: UploadState.ERROR, error: reason }); + uploadAssetsStore.updateItem(deviceAssetId, { state: UploadState.ERROR, error: errorMessage }); return; } } diff --git a/web/src/lib/utils/handle-error.ts b/web/src/lib/utils/handle-error.ts index 6353a2049af65..9ca5bc8773e34 100644 --- a/web/src/lib/utils/handle-error.ts +++ b/web/src/lib/utils/handle-error.ts @@ -20,11 +20,13 @@ export function handleError(error: unknown, message: string) { serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`; } - notificationController.show({ - message: serverMessage || message, - type: NotificationType.Error, - }); + const errorMessage = serverMessage || message; + + notificationController.show({ message: errorMessage, type: NotificationType.Error }); + + return errorMessage; } catch (error) { console.error(error); + return message; } } From 3127636c4218922b310cb47ec37ef1b3755fd868 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 9 Sep 2024 23:54:24 -0400 Subject: [PATCH 10/16] fix(server): handle invalid directory item (#12534) --- server/src/services/metadata.service.spec.ts | 10 ++++++++++ server/src/services/metadata.service.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 8e865bd20fd90..ad07c2595f02d 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -520,6 +520,16 @@ describe(MetadataService.name, () => { ); }); + it('should handle an invalid Directory Item', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ + MotionPhoto: 1, + ContainerDirectory: [{ Foo: 100 }], + }); + + await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); + }); + it('should extract the correct video orientation', async () => { assetMock.getByIds.mockResolvedValue([assetStub.video]); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 83f0abd79bdf8..9a4362dacab42 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -428,7 +428,7 @@ export class MetadataService { if (isMotionPhoto && directory) { for (const entry of directory) { - if (entry.Item.Semantic == 'MotionPhoto') { + if (entry?.Item?.Semantic == 'MotionPhoto') { length = entry.Item.Length ?? 0; padding = entry.Item.Padding ?? 0; break; From f2f6713a53097cf6bd4a5bb8aae280d4fd9ed7d7 Mon Sep 17 00:00:00 2001 From: Jonathan Simon Date: Tue, 10 Sep 2024 00:07:56 -0400 Subject: [PATCH 11/16] fix: typo in es-US localization (#12510) Fix typo in es-US localization search_page_motion_photos string should be 'Fotos en movimiento' not 'Fotos en .ovimiento' --- mobile/assets/i18n/es-US.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/assets/i18n/es-US.json b/mobile/assets/i18n/es-US.json index ea0b328a808d4..8cfae94c005d1 100644 --- a/mobile/assets/i18n/es-US.json +++ b/mobile/assets/i18n/es-US.json @@ -414,7 +414,7 @@ "search_filter_people_title": "Select people", "search_page_categories": "Categorías", "search_page_favorites": "Favoritos", - "search_page_motion_photos": "Fotos en .ovimiento", + "search_page_motion_photos": "Fotos en movimiento", "search_page_no_objects": "No hay información de objetos disponible", "search_page_no_places": "No hay información de lugares disponible", "search_page_people": "Personas", @@ -589,4 +589,4 @@ "viewer_remove_from_stack": "Eliminar de la pila", "viewer_stack_use_as_main_asset": "Utilizar como recurso principal", "viewer_unstack": "Desapilar" -} \ No newline at end of file +} From 02047a0104602439b4b22b6b7ae3d6d28ffa8bd5 Mon Sep 17 00:00:00 2001 From: Ben <45583362+ben-basten@users.noreply.github.com> Date: Tue, 10 Sep 2024 00:12:26 -0400 Subject: [PATCH 12/16] feat(web): move search options into a modal (#12438) * feat(web): move search options into a modal * chore: revert adding focus ring * minor styling --------- Co-authored-by: Alex Tran --- .../full-screen-modal.svelte | 31 ++++++++++---- .../shared-components/modal-header.svelte | 2 +- .../search-bar/search-bar.svelte | 8 ++-- ...-box.svelte => search-filter-modal.svelte} | 42 ++++++++----------- .../search-bar/search-media-section.svelte | 2 +- web/src/lib/i18n/en.json | 1 + 6 files changed, 46 insertions(+), 40 deletions(-) rename web/src/lib/components/shared-components/search-bar/{search-filter-box.svelte => search-filter-modal.svelte} (83%) diff --git a/web/src/lib/components/shared-components/full-screen-modal.svelte b/web/src/lib/components/shared-components/full-screen-modal.svelte index 228d560cab4de..2cecdf74e8424 100644 --- a/web/src/lib/components/shared-components/full-screen-modal.svelte +++ b/web/src/lib/components/shared-components/full-screen-modal.svelte @@ -22,7 +22,7 @@ * - `narrow`: 28rem * - `auto`: fits the width of the modal content, up to a maximum of 32rem */ - export let width: 'wide' | 'narrow' | 'auto' = 'narrow'; + export let width: 'extra-wide' | 'wide' | 'narrow' | 'auto' = 'narrow'; /** * Unique identifier for the modal. @@ -34,12 +34,25 @@ let modalWidth: string; $: { - if (width === 'wide') { - modalWidth = 'w-[48rem]'; - } else if (width === 'narrow') { - modalWidth = 'w-[28rem]'; - } else { - modalWidth = 'sm:max-w-4xl'; + switch (width) { + case 'extra-wide': { + modalWidth = 'w-[56rem]'; + break; + } + + case 'wide': { + modalWidth = 'w-[48rem]'; + break; + } + + case 'narrow': { + modalWidth = 'w-[28rem]'; + break; + } + + default: { + modalWidth = 'sm:max-w-4xl'; + } } } @@ -62,7 +75,7 @@ aria-labelledby={titleId} >
@@ -72,7 +85,7 @@
{#if isStickyBottom}
diff --git a/web/src/lib/components/shared-components/modal-header.svelte b/web/src/lib/components/shared-components/modal-header.svelte index 8c81cebe1a6ad..efd87b476cb68 100644 --- a/web/src/lib/components/shared-components/modal-header.svelte +++ b/web/src/lib/components/shared-components/modal-header.svelte @@ -26,7 +26,7 @@ {#if showLogo} {:else if icon} - + {/if}

{title} diff --git a/web/src/lib/components/shared-components/search-bar/search-bar.svelte b/web/src/lib/components/shared-components/search-bar/search-bar.svelte index 07d4df6e66f7e..b0bbdbe71fcb1 100644 --- a/web/src/lib/components/shared-components/search-bar/search-bar.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-bar.svelte @@ -4,7 +4,7 @@ import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store'; import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js'; import SearchHistoryBox from './search-history-box.svelte'; - import SearchFilterBox from './search-filter-box.svelte'; + import SearchFilterModal from './search-filter-modal.svelte'; import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk'; import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; import { handlePromiseError } from '$lib/utils'; @@ -160,8 +160,8 @@ id="main-search-bar" class="w-full transition-all border-2 px-14 py-4 text-immich-fg/75 dark:text-immich-dark-fg {grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'} - {(showSuggestions && isSearchSuggestions) || showFilter ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'} - {$isSearchEnabled ? 'border-gray-200 dark:border-gray-700 bg-white' : 'border-transparent'}" + {showSuggestions && isSearchSuggestions ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'} + {$isSearchEnabled && !showFilter ? 'border-gray-200 dark:border-gray-700 bg-white' : 'border-transparent'}" placeholder={$t('search_your_photos')} required pattern="^(?!m:$).*$" @@ -215,6 +215,6 @@ {#if showFilter} - onSearch(detail)} /> + onSearch(payload)} onClose={() => (showFilter = false)} /> {/if}

diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte similarity index 83% rename from web/src/lib/components/shared-components/search-bar/search-filter-box.svelte rename to web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte index 45e9393ed446f..3ec539ad976b4 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte @@ -24,8 +24,6 @@ -
-
-
+ + +
@@ -147,7 +142,7 @@ -
+
@@ -155,13 +150,10 @@
- -
- - -
-
+ + + + + +
diff --git a/web/src/lib/components/shared-components/search-bar/search-media-section.svelte b/web/src/lib/components/shared-components/search-bar/search-media-section.svelte index ce43dd0141ef8..b78868d6146c9 100644 --- a/web/src/lib/components/shared-components/search-bar/search-media-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-media-section.svelte @@ -1,6 +1,6 @@ From 12bfb198524071fbf0f3e90c6ad6d3b90325e420 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 10 Sep 2024 00:26:11 -0400 Subject: [PATCH 14/16] chore(deps): update machine-learning (#12535) --- machine-learning/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 39a635c95f491..baeefbf0d8064 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:401b3d8761ddcd1eb1dbb2137a46e3c6644bb49516d860ea455d264309d49d66 AS builder-cpu +FROM python:3.11-bookworm@sha256:3cd9b520be95c671135ea1318f32be6912876024ee16d0f472669d3878801651 AS builder-cpu FROM builder-cpu AS builder-openvino @@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:3660ba2e7ed780bf14fdf5d859894ba94c69c987fb4104b3e72ee541e579c602 AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:50ec89bdac0a845ec1751f91cb6187a3d8adb2b919d6e82d17acf48d1a9743fc AS prod-cpu FROM prod-cpu AS prod-openvino From 27050af57b8bc0514fa387d5df17036c50505079 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 10 Sep 2024 08:51:11 -0400 Subject: [PATCH 15/16] feat(web): manually link live photos (#12514) feat(web,server): manually link live photos --- e2e/src/api/specs/asset.e2e-spec.ts | 32 ++++++++++++++ .../openapi/lib/model/update_asset_dto.dart | 19 +++++++- open-api/immich-openapi-specs.json | 4 ++ open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/dtos/asset.dto.ts | 3 ++ server/src/interfaces/event.interface.ts | 3 +- server/src/services/asset-media.service.ts | 20 +++------ server/src/services/asset.service.ts | 10 ++++- server/src/services/metadata.service.spec.ts | 11 +++-- server/src/services/metadata.service.ts | 5 +-- server/src/services/notification.service.ts | 6 +++ server/src/utils/asset.util.ts | 26 ++++++++++- .../actions/link-live-photo-action.svelte | 44 +++++++++++++++++++ web/src/lib/i18n/en.json | 1 + web/src/lib/utils/actions.ts | 1 + .../(user)/photos/[[assetId=id]]/+page.svelte | 28 ++++++++---- 16 files changed, 178 insertions(+), 36 deletions(-) create mode 100644 web/src/lib/components/photos-page/actions/link-live-photo-action.svelte diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 7d3c3c6e59ad2..e065e60c993d8 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -545,6 +545,38 @@ describe('/asset', () => { expect(status).toEqual(200); }); + it('should not allow linking two photos', async () => { + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ livePhotoVideoId: user1Assets[1].id }); + + expect(body).toEqual(errorDto.badRequest('Live photo video must be a video')); + expect(status).toEqual(400); + }); + + it('should not allow linking a video owned by another user', async () => { + const asset = await utils.createAsset(user2.accessToken, { assetData: { filename: 'example.mp4' } }); + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ livePhotoVideoId: asset.id }); + + expect(body).toEqual(errorDto.badRequest('Live photo video does not belong to the user')); + expect(status).toEqual(400); + }); + + it('should link a motion photo', async () => { + const asset = await utils.createAsset(user1.accessToken, { assetData: { filename: 'example.mp4' } }); + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ livePhotoVideoId: asset.id }); + + expect(status).toEqual(200); + expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: asset.id }); + }); + it('should update date time original when sidecar file contains DateTimeOriginal', async () => { const sidecarData = ` diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 391836c444bb3..6e5be5683f484 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -18,6 +18,7 @@ class UpdateAssetDto { this.isArchived, this.isFavorite, this.latitude, + this.livePhotoVideoId, this.longitude, this.rating, }); @@ -62,6 +63,14 @@ class UpdateAssetDto { /// num? latitude; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? livePhotoVideoId; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -87,6 +96,7 @@ class UpdateAssetDto { other.isArchived == isArchived && other.isFavorite == isFavorite && other.latitude == latitude && + other.livePhotoVideoId == livePhotoVideoId && other.longitude == longitude && other.rating == rating; @@ -98,11 +108,12 @@ class UpdateAssetDto { (isArchived == null ? 0 : isArchived!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) + + (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) + (rating == null ? 0 : rating!.hashCode); @override - String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating]'; + String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, longitude=$longitude, rating=$rating]'; Map toJson() { final json = {}; @@ -131,6 +142,11 @@ class UpdateAssetDto { } else { // json[r'latitude'] = null; } + if (this.livePhotoVideoId != null) { + json[r'livePhotoVideoId'] = this.livePhotoVideoId; + } else { + // json[r'livePhotoVideoId'] = null; + } if (this.longitude != null) { json[r'longitude'] = this.longitude; } else { @@ -157,6 +173,7 @@ class UpdateAssetDto { isArchived: mapValueOfType(json, r'isArchived'), isFavorite: mapValueOfType(json, r'isFavorite'), latitude: num.parse('${json[r'latitude']}'), + livePhotoVideoId: mapValueOfType(json, r'livePhotoVideoId'), longitude: num.parse('${json[r'longitude']}'), rating: num.parse('${json[r'rating']}'), ); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 19d6b5055660b..b80bb52a11383 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -12241,6 +12241,10 @@ "latitude": { "type": "number" }, + "livePhotoVideoId": { + "format": "uuid", + "type": "string" + }, "longitude": { "type": "number" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 2afdf083433b2..7cf4d48eda66b 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -427,6 +427,7 @@ export type UpdateAssetDto = { isArchived?: boolean; isFavorite?: boolean; latitude?: number; + livePhotoVideoId?: string; longitude?: number; rating?: number; }; diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 5a2fdb51200d7..02ea2c69a990e 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -68,6 +68,9 @@ export class UpdateAssetDto extends UpdateAssetBase { @Optional() @IsString() description?: string; + + @ValidateUUID({ optional: true }) + livePhotoVideoId?: string; } export class RandomAssetsDto { diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index ec6e776f5992b..61233a8001eb3 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -17,9 +17,10 @@ type EmitEventMap = { 'album.update': [{ id: string; updatedBy: string }]; 'album.invite': [{ id: string; userId: string }]; - // tag events + // asset events 'asset.tag': [{ assetId: string }]; 'asset.untag': [{ assetId: string }]; + 'asset.hide': [{ assetId: string; userId: string }]; // session events 'session.delete': [{ sessionId: string }]; diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 30fb878cd0fa5..111d222c160c8 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -36,7 +36,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { requireAccess, requireUploadAccess } from 'src/utils/access'; -import { getAssetFiles } from 'src/utils/asset.util'; +import { getAssetFiles, onBeforeLink } from 'src/utils/asset.util'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { fromChecksum } from 'src/utils/request'; @@ -158,20 +158,10 @@ export class AssetMediaService { this.requireQuota(auth, file.size); if (dto.livePhotoVideoId) { - const motionAsset = await this.assetRepository.getById(dto.livePhotoVideoId); - if (!motionAsset) { - throw new BadRequestException('Live photo video not found'); - } - if (motionAsset.type !== AssetType.VIDEO) { - throw new BadRequestException('Live photo video must be a video'); - } - if (motionAsset.ownerId !== auth.user.id) { - throw new BadRequestException('Live photo video does not belong to the user'); - } - if (motionAsset.isVisible) { - await this.assetRepository.update({ id: motionAsset.id, isVisible: false }); - this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, auth.user.id, motionAsset.id); - } + await onBeforeLink( + { asset: this.assetRepository, event: this.eventRepository }, + { userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId }, + ); } const asset = await this.create(auth.user.id, dto, file, sidecarFile); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index bfd3a0c4d26b5..ecc9a135759da 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -39,7 +39,7 @@ import { IStackRepository } from 'src/interfaces/stack.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { requireAccess } from 'src/utils/access'; -import { getAssetFiles, getMyPartnerIds } from 'src/utils/asset.util'; +import { getAssetFiles, getMyPartnerIds, onBeforeLink } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; export class AssetService { @@ -159,6 +159,14 @@ export class AssetService { await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; + + if (rest.livePhotoVideoId) { + await onBeforeLink( + { asset: this.assetRepository, event: this.eventRepository }, + { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId }, + ); + } + await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); await this.assetRepository.update({ id, ...rest }); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index ad07c2595f02d..114c3db8ab6e8 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -8,7 +8,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMapRepository } from 'src/interfaces/map.interface'; @@ -220,11 +220,10 @@ describe(MetadataService.name, () => { await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( JobStatus.SUCCESS, ); - expect(eventMock.clientSend).toHaveBeenCalledWith( - ClientEvent.ASSET_HIDDEN, - assetStub.livePhotoMotionAsset.ownerId, - assetStub.livePhotoMotionAsset.id, - ); + expect(eventMock.emit).toHaveBeenCalledWith('asset.hide', { + userId: assetStub.livePhotoMotionAsset.ownerId, + assetId: assetStub.livePhotoMotionAsset.id, + }); }); it('should search by libraryId', async () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 9a4362dacab42..0522c883dd1f3 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -17,7 +17,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { ArgOf, IEventRepository } from 'src/interfaces/event.interface'; import { IBaseJob, IEntityJob, @@ -186,8 +186,7 @@ export class MetadataService { await this.assetRepository.update({ id: motionAsset.id, isVisible: false }); await this.albumRepository.removeAsset(motionAsset.id); - // Notify clients to hide the linked live photo asset - this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id); + await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId }); return JobStatus.SUCCESS; } diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index d450f8dc759a2..b1c862dc1225c 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -58,6 +58,12 @@ export class NotificationService { } } + @OnEmit({ event: 'asset.hide' }) + onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) { + // Notify clients to hide the linked live photo asset + this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId); + } + @OnEmit({ event: 'user.signup' }) async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) { if (notify) { diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 26d5f9292ebeb..f2a03a9dcb159 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -1,8 +1,11 @@ +import { BadRequestException } from '@nestjs/common'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; -import { AssetFileType, Permission } from 'src/enum'; +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 { checkAccess } from 'src/utils/access'; @@ -130,3 +133,24 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P return [...partnerIds]; }; + +export const onBeforeLink = async ( + { asset: assetRepository, event: eventRepository }: { asset: IAssetRepository; event: IEventRepository }, + { userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string }, +) => { + const motionAsset = await assetRepository.getById(livePhotoVideoId); + if (!motionAsset) { + throw new BadRequestException('Live photo video not found'); + } + if (motionAsset.type !== AssetType.VIDEO) { + throw new BadRequestException('Live photo video must be a video'); + } + if (motionAsset.ownerId !== userId) { + throw new BadRequestException('Live photo video does not belong to the user'); + } + + if (motionAsset?.isVisible) { + await assetRepository.update({ id: livePhotoVideoId, isVisible: false }); + await eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId }); + } +}; diff --git a/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte b/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte new file mode 100644 index 0000000000000..fa33b7d5ccd11 --- /dev/null +++ b/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte @@ -0,0 +1,44 @@ + + +{#if menuItem} + +{/if} + +{#if !menuItem} + {#if loading} + + {:else} + + {/if} +{/if} diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index ee6aaba358317..dbd6f32fde7c5 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -785,6 +785,7 @@ "library_options": "Library options", "light": "Light", "like_deleted": "Like deleted", + "link_motion_video": "Link motion video", "link_options": "Link options", "link_to_oauth": "Link to OAuth", "linked_oauth_account": "Linked OAuth account", diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts index 75232e793d6a0..f1772c200e15a 100644 --- a/web/src/lib/utils/actions.ts +++ b/web/src/lib/utils/actions.ts @@ -6,6 +6,7 @@ import { handleError } from './handle-error'; export type OnDelete = (assetIds: string[]) => void; export type OnRestore = (ids: string[]) => void; +export type OnLink = (asset: AssetResponseDto) => void; export type OnArchive = (ids: string[], isArchived: boolean) => void; export type OnFavorite = (ids: string[], favorite: boolean) => void; export type OnStack = (ids: string[]) => void; diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 70e74f84f17b3..a1131ecfbb16c 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -2,30 +2,32 @@ import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; + import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte'; import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte'; import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte'; - import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; - import StackAction from '$lib/components/photos-page/actions/stack-action.svelte'; + import LinkLivePhotoAction from '$lib/components/photos-page/actions/link-live-photo-action.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; + import StackAction from '$lib/components/photos-page/actions/stack-action.svelte'; + import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; - import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import MemoryLane from '$lib/components/photos-page/memory-lane.svelte'; + import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import { AssetAction } from '$lib/constants'; import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; - import { AssetStore } from '$lib/stores/assets.store'; - import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import { mdiDotsVertical, mdiPlus } from '@mdi/js'; + import { AssetStore } from '$lib/stores/assets.store'; import { preferences, user } from '$lib/stores/user.store'; - import { t } from 'svelte-i18n'; + import { openFileUploadDialog } from '$lib/utils/file-uploader'; + import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; + import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import { onDestroy } from 'svelte'; - import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; + import { t } from 'svelte-i18n'; let { isViewing: showAssetViewer } = assetViewingStore; const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true }); @@ -51,6 +53,13 @@ } }; + const handleLink = (asset: AssetResponseDto) => { + if (asset.livePhotoVideoId) { + assetStore.removeAssets([asset.livePhotoVideoId]); + } + assetStore.updateAssets([asset]); + }; + onDestroy(() => { assetStore.destroy(); }); @@ -78,6 +87,9 @@ onUnstack={(assets) => assetStore.addAssets(assets)} /> {/if} + {#if $selectedAssets.size === 2 && [...$selectedAssets].some((asset) => asset.type === AssetTypeEnum.Image && [...$selectedAssets].some((asset) => asset.type === AssetTypeEnum.Video))} + + {/if} assetStore.removeAssets(assetIds)} /> From d634ef2d2b683d019e263d17f34ee4cd735bcb10 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 10 Sep 2024 09:48:29 -0400 Subject: [PATCH 16/16] fix(server): person repo methods (#12524) --- server/src/cores/storage.core.ts | 2 +- server/src/interfaces/person.interface.ts | 6 +- server/src/repositories/person.repository.ts | 22 +++++-- server/src/services/audit.service.ts | 2 +- server/src/services/media.service.ts | 2 +- server/src/services/metadata.service.spec.ts | 30 +++++---- server/src/services/metadata.service.ts | 9 +-- server/src/services/person.service.spec.ts | 62 +++++++++---------- server/src/services/person.service.ts | 27 ++++---- .../repositories/person.repository.mock.ts | 4 +- 10 files changed, 85 insertions(+), 81 deletions(-) diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index a4d0d06152f50..e20a0c658db7f 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -301,7 +301,7 @@ export class StorageCore { return this.assetRepository.update({ id, sidecarPath: newPath }); } case PersonPathType.FACE: { - return this.personRepository.update([{ id, thumbnailPath: newPath }]); + return this.personRepository.update({ id, thumbnailPath: newPath }); } } } diff --git a/server/src/interfaces/person.interface.ts b/server/src/interfaces/person.interface.ts index fc6a389f3cc06..5708274a6e99f 100644 --- a/server/src/interfaces/person.interface.ts +++ b/server/src/interfaces/person.interface.ts @@ -54,7 +54,8 @@ export interface IPersonRepository { getAssets(personId: string): Promise; - create(entities: Partial[]): Promise; + create(person: Partial): Promise; + createAll(people: Partial[]): Promise; createFaces(entities: Partial[]): Promise; delete(entities: PersonEntity[]): Promise; deleteAll(): Promise; @@ -74,6 +75,7 @@ export interface IPersonRepository { reassignFace(assetFaceId: string, newPersonId: string): Promise; getNumberOfPeople(userId: string): Promise; reassignFaces(data: UpdateFacesData): Promise; - update(entities: Partial[]): Promise; + update(person: Partial): Promise; + updateAll(people: Partial[]): Promise; getLatestFaceDate(): Promise; } diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 1290df740e62d..c0bfee53987dc 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -280,8 +280,13 @@ export class PersonRepository implements IPersonRepository { return result; } - create(entities: Partial[]): Promise { - return this.personRepository.save(entities); + create(person: Partial): Promise { + return this.save(person); + } + + async createAll(people: Partial[]): Promise { + const results = await this.personRepository.save(people); + return results.map((person) => person.id); } async createFaces(entities: AssetFaceEntity[]): Promise { @@ -297,8 +302,12 @@ export class PersonRepository implements IPersonRepository { }); } - async update(entities: Partial[]): Promise { - return await this.personRepository.save(entities); + async update(person: Partial): Promise { + return this.save(person); + } + + async updateAll(people: Partial[]): Promise { + await this.personRepository.save(people); } @GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] }) @@ -320,4 +329,9 @@ export class PersonRepository implements IPersonRepository { .getRawOne(); return result?.latestDate; } + + private async save(person: Partial): Promise { + const { id } = await this.personRepository.save(person); + return this.personRepository.findOneByOrFail({ id }); + } } diff --git a/server/src/services/audit.service.ts b/server/src/services/audit.service.ts index 4f292f7cc1300..72db2b6eb56ce 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -115,7 +115,7 @@ export class AuditService { } case PersonPathType.FACE: { - await this.personRepository.update([{ id, thumbnailPath: pathValue }]); + await this.personRepository.update({ id, thumbnailPath: pathValue }); break; } diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 919348b53ef99..e74335bdc391c 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -117,7 +117,7 @@ export class MediaService { continue; } - await this.personRepository.update([{ id: person.id, faceAssetId: face.id }]); + await this.personRepository.update({ id: person.id, faceAssetId: face.id }); } jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } }); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 114c3db8ab6e8..ea7254f53f4d8 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1002,13 +1002,12 @@ describe(MetadataService.name, () => { systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); metadataMock.readTags.mockResolvedValue(metadataStub.withFaceNoName); personMock.getDistinctNames.mockResolvedValue([]); - personMock.create.mockResolvedValue([]); + personMock.createAll.mockResolvedValue([]); personMock.replaceFaces.mockResolvedValue([]); - personMock.update.mockResolvedValue([]); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.create).toHaveBeenCalledWith([]); + expect(personMock.createAll).toHaveBeenCalledWith([]); expect(personMock.replaceFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF); - expect(personMock.update).toHaveBeenCalledWith([]); + expect(personMock.updateAll).toHaveBeenCalledWith([]); }); it('should skip importing faces with empty name', async () => { @@ -1016,13 +1015,12 @@ describe(MetadataService.name, () => { systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); metadataMock.readTags.mockResolvedValue(metadataStub.withFaceEmptyName); personMock.getDistinctNames.mockResolvedValue([]); - personMock.create.mockResolvedValue([]); + personMock.createAll.mockResolvedValue([]); personMock.replaceFaces.mockResolvedValue([]); - personMock.update.mockResolvedValue([]); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.create).toHaveBeenCalledWith([]); + expect(personMock.createAll).toHaveBeenCalledWith([]); expect(personMock.replaceFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF); - expect(personMock.update).toHaveBeenCalledWith([]); + expect(personMock.updateAll).toHaveBeenCalledWith([]); }); it('should apply metadata face tags creating new persons', async () => { @@ -1030,13 +1028,13 @@ describe(MetadataService.name, () => { systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); metadataMock.readTags.mockResolvedValue(metadataStub.withFace); personMock.getDistinctNames.mockResolvedValue([]); - personMock.create.mockResolvedValue([personStub.withName]); + personMock.createAll.mockResolvedValue([personStub.withName.id]); personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']); - personMock.update.mockResolvedValue([personStub.withName]); + personMock.update.mockResolvedValue(personStub.withName); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id]); expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); - expect(personMock.create).toHaveBeenCalledWith([expect.objectContaining({ name: personStub.withName.name })]); + expect(personMock.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: personStub.withName.name })]); expect(personMock.replaceFaces).toHaveBeenCalledWith( assetStub.primaryImage.id, [ @@ -1055,7 +1053,7 @@ describe(MetadataService.name, () => { ], SourceType.EXIF, ); - expect(personMock.update).toHaveBeenCalledWith([{ id: 'random-uuid', faceAssetId: 'random-uuid' }]); + expect(personMock.updateAll).toHaveBeenCalledWith([{ id: 'random-uuid', faceAssetId: 'random-uuid' }]); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, @@ -1069,13 +1067,13 @@ describe(MetadataService.name, () => { systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); metadataMock.readTags.mockResolvedValue(metadataStub.withFace); personMock.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); - personMock.create.mockResolvedValue([]); + personMock.createAll.mockResolvedValue([]); personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']); - personMock.update.mockResolvedValue([personStub.withName]); + personMock.update.mockResolvedValue(personStub.withName); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id]); expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); - expect(personMock.create).toHaveBeenCalledWith([]); + expect(personMock.createAll).toHaveBeenCalledWith([]); expect(personMock.replaceFaces).toHaveBeenCalledWith( assetStub.primaryImage.id, [ @@ -1094,7 +1092,7 @@ describe(MetadataService.name, () => { ], SourceType.EXIF, ); - expect(personMock.update).toHaveBeenCalledWith([]); + expect(personMock.updateAll).toHaveBeenCalledWith([]); expect(jobMock.queueAll).toHaveBeenCalledWith([]); }); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 0522c883dd1f3..4ffbd7f09b4f6 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -584,18 +584,15 @@ export class MetadataService { this.logger.debug(`Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`); } - const newPersons = await this.personRepository.create(missing); + const newPersonIds = await this.personRepository.createAll(missing); const faceIds = await this.personRepository.replaceFaces(asset.id, discoveredFaces, SourceType.EXIF); this.logger.debug(`Created ${faceIds.length} faces for asset ${asset.id}`); - await this.personRepository.update(missingWithFaceAsset); + await this.personRepository.updateAll(missingWithFaceAsset); await this.jobRepository.queueAll( - newPersons.map((person) => ({ - name: JobName.GENERATE_PERSON_THUMBNAIL, - data: { id: person.id }, - })), + newPersonIds.map((id) => ({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } })), ); } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 51598b93d063b..2b111706f1ea6 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -241,18 +241,18 @@ describe(PersonService.name, () => { }); it("should update a person's name", async () => { - personMock.update.mockResolvedValue([personStub.withName]); + personMock.update.mockResolvedValue(personStub.withName); personMock.getAssets.mockResolvedValue([assetStub.image]); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', name: 'Person 1' }]); + expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' }); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it("should update a person's date of birth", async () => { - personMock.update.mockResolvedValue([personStub.withBirthDate]); + personMock.update.mockResolvedValue(personStub.withBirthDate); personMock.getAssets.mockResolvedValue([assetStub.image]); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); @@ -264,25 +264,25 @@ describe(PersonService.name, () => { isHidden: false, updatedAt: expect.any(Date), }); - expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', birthDate: '1976-06-30' }]); + expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' }); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should update a person visibility', async () => { - personMock.update.mockResolvedValue([personStub.withName]); + personMock.update.mockResolvedValue(personStub.withName); personMock.getAssets.mockResolvedValue([assetStub.image]); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', isHidden: false }]); + expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false }); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it("should update a person's thumbnailPath", async () => { - personMock.update.mockResolvedValue([personStub.withName]); + personMock.update.mockResolvedValue(personStub.withName); personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); @@ -291,7 +291,7 @@ describe(PersonService.name, () => { sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }), ).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', faceAssetId: faceStub.face1.id }]); + expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id }); expect(personMock.getFacesByIds).toHaveBeenCalledWith([ { assetId: faceStub.face1.assetId, @@ -441,11 +441,11 @@ describe(PersonService.name, () => { describe('createPerson', () => { it('should create a new person', async () => { - personMock.create.mockResolvedValue([personStub.primaryPerson]); + personMock.create.mockResolvedValue(personStub.primaryPerson); await expect(sut.create(authStub.admin, {})).resolves.toBe(personStub.primaryPerson); - expect(personMock.create).toHaveBeenCalledWith([{ ownerId: authStub.admin.user.id }]); + expect(personMock.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id }); }); }); @@ -819,7 +819,7 @@ describe(PersonService.name, () => { systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue([faceStub.primaryFace1.person]); + personMock.create.mockResolvedValue(faceStub.primaryFace1.person); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); @@ -844,16 +844,14 @@ describe(PersonService.name, () => { systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue([personStub.withName]); + personMock.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); - expect(personMock.create).toHaveBeenCalledWith([ - { - ownerId: faceStub.noPerson1.asset.ownerId, - faceAssetId: faceStub.noPerson1.id, - }, - ]); + expect(personMock.create).toHaveBeenCalledWith({ + ownerId: faceStub.noPerson1.asset.ownerId, + faceAssetId: faceStub.noPerson1.id, + }); expect(personMock.reassignFaces).toHaveBeenCalledWith({ faceIds: [faceStub.noPerson1.id], newPersonId: personStub.withName.id, @@ -865,7 +863,7 @@ describe(PersonService.name, () => { searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue([personStub.withName]); + personMock.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); @@ -884,7 +882,7 @@ describe(PersonService.name, () => { systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue([personStub.withName]); + personMock.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); @@ -906,7 +904,7 @@ describe(PersonService.name, () => { systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue([personStub.withName]); + personMock.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true }); @@ -979,12 +977,10 @@ describe(PersonService.name, () => { processInvalidImages: false, }, ); - expect(personMock.update).toHaveBeenCalledWith([ - { - id: 'person-1', - thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', - }, - ]); + expect(personMock.update).toHaveBeenCalledWith({ + id: 'person-1', + thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', + }); }); it('should generate a thumbnail without going negative', async () => { @@ -1103,7 +1099,7 @@ describe(PersonService.name, () => { it('should merge two people with smart merge', async () => { personMock.getById.mockResolvedValueOnce(personStub.randomPerson); personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.update.mockResolvedValue([{ ...personStub.randomPerson, name: personStub.primaryPerson.name }]); + personMock.update.mockResolvedValue({ ...personStub.randomPerson, name: personStub.primaryPerson.name }); accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3'])); accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); @@ -1116,12 +1112,10 @@ describe(PersonService.name, () => { oldPersonId: personStub.primaryPerson.id, }); - expect(personMock.update).toHaveBeenCalledWith([ - { - id: personStub.randomPerson.id, - name: personStub.primaryPerson.name, - }, - ]); + expect(personMock.update).toHaveBeenCalledWith({ + id: personStub.randomPerson.id, + name: personStub.primaryPerson.name, + }); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index c4b5df5719352..dd4a4cecf2b56 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -173,7 +173,7 @@ export class PersonService { const assetFace = await this.repository.getRandomFace(personId); if (assetFace !== null) { - await this.repository.update([{ id: personId, faceAssetId: assetFace.id }]); + await this.repository.update({ id: personId, faceAssetId: assetFace.id }); jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personId } }); } } @@ -211,16 +211,13 @@ export class PersonService { return assets.map((asset) => mapAsset(asset)); } - async create(auth: AuthDto, dto: PersonCreateDto): Promise { - const [created] = await this.repository.create([ - { - ownerId: auth.user.id, - name: dto.name, - birthDate: dto.birthDate, - isHidden: dto.isHidden, - }, - ]); - return created; + create(auth: AuthDto, dto: PersonCreateDto): Promise { + return this.repository.create({ + ownerId: auth.user.id, + name: dto.name, + birthDate: dto.birthDate, + isHidden: dto.isHidden, + }); } async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise { @@ -239,7 +236,7 @@ export class PersonService { faceId = face.id; } - const [person] = await this.repository.update([{ id, faceAssetId: faceId, name, birthDate, isHidden }]); + const person = await this.repository.update({ id, faceAssetId: faceId, name, birthDate, isHidden }); if (assetId) { await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } }); @@ -501,7 +498,7 @@ export class PersonService { if (isCore && !personId) { this.logger.log(`Creating new person for face ${id}`); - const [newPerson] = await this.repository.create([{ ownerId: face.asset.ownerId, faceAssetId: face.id }]); + const newPerson = await this.repository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id }); await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } }); personId = newPerson.id; } @@ -577,7 +574,7 @@ export class PersonService { } as const; await this.mediaRepository.generateThumbnail(inputPath, thumbnailPath, thumbnailOptions); - await this.repository.update([{ id: person.id, thumbnailPath }]); + await this.repository.update({ id: person.id, thumbnailPath }); return JobStatus.SUCCESS; } @@ -624,7 +621,7 @@ export class PersonService { } if (Object.keys(update).length > 0) { - [primaryPerson] = await this.repository.update([{ id: primaryPerson.id, ...update }]); + primaryPerson = await this.repository.update({ id: primaryPerson.id, ...update }); } const mergeName = mergePerson.name || mergePerson.id; diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index 6547a543390a0..77e8ccf010671 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -13,9 +13,11 @@ export const newPersonRepositoryMock = (): Mocked => { getDistinctNames: vitest.fn(), create: vitest.fn(), + createAll: vitest.fn(), update: vitest.fn(), - deleteAll: vitest.fn(), + updateAll: vitest.fn(), delete: vitest.fn(), + deleteAll: vitest.fn(), deleteAllFaces: vitest.fn(), getStatistics: vitest.fn(),