From 884ebbc9651e9855afb9e9cabe689d3d6e1f34f4 Mon Sep 17 00:00:00 2001 From: Yaros Date: Thu, 26 Feb 2026 13:08:04 +0100 Subject: [PATCH] Revert "Merge branch 'main' into feat/mobile-ocr" This reverts commit 93cd80ad12c5be21bda348c282fee804194844c3. --- e2e/test-assets | 2 +- i18n/en.json | 1 - .../widgets/images/image_provider.dart | 15 +- .../widgets/images/local_image_provider.dart | 1 + .../widgets/images/remote_image_provider.dart | 5 +- mobile/openapi/README.md | 1 - mobile/openapi/lib/api.dart | 1 - mobile/openapi/lib/api_client.dart | 2 - .../lib/model/sync_asset_edit_delete_v1.dart | 99 ---- .../openapi/lib/model/sync_entity_type.dart | 6 - .../openapi/lib/model/sync_request_type.dart | 3 - open-api/immich-openapi-specs.json | 14 - open-api/typescript-sdk/src/fetch-client.ts | 6 - server/src/database.ts | 7 - server/src/dtos/editing.dto.ts | 7 +- server/src/dtos/sync.dto.ts | 19 - server/src/enum.ts | 3 - server/src/queries/asset.edit.repository.sql | 14 - server/src/queries/sync.repository.sql | 32 -- .../src/repositories/asset-edit.repository.ts | 19 +- server/src/repositories/media.repository.ts | 48 +- server/src/repositories/sync.repository.ts | 28 +- .../src/repositories/websocket.repository.ts | 4 +- server/src/schema/functions.ts | 13 - server/src/schema/index.ts | 3 - .../migrations/1771873813973-AssetEditSync.ts | 53 -- .../schema/tables/asset-edit-audit.table.ts | 17 - server/src/schema/tables/asset-edit.table.ts | 18 +- server/src/services/asset.service.ts | 1 - server/src/services/job.service.ts | 2 - server/src/services/media.service.spec.ts | 108 ++--- server/src/services/metadata.service.spec.ts | 39 +- server/src/services/person.service.spec.ts | 457 ++++++++---------- server/src/services/person.service.ts | 2 +- server/src/services/search.service.spec.ts | 12 +- server/src/services/sync.service.ts | 18 - server/test/factories/asset-edit.factory.ts | 5 +- server/test/fixtures/person.stub.ts | 165 +++++++ .../specs/services/audit.database.spec.ts | 23 - .../specs/services/sync.service.spec.ts | 19 +- .../medium/specs/sync/sync-asset-edit.spec.ts | 300 ------------ .../pages/SharedLinkErrorPage.svelte | 5 +- web/src/lib/stores/websocket.ts | 3 +- 43 files changed, 486 insertions(+), 1114 deletions(-) delete mode 100644 mobile/openapi/lib/model/sync_asset_edit_delete_v1.dart delete mode 100644 server/src/schema/migrations/1771873813973-AssetEditSync.ts delete mode 100644 server/src/schema/tables/asset-edit-audit.table.ts delete mode 100644 server/test/medium/specs/sync/sync-asset-edit.spec.ts diff --git a/e2e/test-assets b/e2e/test-assets index 4a0b5a2f69..163c251744 160000 --- a/e2e/test-assets +++ b/e2e/test-assets @@ -1 +1 @@ -Subproject commit 4a0b5a2f699a7462039a5b8f75b5cce4aeee3a9d +Subproject commit 163c251744e0a35d7ecfd02682452043f149fc2b diff --git a/i18n/en.json b/i18n/en.json index b99dac5609..2d77cc3f2f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1074,7 +1074,6 @@ "failed_to_update_notification_status": "Failed to update notification status", "incorrect_email_or_password": "Incorrect email or password", "library_folder_already_exists": "This import path already exists.", - "page_not_found": "Page not found :/", "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation", "profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.", "quota_higher_than_disk_size": "You set a quota higher than the disk size", diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index c3cda46e81..3c3ed460b4 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -48,7 +48,7 @@ mixin CancellableImageProviderMixin on CancellableImageProvide return null; } - Stream loadRequest(ImageRequest request, ImageDecoderCallback decode, {bool evictOnError = true}) async* { + Stream loadRequest(ImageRequest request, ImageDecoderCallback decode) async* { if (isCancelled) { this.request = null; PaintingBinding.instance.imageCache.evict(this); @@ -57,19 +57,14 @@ mixin CancellableImageProviderMixin on CancellableImageProvide try { final image = await request.load(decode); - if ((image == null && evictOnError) || isCancelled) { + if (image == null || isCancelled) { PaintingBinding.instance.imageCache.evict(this); return; - } else if (image == null) { - return; } yield image; - } catch (e, stack) { - if (evictOnError) { - PaintingBinding.instance.imageCache.evict(this); - rethrow; - } - _log.warning('Non-fatal image load error', e, stack); + } catch (e) { + PaintingBinding.instance.imageCache.evict(this); + rethrow; } finally { this.request = null; } diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index 1c7d102239..03b9370190 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -94,6 +94,7 @@ class LocalFullImageProvider extends CancellableImageProvider identical(this, other) || other is SyncAssetEditDeleteV1 && - other.editId == editId; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (editId.hashCode); - - @override - String toString() => 'SyncAssetEditDeleteV1[editId=$editId]'; - - Map toJson() { - final json = {}; - json[r'editId'] = this.editId; - return json; - } - - /// Returns a new [SyncAssetEditDeleteV1] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static SyncAssetEditDeleteV1? fromJson(dynamic value) { - upgradeDto(value, "SyncAssetEditDeleteV1"); - if (value is Map) { - final json = value.cast(); - - return SyncAssetEditDeleteV1( - editId: mapValueOfType(json, r'editId')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = SyncAssetEditDeleteV1.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = SyncAssetEditDeleteV1.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of SyncAssetEditDeleteV1-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = SyncAssetEditDeleteV1.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'editId', - }; -} - diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index 7fc9656dff..c89439408e 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -29,8 +29,6 @@ class SyncEntityType { static const assetV1 = SyncEntityType._(r'AssetV1'); static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1'); static const assetExifV1 = SyncEntityType._(r'AssetExifV1'); - static const assetEditV1 = SyncEntityType._(r'AssetEditV1'); - static const assetEditDeleteV1 = SyncEntityType._(r'AssetEditDeleteV1'); static const assetMetadataV1 = SyncEntityType._(r'AssetMetadataV1'); static const assetMetadataDeleteV1 = SyncEntityType._(r'AssetMetadataDeleteV1'); static const assetOcrV1 = SyncEntityType._(r'AssetOcrV1'); @@ -84,8 +82,6 @@ class SyncEntityType { assetV1, assetDeleteV1, assetExifV1, - assetEditV1, - assetEditDeleteV1, assetMetadataV1, assetMetadataDeleteV1, assetOcrV1, @@ -174,8 +170,6 @@ class SyncEntityTypeTypeTransformer { case r'AssetV1': return SyncEntityType.assetV1; case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1; case r'AssetExifV1': return SyncEntityType.assetExifV1; - case r'AssetEditV1': return SyncEntityType.assetEditV1; - case r'AssetEditDeleteV1': return SyncEntityType.assetEditDeleteV1; case r'AssetMetadataV1': return SyncEntityType.assetMetadataV1; case r'AssetMetadataDeleteV1': return SyncEntityType.assetMetadataDeleteV1; case r'AssetOcrV1': return SyncEntityType.assetOcrV1; diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index 316a9ffc21..c1ec705edb 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -30,7 +30,6 @@ class SyncRequestType { static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1'); static const assetsV1 = SyncRequestType._(r'AssetsV1'); static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1'); - static const assetEditsV1 = SyncRequestType._(r'AssetEditsV1'); static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1'); static const assetOcrV1 = SyncRequestType._(r'AssetOcrV1'); static const authUsersV1 = SyncRequestType._(r'AuthUsersV1'); @@ -56,7 +55,6 @@ class SyncRequestType { albumAssetExifsV1, assetsV1, assetExifsV1, - assetEditsV1, assetMetadataV1, assetOcrV1, authUsersV1, @@ -117,7 +115,6 @@ class SyncRequestTypeTypeTransformer { case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1; case r'AssetsV1': return SyncRequestType.assetsV1; case r'AssetExifsV1': return SyncRequestType.assetExifsV1; - case r'AssetEditsV1': return SyncRequestType.assetEditsV1; case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1; case r'AssetOcrV1': return SyncRequestType.assetOcrV1; case r'AuthUsersV1': return SyncRequestType.authUsersV1; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 58469dd270..c2b33256a6 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -22614,17 +22614,6 @@ ], "type": "object" }, - "SyncAssetEditDeleteV1": { - "properties": { - "editId": { - "type": "string" - } - }, - "required": [ - "editId" - ], - "type": "object" - }, "SyncAssetExifV1": { "properties": { "assetId": { @@ -23274,8 +23263,6 @@ "AssetV1", "AssetDeleteV1", "AssetExifV1", - "AssetEditV1", - "AssetEditDeleteV1", "AssetMetadataV1", "AssetMetadataDeleteV1", "AssetOcrV1", @@ -23575,7 +23562,6 @@ "AlbumAssetExifsV1", "AssetsV1", "AssetExifsV1", - "AssetEditsV1", "AssetMetadataV1", "AssetOcrV1", "AuthUsersV1", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index a3932ff256..fa0e9bad1a 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2967,9 +2967,6 @@ export type SyncAssetDeleteV1 = { /** Asset ID */ assetId: string; }; -export type SyncAssetEditDeleteV1 = { - editId: string; -}; export type SyncAssetExifV1 = { /** Asset ID */ assetId: string; @@ -7271,8 +7268,6 @@ export enum SyncEntityType { AssetV1 = "AssetV1", AssetDeleteV1 = "AssetDeleteV1", AssetExifV1 = "AssetExifV1", - AssetEditV1 = "AssetEditV1", - AssetEditDeleteV1 = "AssetEditDeleteV1", AssetMetadataV1 = "AssetMetadataV1", AssetMetadataDeleteV1 = "AssetMetadataDeleteV1", AssetOcrV1 = "AssetOcrV1", @@ -7326,7 +7321,6 @@ export enum SyncRequestType { AlbumAssetExifsV1 = "AlbumAssetExifsV1", AssetsV1 = "AssetsV1", AssetExifsV1 = "AssetExifsV1", - AssetEditsV1 = "AssetEditsV1", AssetMetadataV1 = "AssetMetadataV1", AssetOcrV1 = "AssetOcrV1", AuthUsersV1 = "AuthUsersV1", diff --git a/server/src/database.ts b/server/src/database.ts index 28f2213169..39792c503d 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -454,13 +454,6 @@ export const columns = { 'asset_ocr.updateId', 'asset_ocr.isVisible', ], - syncAssetEdit: [ - 'asset_edit.id', - 'asset_edit.assetId', - 'asset_edit.sequence', - 'asset_edit.action', - 'asset_edit.parameters', - ], exif: [ 'asset_exif.assetId', 'asset_exif.autoStackId', diff --git a/server/src/dtos/editing.dto.ts b/server/src/dtos/editing.dto.ts index 8217fec41c..fcdfdcad5f 100644 --- a/server/src/dtos/editing.dto.ts +++ b/server/src/dtos/editing.dto.ts @@ -1,6 +1,7 @@ -import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; +import { ApiProperty, getSchemaPath } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator'; +import { ExtraModel } from 'src/dtos/sync.dto'; import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateEnum, ValidateUUID } from 'src/validation'; export enum AssetEditAction { @@ -14,6 +15,7 @@ export enum MirrorAxis { Vertical = 'vertical', } +@ExtraModel() export class CropParameters { @IsInt() @Min(0) @@ -36,12 +38,14 @@ export class CropParameters { height!: number; } +@ExtraModel() export class RotateParameters { @IsAxisAlignedRotation() @ApiProperty({ description: 'Rotation angle in degrees' }) angle!: number; } +@ExtraModel() export class MirrorParameters { @IsEnum(MirrorAxis) @ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' }) @@ -63,7 +67,6 @@ export type AssetEditActionItem = parameters: MirrorParameters; }; -@ApiExtraModels(CropParameters, RotateParameters, MirrorParameters) export class AssetEditActionItemDto { @ValidateEnum({ name: 'AssetEditAction', enum: AssetEditAction, description: 'Type of edit action to perform' }) action!: AssetEditAction; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 94f48559d1..ca7a64adc1 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -2,7 +2,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { AssetEditAction } from 'src/dtos/editing.dto'; import { AlbumUserRole, AssetOrder, @@ -275,22 +274,6 @@ export class SyncAssetOcrDeleteV1 { @ApiProperty({ description: 'Timestamp when the OCR entry was deleted' }) deletedAt!: Date; } -export class SyncAssetEditV1 { - id!: string; - assetId!: string; - - @ValidateEnum({ enum: AssetEditAction, name: 'AssetEditAction' }) - action!: AssetEditAction; - parameters!: object; - - @ApiProperty({ type: 'integer' }) - sequence!: number; -} - -@ExtraModel() -export class SyncAssetEditDeleteV1 { - editId!: string; -} @ExtraModel() export class SyncAssetMetadataV1 { @@ -556,8 +539,6 @@ export type SyncItem = { [SyncEntityType.AssetExifV1]: SyncAssetExifV1; [SyncEntityType.AssetOcrV1]: SyncAssetOcrV1; [SyncEntityType.AssetOcrDeleteV1]: SyncAssetOcrDeleteV1; - [SyncEntityType.AssetEditV1]: SyncAssetEditV1; - [SyncEntityType.AssetEditDeleteV1]: SyncAssetEditDeleteV1; [SyncEntityType.PartnerAssetV1]: SyncAssetV1; [SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1; [SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1; diff --git a/server/src/enum.ts b/server/src/enum.ts index cbc900fbce..8b48e8303a 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -720,7 +720,6 @@ export enum SyncRequestType { AlbumAssetExifsV1 = 'AlbumAssetExifsV1', AssetsV1 = 'AssetsV1', AssetExifsV1 = 'AssetExifsV1', - AssetEditsV1 = 'AssetEditsV1', AssetMetadataV1 = 'AssetMetadataV1', AssetOcrV1 = 'AssetOcrV1', AuthUsersV1 = 'AuthUsersV1', @@ -747,8 +746,6 @@ export enum SyncEntityType { AssetV1 = 'AssetV1', AssetDeleteV1 = 'AssetDeleteV1', AssetExifV1 = 'AssetExifV1', - AssetEditV1 = 'AssetEditV1', - AssetEditDeleteV1 = 'AssetEditDeleteV1', AssetMetadataV1 = 'AssetMetadataV1', AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1', AssetOcrV1 = 'AssetOcrV1', diff --git a/server/src/queries/asset.edit.repository.sql b/server/src/queries/asset.edit.repository.sql index 44dca38031..9330305973 100644 --- a/server/src/queries/asset.edit.repository.sql +++ b/server/src/queries/asset.edit.repository.sql @@ -18,17 +18,3 @@ where "assetId" = $1 order by "sequence" asc - --- AssetEditRepository.getWithSyncInfo -select - "asset_edit"."id", - "asset_edit"."assetId", - "asset_edit"."sequence", - "asset_edit"."action", - "asset_edit"."parameters" -from - "asset_edit" -where - "assetId" = $1 -order by - "sequence" asc diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 43c6a380bf..68a85e4c0f 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -514,38 +514,6 @@ where order by "asset_exif"."updateId" asc --- SyncRepository.assetEdit.getDeletes -select - "asset_edit_audit"."id", - "editId" -from - "asset_edit_audit" as "asset_edit_audit" - inner join "asset" on "asset"."id" = "asset_edit_audit"."assetId" -where - "asset_edit_audit"."id" < $1 - and "asset_edit_audit"."id" > $2 - and "asset"."ownerId" = $3 -order by - "asset_edit_audit"."id" asc - --- SyncRepository.assetEdit.getUpserts -select - "asset_edit"."id", - "asset_edit"."assetId", - "asset_edit"."sequence", - "asset_edit"."action", - "asset_edit"."parameters", - "asset_edit"."updateId" -from - "asset_edit" as "asset_edit" - inner join "asset" on "asset"."id" = "asset_edit"."assetId" -where - "asset_edit"."updateId" < $1 - and "asset_edit"."updateId" > $2 - and "asset"."ownerId" = $3 -order by - "asset_edit"."updateId" asc - -- SyncRepository.assetFace.getDeletes select "asset_face_audit"."id", diff --git a/server/src/repositories/asset-edit.repository.ts b/server/src/repositories/asset-edit.repository.ts index 164ebec6b6..23c7c3a0b9 100644 --- a/server/src/repositories/asset-edit.repository.ts +++ b/server/src/repositories/asset-edit.repository.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Kysely } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; -import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetEditActionItem, AssetEditActionItemResponseDto } from 'src/dtos/editing.dto'; import { DB } from 'src/schema'; @@ -10,7 +9,9 @@ import { DB } from 'src/schema'; export class AssetEditRepository { constructor(@InjectKysely() private db: Kysely) {} - @GenerateSql({ params: [DummyValue.UUID] }) + @GenerateSql({ + params: [DummyValue.UUID], + }) replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise { return this.db.transaction().execute(async (trx) => { await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute(); @@ -27,7 +28,9 @@ export class AssetEditRepository { }); } - @GenerateSql({ params: [DummyValue.UUID] }) + @GenerateSql({ + params: [DummyValue.UUID], + }) getAll(assetId: string): Promise { return this.db .selectFrom('asset_edit') @@ -36,14 +39,4 @@ export class AssetEditRepository { .orderBy('sequence', 'asc') .execute(); } - - @GenerateSql({ params: [DummyValue.UUID] }) - getWithSyncInfo(assetId: string) { - return this.db - .selectFrom('asset_edit') - .select(columns.syncAssetEdit) - .where('assetId', '=', assetId) - .orderBy('sequence', 'asc') - .execute(); - } } diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 7b0b30583d..e3e78b3238 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -243,26 +243,23 @@ export class MediaRepository { bitrate: this.parseInt(results.format.bit_rate), }, videoStreams: results.streams - .filter((stream) => stream.codec_type === 'video' && !stream.disposition?.attached_pic) - .map((stream) => { - const height = this.parseInt(stream.height); - const dar = this.getDar(stream.display_aspect_ratio); - return { - index: stream.index, - height, - width: dar ? Math.round(height * dar) : this.parseInt(stream.width), - codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name, - codecType: stream.codec_type, - frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames), - rotation: this.parseInt(stream.rotation), - isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67', - bitrate: this.parseInt(stream.bit_rate), - pixelFormat: stream.pix_fmt || 'yuv420p', - colorPrimaries: stream.color_primaries, - colorSpace: stream.color_space, - colorTransfer: stream.color_transfer, - }; - }), + .filter((stream) => stream.codec_type === 'video') + .filter((stream) => !stream.disposition?.attached_pic) + .map((stream) => ({ + index: stream.index, + height: this.parseInt(stream.height), + width: this.parseInt(stream.width), + codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name, + codecType: stream.codec_type, + frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames), + rotation: this.parseInt(stream.rotation), + isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67', + bitrate: this.parseInt(stream.bit_rate), + pixelFormat: stream.pix_fmt || 'yuv420p', + colorPrimaries: stream.color_primaries, + colorSpace: stream.color_space, + colorTransfer: stream.color_transfer, + })), audioStreams: results.streams .filter((stream) => stream.codec_type === 'audio') .map((stream) => ({ @@ -355,15 +352,4 @@ export class MediaRepository { private parseFloat(value: string | number | undefined): number { return Number.parseFloat(value as string) || 0; } - - private getDar(dar: string | undefined): number { - if (dar) { - const [darW, darH] = dar.split(':').map(Number); - if (darW && darH) { - return darW / darH; - } - } - - return 0; - } } diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 91b567c537..9288633d01 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -53,7 +53,6 @@ export class SyncRepository { albumUser: AlbumUserSync; asset: AssetSync; assetExif: AssetExifSync; - assetEdit: AssetEditSync; assetFace: AssetFaceSync; assetMetadata: AssetMetadataSync; assetOcr: AssetOcrSync; @@ -77,7 +76,6 @@ export class SyncRepository { this.albumUser = new AlbumUserSync(this.db); this.asset = new AssetSync(this.db); this.assetExif = new AssetExifSync(this.db); - this.assetEdit = new AssetEditSync(this.db); this.assetFace = new AssetFaceSync(this.db); this.assetMetadata = new AssetMetadataSync(this.db); this.assetOcr = new AssetOcrSync(this.db); @@ -95,7 +93,7 @@ export class SyncRepository { } } -export class BaseSync { +class BaseSync { constructor(protected db: Kysely) {} protected backfillQuery(t: T, { nowId, beforeUpdateId, afterUpdateId }: SyncBackfillOptions) { @@ -505,30 +503,6 @@ class AssetExifSync extends BaseSync { } } -class AssetEditSync extends BaseSync { - @GenerateSql({ params: [dummyQueryOptions], stream: true }) - getDeletes(options: SyncQueryOptions) { - return this.auditQuery('asset_edit_audit', options) - .select(['asset_edit_audit.id', 'editId']) - .innerJoin('asset', 'asset.id', 'asset_edit_audit.assetId') - .where('asset.ownerId', '=', options.userId) - .stream(); - } - - cleanupAuditTable(daysAgo: number) { - return this.auditCleanup('asset_edit_audit', daysAgo); - } - - @GenerateSql({ params: [dummyQueryOptions], stream: true }) - getUpserts(options: SyncQueryOptions) { - return this.upsertQuery('asset_edit', options) - .select([...columns.syncAssetEdit, 'asset_edit.updateId']) - .innerJoin('asset', 'asset.id', 'asset_edit.assetId') - .where('asset.ownerId', '=', options.userId) - .stream(); - } -} - class MemorySync extends BaseSync { @GenerateSql({ params: [dummyQueryOptions], stream: true }) getDeletes(options: SyncQueryOptions) { diff --git a/server/src/repositories/websocket.repository.ts b/server/src/repositories/websocket.repository.ts index 235d2f2a84..bfed556895 100644 --- a/server/src/repositories/websocket.repository.ts +++ b/server/src/repositories/websocket.repository.ts @@ -11,7 +11,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { NotificationDto } from 'src/dtos/notification.dto'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; -import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto'; +import { SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto'; import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { handlePromiseError } from 'src/utils/misc'; @@ -37,7 +37,7 @@ export interface ClientEventMap { AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }]; AppRestartV1: [AppRestartEvent]; - AssetEditReadyV1: [{ asset: SyncAssetV1; edit: SyncAssetEditV1[] }]; + AssetEditReadyV1: [{ asset: SyncAssetV1 }]; } export type AuthFn = (client: Socket) => Promise; diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 39d54e6395..331ab6adba 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -287,19 +287,6 @@ export const asset_edit_delete = registerFunction({ `, }); -export const asset_edit_audit = registerFunction({ - name: 'asset_edit_audit', - returnType: 'TRIGGER', - language: 'PLPGSQL', - body: ` - BEGIN - INSERT INTO asset_edit_audit ("editId", "assetId") - SELECT "id", "assetId" - FROM OLD; - RETURN NULL; - END`, -}); - export const asset_ocr_delete_audit = registerFunction({ name: 'asset_ocr_delete_audit', returnType: 'TRIGGER', diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 7ad8a84e12..5c580bee4b 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -30,7 +30,6 @@ import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumTable } from 'src/schema/tables/album.table'; import { ApiKeyTable } from 'src/schema/tables/api-key.table'; import { AssetAuditTable } from 'src/schema/tables/asset-audit.table'; -import { AssetEditAuditTable } from 'src/schema/tables/asset-edit-audit.table'; import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table'; @@ -91,7 +90,6 @@ export class ImmichDatabase { ApiKeyTable, AssetAuditTable, AssetEditTable, - AssetEditAuditTable, AssetFaceTable, AssetFaceAuditTable, AssetMetadataTable, @@ -190,7 +188,6 @@ export interface DB { asset: AssetTable; asset_audit: AssetAuditTable; asset_edit: AssetEditTable; - asset_edit_audit: AssetEditAuditTable; asset_exif: AssetExifTable; asset_face: AssetFaceTable; asset_face_audit: AssetFaceAuditTable; diff --git a/server/src/schema/migrations/1771873813973-AssetEditSync.ts b/server/src/schema/migrations/1771873813973-AssetEditSync.ts deleted file mode 100644 index 4f5be1ddcd..0000000000 --- a/server/src/schema/migrations/1771873813973-AssetEditSync.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Kysely, sql } from 'kysely'; - -export async function up(db: Kysely): Promise { - await sql`CREATE OR REPLACE FUNCTION asset_edit_audit() - RETURNS TRIGGER - LANGUAGE PLPGSQL - AS $$ - BEGIN - INSERT INTO asset_edit_audit ("editId", "assetId") - SELECT "id", "assetId" - FROM OLD; - RETURN NULL; - END - $$;`.execute(db); - await sql`CREATE TABLE "asset_edit_audit" ( - "id" uuid NOT NULL DEFAULT immich_uuid_v7(), - "editId" uuid NOT NULL, - "assetId" uuid NOT NULL, - "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), - CONSTRAINT "asset_edit_audit_pkey" PRIMARY KEY ("id") -);`.execute(db); - await sql`CREATE INDEX "asset_edit_audit_assetId_idx" ON "asset_edit_audit" ("assetId");`.execute(db); - await sql`CREATE INDEX "asset_edit_audit_deletedAt_idx" ON "asset_edit_audit" ("deletedAt");`.execute(db); - await sql`ALTER TABLE "asset_edit" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db); - await sql`ALTER TABLE "asset_edit" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db); - await sql`CREATE INDEX "asset_edit_updateId_idx" ON "asset_edit" ("updateId");`.execute(db); - await sql`CREATE OR REPLACE TRIGGER "asset_edit_audit" - AFTER DELETE ON "asset_edit" - REFERENCING OLD TABLE AS "old" - FOR EACH STATEMENT - WHEN (pg_trigger_depth() = 0) - EXECUTE FUNCTION asset_edit_audit();`.execute(db); - await sql`CREATE OR REPLACE TRIGGER "asset_edit_updatedAt" - BEFORE UPDATE ON "asset_edit" - FOR EACH ROW - EXECUTE FUNCTION updated_at();`.execute(db); - await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_edit_audit', '{"type":"function","name":"asset_edit_audit","sql":"CREATE OR REPLACE FUNCTION asset_edit_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO asset_edit_audit (\\"editId\\", \\"assetId\\")\\n SELECT \\"id\\", \\"assetId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); - await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_edit_audit', '{"type":"trigger","name":"asset_edit_audit","sql":"CREATE OR REPLACE TRIGGER \\"asset_edit_audit\\"\\n AFTER DELETE ON \\"asset_edit\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_edit_audit();"}'::jsonb);`.execute(db); - await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_edit_updatedAt', '{"type":"trigger","name":"asset_edit_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"asset_edit_updatedAt\\"\\n BEFORE UPDATE ON \\"asset_edit\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db); -} - -export async function down(db: Kysely): Promise { - await sql`DROP TRIGGER "asset_edit_audit" ON "asset_edit";`.execute(db); - await sql`DROP TRIGGER "asset_edit_updatedAt" ON "asset_edit";`.execute(db); - await sql`DROP INDEX "asset_edit_updateId_idx";`.execute(db); - await sql`ALTER TABLE "asset_edit" DROP COLUMN "updatedAt";`.execute(db); - await sql`ALTER TABLE "asset_edit" DROP COLUMN "updateId";`.execute(db); - await sql`DROP TABLE "asset_edit_audit";`.execute(db); - await sql`DROP FUNCTION asset_edit_audit;`.execute(db); - await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_asset_edit_audit';`.execute(db); - await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_edit_audit';`.execute(db); - await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_edit_updatedAt';`.execute(db); -} diff --git a/server/src/schema/tables/asset-edit-audit.table.ts b/server/src/schema/tables/asset-edit-audit.table.ts deleted file mode 100644 index 9c8b29f374..0000000000 --- a/server/src/schema/tables/asset-edit-audit.table.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; -import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; - -@Table('asset_edit_audit') -export class AssetEditAuditTable { - @PrimaryGeneratedUuidV7Column() - id!: Generated; - - @Column({ type: 'uuid' }) - editId!: string; - - @Column({ type: 'uuid', index: true }) - assetId!: string; - - @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) - deletedAt!: Generated; -} diff --git a/server/src/schema/tables/asset-edit.table.ts b/server/src/schema/tables/asset-edit.table.ts index 2e9d2be20d..1ec6bf081c 100644 --- a/server/src/schema/tables/asset-edit.table.ts +++ b/server/src/schema/tables/asset-edit.table.ts @@ -6,17 +6,13 @@ import { Generated, PrimaryGeneratedColumn, Table, - Timestamp, Unique, - UpdateDateColumn, } from '@immich/sql-tools'; -import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AssetEditAction, AssetEditParameters } from 'src/dtos/editing.dto'; -import { asset_edit_audit, asset_edit_delete, asset_edit_insert } from 'src/schema/functions'; +import { asset_edit_delete, asset_edit_insert } from 'src/schema/functions'; import { AssetTable } from 'src/schema/tables/asset.table'; @Table('asset_edit') -@UpdatedAtTrigger('asset_edit_updatedAt') @AfterInsertTrigger({ scope: 'statement', function: asset_edit_insert, referencingNewTableAs: 'inserted_edit' }) @AfterDeleteTrigger({ scope: 'statement', @@ -24,12 +20,6 @@ import { AssetTable } from 'src/schema/tables/asset.table'; referencingOldTableAs: 'deleted_edit', when: 'pg_trigger_depth() = 0', }) -@AfterDeleteTrigger({ - scope: 'statement', - function: asset_edit_audit, - referencingOldTableAs: 'old', - when: 'pg_trigger_depth() = 0', -}) @Unique({ columns: ['assetId', 'sequence'] }) export class AssetEditTable { @PrimaryGeneratedColumn() @@ -46,10 +36,4 @@ export class AssetEditTable { @Column({ type: 'integer' }) sequence!: number; - - @UpdateDateColumn() - updatedAt!: Generated; - - @UpdateIdColumn({ index: true }) - updateId!: Generated; } diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index f41004dd1c..32225545bb 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -546,7 +546,6 @@ export class AssetService extends BaseService { async getAssetEdits(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); const edits = await this.assetEditRepository.getAll(id); - return { assetId: id, edits, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 7c9581ff9a..2a47745a6c 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -98,7 +98,6 @@ export class JobService extends BaseService { case JobName.AssetEditThumbnailGeneration: { const asset = await this.assetRepository.getById(item.data.id); - const edits = await this.assetEditRepository.getWithSyncInfo(item.data.id); if (asset) { this.websocketRepository.clientSend('AssetEditReadyV1', asset.ownerId, { @@ -123,7 +122,6 @@ export class JobService extends BaseService { height: asset.height, isEdited: asset.isEdited, }, - edit: edits, }); } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index fc825fb273..5317989739 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -23,11 +23,10 @@ import { MediaService } from 'src/services/media.service'; import { JobCounts, RawImageInfo } from 'src/types'; import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; -import { PersonFactory } from 'test/factories/person.factory'; import { probeStub } from 'test/fixtures/media.stub'; -import { personThumbnailStub } from 'test/fixtures/person.stub'; +import { personStub, personThumbnailStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { factory, newUuid } from 'test/small.factory'; +import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; const fullsizeBuffer = Buffer.from('embedded image data'); @@ -51,10 +50,9 @@ describe(MediaService.name, () => { describe('handleQueueGenerateThumbnails', () => { it('should queue all assets', async () => { const asset = AssetFactory.create(); - const person = PersonFactory.create({ faceAssetId: newUuid() }); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream([person])); + mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail])); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -70,7 +68,7 @@ describe(MediaService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { id: personStub.newThumbnail.id }, }, ]); }); @@ -108,13 +106,8 @@ describe(MediaService.name, () => { }); it('should queue all people with missing thumbnail path', async () => { - const [person1, person2] = [ - PersonFactory.create({ thumbnailPath: undefined }), - PersonFactory.create({ thumbnailPath: undefined }), - ]; - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([AssetFactory.create()])); - mocks.person.getAll.mockReturnValue(makeStream([person1, person2])); + mocks.person.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail])); mocks.person.getRandomFace.mockResolvedValueOnce(AssetFaceFactory.create()); await sut.handleQueueGenerateThumbnails({ force: false }); @@ -127,7 +120,7 @@ describe(MediaService.name, () => { { name: JobName.PersonGenerateThumbnail, data: { - id: person1.id, + id: personStub.newThumbnail.id, }, }, ]); @@ -283,17 +276,17 @@ describe(MediaService.name, () => { describe('handleQueueMigration', () => { it('should remove empty directories and queue jobs', async () => { const asset = AssetFactory.create(); - const person = PersonFactory.create(); - mocks.assetJob.streamForMigrationJob.mockReturnValue(makeStream([asset])); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); - mocks.person.getAll.mockReturnValue(makeStream([person])); + mocks.person.getAll.mockReturnValue(makeStream([personStub.withName])); await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.Success); expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2); expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.AssetFileMigration, data: { id: asset.id } }]); - expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.PersonFileMigration, data: { id: person.id } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.PersonFileMigration, data: { id: personStub.withName.id } }, + ]); }); }); @@ -1486,9 +1479,8 @@ describe(MediaService.name, () => { }); it('should skip a person without a face asset id', async () => { - const person = PersonFactory.create({ faceAssetId: null }); - mocks.person.getById.mockResolvedValue(person); - await sut.handleGeneratePersonThumbnail({ id: person.id }); + mocks.person.getById.mockResolvedValue(personStub.noThumbnail); + await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); @@ -1498,17 +1490,17 @@ describe(MediaService.name, () => { }); it('should generate a thumbnail', async () => { - const person = PersonFactory.create(); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( + JobStatus.Success, + ); - expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id); + expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.originalPath, { colorspace: Colorspace.P3, @@ -1539,21 +1531,21 @@ describe(MediaService.name, () => { }, expect.any(String), ); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) }); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', thumbnailPath: expect.any(String) }); }); it('should use preview path if video', async () => { - const person = PersonFactory.create(); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.videoThumbnail); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( + JobStatus.Success, + ); - expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id); + expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledWith(expect.any(String), { colorspace: Colorspace.P3, @@ -1584,19 +1576,19 @@ describe(MediaService.name, () => { }, expect.any(String), ); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) }); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', thumbnailPath: expect.any(String) }); }); it('should generate a thumbnail without going negative', async () => { - const person = PersonFactory.create(); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailStart); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 2160, height: 3840 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( + JobStatus.Success, + ); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailStart.originalPath, { colorspace: Colorspace.P3, @@ -1630,16 +1622,16 @@ describe(MediaService.name, () => { }); it('should generate a thumbnail without overflowing', async () => { - const person = PersonFactory.create(); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailEnd); - mocks.person.update.mockResolvedValue(person); + mocks.person.update.mockResolvedValue(personStub.primaryPerson); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( + JobStatus.Success, + ); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailEnd.originalPath, { colorspace: Colorspace.P3, @@ -1673,16 +1665,16 @@ describe(MediaService.name, () => { }); it('should handle negative coordinates', async () => { - const person = PersonFactory.create(); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.negativeCoordinate); - mocks.person.update.mockResolvedValue(person); + mocks.person.update.mockResolvedValue(personStub.primaryPerson); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 4624, height: 3080 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( + JobStatus.Success, + ); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.negativeCoordinate.originalPath, { colorspace: Colorspace.P3, @@ -1716,16 +1708,16 @@ describe(MediaService.name, () => { }); it('should handle overflowing coordinate', async () => { - const person = PersonFactory.create(); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.overflowingCoordinate); - mocks.person.update.mockResolvedValue(person); + mocks.person.update.mockResolvedValue(personStub.primaryPerson); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 4624, height: 3080 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( + JobStatus.Success, + ); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.overflowingCoordinate.originalPath, { colorspace: Colorspace.P3, @@ -1759,11 +1751,9 @@ describe(MediaService.name, () => { }); it('should use embedded preview if enabled and raw image', async () => { - const person = PersonFactory.create(); - mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail); - mocks.person.update.mockResolvedValue(person); + mocks.person.update.mockResolvedValue(personStub.primaryPerson); mocks.media.generateThumbnail.mockResolvedValue(); const extracted = Buffer.from(''); const data = Buffer.from(''); @@ -1772,7 +1762,9 @@ describe(MediaService.name, () => { mocks.media.decodeImage.mockResolvedValue({ data, info }); mocks.media.getImageMetadata.mockResolvedValue({ width: 2160, height: 3840, isTransparent: false }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( + JobStatus.Success, + ); expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath); expect(mocks.media.decodeImage).toHaveBeenCalledWith(extracted, { @@ -1807,23 +1799,21 @@ describe(MediaService.name, () => { }); it('should not use embedded preview if enabled and not raw image', async () => { - const person = PersonFactory.create(); - mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle); mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 2160, height: 3840 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( + JobStatus.Success, + ); expect(mocks.media.extract).not.toHaveBeenCalled(); expect(mocks.media.generateThumbnail).toHaveBeenCalled(); }); it('should not use embedded preview if enabled and raw image if not exists', async () => { - const person = PersonFactory.create(); - mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail); mocks.media.generateThumbnail.mockResolvedValue(); @@ -1831,7 +1821,9 @@ describe(MediaService.name, () => { const info = { width: 2160, height: 3840 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( + JobStatus.Success, + ); expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, { @@ -1843,8 +1835,6 @@ describe(MediaService.name, () => { }); it('should not use embedded preview if enabled and raw image if low resolution', async () => { - const person = PersonFactory.create(); - mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail); mocks.media.generateThumbnail.mockResolvedValue(); @@ -1855,7 +1845,9 @@ describe(MediaService.name, () => { mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg }); mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( + JobStatus.Success, + ); expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, { diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index feaba36b1d..1080407922 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -16,8 +16,8 @@ import { import { ImmichTags } from 'src/repositories/metadata.repository'; import { firstDateTime, MetadataService } from 'src/services/metadata.service'; import { AssetFactory } from 'test/factories/asset.factory'; -import { PersonFactory } from 'test/factories/person.factory'; import { probeStub } from 'test/fixtures/media.stub'; +import { personStub } from 'test/fixtures/person.stub'; import { tagStub } from 'test/fixtures/tag.stub'; import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -1208,18 +1208,18 @@ describe(MetadataService.name, () => { it('should apply metadata face tags creating new people', async () => { const asset = AssetFactory.create(); - const person = PersonFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); - mockReadTags(makeFaceTags({ Name: person.name })); + mockReadTags(makeFaceTags({ Name: personStub.withName.name })); mocks.person.getDistinctNames.mockResolvedValue([]); - mocks.person.createAll.mockResolvedValue([person.id]); - mocks.person.update.mockResolvedValue(person); + mocks.person.createAll.mockResolvedValue([personStub.withName.id]); + mocks.person.update.mockResolvedValue(personStub.withName); await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true }); - expect(mocks.person.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: person.name })]); + expect(mocks.person.createAll).toHaveBeenCalledWith([ + expect.objectContaining({ name: personStub.withName.name }), + ]); expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [ { @@ -1243,21 +1243,19 @@ describe(MetadataService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { id: personStub.withName.id }, }, ]); }); it('should assign metadata face tags to existing persons', async () => { const asset = AssetFactory.create(); - const person = PersonFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); - mockReadTags(makeFaceTags({ Name: person.name })); - mocks.person.getDistinctNames.mockResolvedValue([{ id: person.id, name: person.name }]); + mockReadTags(makeFaceTags({ Name: personStub.withName.name })); + mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); mocks.person.createAll.mockResolvedValue([]); - mocks.person.update.mockResolvedValue(person); + mocks.person.update.mockResolvedValue(personStub.withName); await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true }); @@ -1267,7 +1265,7 @@ describe(MetadataService.name, () => { { id: 'random-uuid', assetId: asset.id, - personId: person.id, + personId: personStub.withName.id, imageHeight: 100, imageWidth: 1000, boundingBoxX1: 0, @@ -1337,20 +1335,21 @@ describe(MetadataService.name, () => { async ({ orientation, expected }) => { const { imgW, imgH, x1, x2, y1, y2 } = expected; const asset = AssetFactory.create(); - const person = PersonFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); - mockReadTags(makeFaceTags({ Name: person.name }, orientation)); + mockReadTags(makeFaceTags({ Name: personStub.withName.name }, orientation)); mocks.person.getDistinctNames.mockResolvedValue([]); - mocks.person.createAll.mockResolvedValue([person.id]); - mocks.person.update.mockResolvedValue(person); + mocks.person.createAll.mockResolvedValue([personStub.withName.id]); + mocks.person.update.mockResolvedValue(personStub.withName); await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true, }); - expect(mocks.person.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: person.name })]); + expect(mocks.person.createAll).toHaveBeenCalledWith([ + expect.objectContaining({ name: personStub.withName.name }), + ]); expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [ { @@ -1374,7 +1373,7 @@ describe(MetadataService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { id: personStub.withName.id }, }, ]); }, diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index c22fd65a1a..d7c9fa9f59 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -1,6 +1,6 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; -import { mapFaces, mapPerson } from 'src/dtos/person.dto'; +import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum'; import { FaceSearchResult } from 'src/repositories/search.repository'; import { PersonService } from 'src/services/person.service'; @@ -11,11 +11,25 @@ import { AuthFactory } from 'test/factories/auth.factory'; import { PersonFactory } from 'test/factories/person.factory'; import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; +import { personStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { getAsDetectedFace, getForFacialRecognitionJob } from 'test/mappers'; import { newDate, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; +const responseDto: PersonResponseDto = { + id: 'person-1', + name: 'Person 1', + birthDate: null, + thumbnailPath: '/path/to/thumbnail.jpg', + isHidden: false, + updatedAt: expect.any(Date), + isFavorite: false, + color: expect.any(String), +}; + +const statistics = { assets: 3 }; + describe(PersonService.name, () => { let sut: PersonService; let mocks: ServiceMocks; @@ -30,54 +44,60 @@ describe(PersonService.name, () => { describe('getAll', () => { it('should get all hidden and visible people with thumbnails', async () => { - const auth = AuthFactory.create(); - const [person, hiddenPerson] = [PersonFactory.create(), PersonFactory.create({ isHidden: true })]; - mocks.person.getAllForUser.mockResolvedValue({ - items: [person, hiddenPerson], + items: [personStub.withName, personStub.hidden], hasNextPage: false, }); mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); - await expect(sut.getAll(auth, { withHidden: true, page: 1, size: 10 })).resolves.toEqual({ + await expect(sut.getAll(authStub.admin, { withHidden: true, page: 1, size: 10 })).resolves.toEqual({ hasNextPage: false, total: 2, hidden: 1, people: [ - expect.objectContaining({ id: person.id, isHidden: false }), - expect.objectContaining({ - id: hiddenPerson.id, + responseDto, + { + id: 'person-1', + name: '', + birthDate: null, + thumbnailPath: '/path/to/thumbnail.jpg', isHidden: true, - }), + isFavorite: false, + updatedAt: expect.any(Date), + color: expect.any(String), + }, ], }); - expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, { + expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, { minimumFaceCount: 3, withHidden: true, }); }); it('should get all visible people and favorites should be first in the array', async () => { - const auth = AuthFactory.create(); - const [isFavorite, person] = [PersonFactory.create({ isFavorite: true }), PersonFactory.create()]; - mocks.person.getAllForUser.mockResolvedValue({ - items: [isFavorite, person], + items: [personStub.isFavorite, personStub.withName], hasNextPage: false, }); mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); - await expect(sut.getAll(auth, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({ + await expect(sut.getAll(authStub.admin, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({ hasNextPage: false, total: 2, hidden: 1, people: [ - expect.objectContaining({ - id: isFavorite.id, + { + id: 'person-4', + name: personStub.isFavorite.name, + birthDate: null, + thumbnailPath: '/path/to/thumbnail.jpg', + isHidden: false, isFavorite: true, - }), - expect.objectContaining({ id: person.id, isFavorite: false }), + updatedAt: expect.any(Date), + color: personStub.isFavorite.color, + }, + responseDto, ], }); - expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, { + expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, { minimumFaceCount: 3, withHidden: false, }); @@ -86,89 +106,71 @@ describe(PersonService.name, () => { describe('getById', () => { it('should require person.read permission', async () => { - const auth = AuthFactory.create(); - const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); - await expect(sut.getById(auth, person.id)).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + mocks.person.getById.mockResolvedValue(personStub.withName); + await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw a bad request when person is not found', async () => { - const auth = AuthFactory.create(); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['unknown'])); - await expect(sut.getById(auth, 'unknown')).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['unknown'])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should get a person by id', async () => { - const auth = AuthFactory.create(); - const person = PersonFactory.create(); - - mocks.person.getById.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - await expect(sut.getById(auth, person.id)).resolves.toEqual(expect.objectContaining({ id: person.id })); - expect(mocks.person.getById).toHaveBeenCalledWith(person.id); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + mocks.person.getById.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto); + expect(mocks.person.getById).toHaveBeenCalledWith('person-1'); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('getThumbnail', () => { it('should require person.read permission', async () => { - const auth = AuthFactory.create(); - const person = PersonFactory.create(); - - mocks.person.getById.mockResolvedValue(person); - await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(BadRequestException); + mocks.person.getById.mockResolvedValue(personStub.noName); + await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when personId is invalid', async () => { - const auth = AuthFactory.create(); - - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['unknown'])); - await expect(sut.getThumbnail(auth, 'unknown')).rejects.toBeInstanceOf(NotFoundException); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['unknown'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when person has no thumbnail', async () => { - const auth = AuthFactory.create(); - const person = PersonFactory.create({ thumbnailPath: '' }); - - mocks.person.getById.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(NotFoundException); + mocks.person.getById.mockResolvedValue(personStub.noThumbnail); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should serve the thumbnail', async () => { - const auth = AuthFactory.create(); - const person = PersonFactory.create(); - - mocks.person.getById.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - await expect(sut.getThumbnail(auth, person.id)).resolves.toEqual( + mocks.person.getById.mockResolvedValue(personStub.noName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + await expect(sut.getThumbnail(authStub.admin, 'person-1')).resolves.toEqual( new ImmichFileResponse({ - path: person.thumbnailPath, + path: '/path/to/thumbnail.jpg', contentType: 'image/jpeg', cacheControl: CacheControl.PrivateWithoutCache, }), ); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('update', () => { it('should require person.write permission', async () => { - const auth = AuthFactory.create(); - const person = PersonFactory.create(); - - mocks.person.getById.mockResolvedValue(person); - await expect(sut.update(auth, person.id, { name: 'Person 1' })).rejects.toBeInstanceOf(BadRequestException); + mocks.person.getById.mockResolvedValue(personStub.noName); + await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf( + BadRequestException, + ); expect(mocks.person.update).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when personId is invalid', async () => { @@ -181,108 +183,86 @@ describe(PersonService.name, () => { }); it("should update a person's name", async () => { - const auth = AuthFactory.create(); - const person = PersonFactory.create({ name: 'Person 1' }); + mocks.person.update.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - mocks.person.update.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto); - await expect(sut.update(auth, person.id, { name: 'Person 1' })).resolves.toEqual( - expect.objectContaining({ id: person.id, name: 'Person 1' }), - ); - - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, name: 'Person 1' }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it("should update a person's date of birth", async () => { - const auth = AuthFactory.create(); - const person = PersonFactory.create({ birthDate: new Date('1976-06-30') }); + mocks.person.update.mockResolvedValue(personStub.withBirthDate); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - mocks.person.update.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - - await expect(sut.update(auth, person.id, { birthDate: new Date('1976-06-30') })).resolves.toEqual({ - id: person.id, - name: person.name, + await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({ + id: 'person-1', + name: 'Person 1', birthDate: '1976-06-30', - thumbnailPath: person.thumbnailPath, + thumbnailPath: '/path/to/thumbnail.jpg', isHidden: false, isFavorite: false, updatedAt: expect.any(Date), + color: expect.any(String), }); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, birthDate: new Date('1976-06-30') }); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') }); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should update a person visibility', async () => { - const auth = AuthFactory.create(); - const person = PersonFactory.create({ isHidden: true }); + mocks.person.update.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - mocks.person.update.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto); - await expect(sut.update(auth, person.id, { isHidden: true })).resolves.toEqual( - expect.objectContaining({ isHidden: true }), - ); - - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isHidden: true }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should update a person favorite status', async () => { - const auth = AuthFactory.create(); - const person = PersonFactory.create({ isFavorite: true }); + mocks.person.update.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - mocks.person.update.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + await expect(sut.update(authStub.admin, 'person-1', { isFavorite: true })).resolves.toEqual(responseDto); - await expect(sut.update(auth, person.id, { isFavorite: true })).resolves.toEqual( - expect.objectContaining({ isFavorite: true }), - ); - - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isFavorite: true }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', isFavorite: true }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it("should update a person's thumbnailPath", async () => { const face = AssetFaceFactory.create(); const auth = AuthFactory.create(); - const person = PersonFactory.create(); - - mocks.person.update.mockResolvedValue(person); + mocks.person.update.mockResolvedValue(personStub.withName); mocks.person.getForFeatureFaceUpdate.mockResolvedValue(face); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([face.assetId])); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - await expect(sut.update(auth, person.id, { featureFaceAssetId: face.assetId })).resolves.toEqual( - expect.objectContaining({ id: person.id }), - ); + await expect(sut.update(auth, 'person-1', { featureFaceAssetId: face.assetId })).resolves.toEqual(responseDto); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: face.id }); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: face.id }); expect(mocks.person.getForFeatureFaceUpdate).toHaveBeenCalledWith({ assetId: face.assetId, - personId: person.id, + personId: 'person-1', }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { id: 'person-1' }, }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['person-1'])); }); it('should throw an error when the face feature assetId is invalid', async () => { - const auth = AuthFactory.create(); - const person = PersonFactory.create(); + mocks.person.getById.mockResolvedValue(personStub.withName); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - mocks.person.getById.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - - await expect(sut.update(auth, person.id, { featureFaceAssetId: '-1' })).rejects.toThrow(BadRequestException); + await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow( + BadRequestException, + ); expect(mocks.person.update).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); @@ -303,39 +283,36 @@ describe(PersonService.name, () => { mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set()); await expect( - sut.reassignFaces(AuthFactory.create(), 'person-id', { + sut.reassignFaces(authStub.admin, personStub.noName.id, { data: [{ personId: 'asset-face-1', assetId: '' }], }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.job.queue).not.toHaveBeenCalledWith(); expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); - it('should reassign a face', async () => { const face = AssetFaceFactory.create(); const auth = AuthFactory.create(); - const person = PersonFactory.create(); - - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - mocks.person.getById.mockResolvedValue(person); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id])); + mocks.person.getById.mockResolvedValue(personStub.noName); mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); mocks.person.getFacesByIds.mockResolvedValue([face]); mocks.person.reassignFace.mockResolvedValue(1); mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create()); mocks.person.refreshFaces.mockResolvedValue(); mocks.person.reassignFace.mockResolvedValue(5); - mocks.person.update.mockResolvedValue(person); + mocks.person.update.mockResolvedValue(personStub.noName); await expect( - sut.reassignFaces(auth, person.id, { - data: [{ personId: person.id, assetId: face.assetId }], + sut.reassignFaces(auth, personStub.noName.id, { + data: [{ personId: personStub.withName.id, assetId: face.assetId }], }), ).resolves.toBeDefined(); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { id: personStub.newThumbnail.id }, }, ]); }); @@ -343,7 +320,7 @@ describe(PersonService.name, () => { describe('handlePersonMigration', () => { it('should not move person files', async () => { - await expect(sut.handlePersonMigration(PersonFactory.create())).resolves.toBe(JobStatus.Failed); + await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.Failed); }); }); @@ -370,14 +347,12 @@ describe(PersonService.name, () => { describe('createNewFeaturePhoto', () => { it('should change person feature photo', async () => { - const person = PersonFactory.create(); - mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create()); - await sut.createNewFeaturePhoto([person.id]); + await sut.createNewFeaturePhoto([personStub.newThumbnail.id]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { id: personStub.newThumbnail.id }, }, ]); }); @@ -386,22 +361,23 @@ describe(PersonService.name, () => { describe('reassignFacesById', () => { it('should create a new person', async () => { const face = AssetFaceFactory.create(); - const person = PersonFactory.create(); - - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); mocks.person.getFaceById.mockResolvedValue(face); mocks.person.reassignFace.mockResolvedValue(1); - mocks.person.getById.mockResolvedValue(person); - await expect(sut.reassignFacesById(AuthFactory.create(), person.id, { id: face.id })).resolves.toEqual({ - birthDate: person.birthDate, - isHidden: person.isHidden, - isFavorite: person.isFavorite, - id: person.id, - name: person.name, - thumbnailPath: person.thumbnailPath, - updatedAt: expect.any(Date), - }); + mocks.person.getById.mockResolvedValue(personStub.noName); + await expect(sut.reassignFacesById(AuthFactory.create(), personStub.noName.id, { id: face.id })).resolves.toEqual( + { + birthDate: personStub.noName.birthDate, + isHidden: personStub.noName.isHidden, + isFavorite: personStub.noName.isFavorite, + id: personStub.noName.id, + name: personStub.noName.name, + thumbnailPath: personStub.noName.thumbnailPath, + updatedAt: expect.any(Date), + color: personStub.noName.color, + }, + ); expect(mocks.job.queue).not.toHaveBeenCalledWith(); expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); @@ -409,14 +385,12 @@ describe(PersonService.name, () => { it('should fail if user has not the correct permissions on the asset', async () => { const face = AssetFaceFactory.create(); - const person = PersonFactory.create(); - - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); mocks.person.getFaceById.mockResolvedValue(face); mocks.person.reassignFace.mockResolvedValue(1); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getById.mockResolvedValue(personStub.noName); await expect( - sut.reassignFacesById(AuthFactory.create(), person.id, { + sut.reassignFacesById(AuthFactory.create(), personStub.noName.id, { id: face.id, }), ).rejects.toBeInstanceOf(BadRequestException); @@ -428,25 +402,22 @@ describe(PersonService.name, () => { describe('createPerson', () => { it('should create a new person', async () => { - const auth = AuthFactory.create(); + mocks.person.create.mockResolvedValue(personStub.primaryPerson); - mocks.person.create.mockResolvedValue(PersonFactory.create()); - await expect(sut.create(auth, {})).resolves.toBeDefined(); + await expect(sut.create(authStub.admin, {})).resolves.toBeDefined(); - expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: auth.user.id }); + expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id }); }); }); describe('handlePersonCleanup', () => { it('should delete people without faces', async () => { - const person = PersonFactory.create(); - - mocks.person.getAllWithoutFaces.mockResolvedValue([person]); + mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.noName]); await sut.handlePersonCleanup(); - expect(mocks.person.delete).toHaveBeenCalledWith([person.id]); - expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.noName.id]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.noName.thumbnailPath); }); }); @@ -478,17 +449,15 @@ describe(PersonService.name, () => { it('should queue all assets', async () => { const asset = AssetFactory.create(); - const person = PersonFactory.create(); - mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); - mocks.person.getAllWithoutFaces.mockResolvedValue([person]); + mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.withName]); await sut.handleQueueDetectFaces({ force: true }); expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MachineLearning }); - expect(mocks.person.delete).toHaveBeenCalledWith([person.id]); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]); expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true }); - expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath); + expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath); expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { @@ -521,12 +490,10 @@ describe(PersonService.name, () => { it('should delete existing people and faces if forced', async () => { const asset = AssetFactory.create(); const face = AssetFaceFactory.from().person().build(); - const person = PersonFactory.create(); - - mocks.person.getAll.mockReturnValue(makeStream([face.person!, person])); + mocks.person.getAll.mockReturnValue(makeStream([face.person!, personStub.randomPerson])); mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); - mocks.person.getAllWithoutFaces.mockResolvedValue([person]); + mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); mocks.person.deleteFaces.mockResolvedValue(); await sut.handleQueueDetectFaces({ force: true }); @@ -538,8 +505,8 @@ describe(PersonService.name, () => { data: { id: asset.id }, }, ]); - expect(mocks.person.delete).toHaveBeenCalledWith([person.id]); - expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true }); }); }); @@ -694,8 +661,6 @@ describe(PersonService.name, () => { it('should delete existing people if forced', async () => { const face = AssetFaceFactory.from().person().build(); - const person = PersonFactory.create(); - mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, @@ -704,9 +669,9 @@ describe(PersonService.name, () => { failed: 0, delayed: 0, }); - mocks.person.getAll.mockReturnValue(makeStream([face.person!, person])); + mocks.person.getAll.mockReturnValue(makeStream([face.person!, personStub.randomPerson])); mocks.person.getAllFaces.mockReturnValue(makeStream([face])); - mocks.person.getAllWithoutFaces.mockResolvedValue([person]); + mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); mocks.person.unassignFaces.mockResolvedValue(); await sut.handleQueueRecognizeFaces({ force: true }); @@ -719,8 +684,8 @@ describe(PersonService.name, () => { data: { id: face.id, deferred: false }, }, ]); - expect(mocks.person.delete).toHaveBeenCalledWith([person.id]); - expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]); + expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false }); }); }); @@ -1094,71 +1059,59 @@ describe(PersonService.name, () => { describe('mergePerson', () => { it('should require person.write and person.merge permission', async () => { - const auth = AuthFactory.create(); - const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()]; + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson); - mocks.person.getById.mockResolvedValueOnce(person); - mocks.person.getById.mockResolvedValueOnce(mergePerson); - - await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).rejects.toBeInstanceOf( + await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( BadRequestException, ); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); expect(mocks.person.delete).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should merge two people without smart merge', async () => { - const auth = AuthFactory.create(); - const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()]; + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); - mocks.person.getById.mockResolvedValueOnce(person); - mocks.person.getById.mockResolvedValueOnce(mergePerson); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id])); - - await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([ - { id: mergePerson.id, success: true }, + await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ + { id: 'person-2', success: true }, ]); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - newPersonId: person.id, - oldPersonId: mergePerson.id, + newPersonId: personStub.primaryPerson.id, + oldPersonId: personStub.mergePerson.id, }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should merge two people with smart merge', async () => { - const auth = AuthFactory.create(); - const [person, mergePerson] = [ - PersonFactory.create({ name: undefined }), - PersonFactory.create({ name: 'Merge person' }), - ]; + mocks.person.getById.mockResolvedValueOnce(personStub.randomPerson); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.update.mockResolvedValue({ ...personStub.randomPerson, name: personStub.primaryPerson.name }); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3'])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); - mocks.person.getById.mockResolvedValueOnce(person); - mocks.person.getById.mockResolvedValueOnce(mergePerson); - mocks.person.update.mockResolvedValue({ ...person, name: mergePerson.name }); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id])); - - await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([ - { id: mergePerson.id, success: true }, + await expect(sut.mergePerson(authStub.admin, 'person-3', { ids: ['person-1'] })).resolves.toEqual([ + { id: 'person-1', success: true }, ]); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - newPersonId: person.id, - oldPersonId: mergePerson.id, + newPersonId: personStub.randomPerson.id, + oldPersonId: personStub.primaryPerson.id, }); expect(mocks.person.update).toHaveBeenCalledWith({ - id: person.id, - name: mergePerson.name, + id: personStub.randomPerson.id, + name: personStub.primaryPerson.name, }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should throw an error when the primary person is not found', async () => { @@ -1173,60 +1126,48 @@ describe(PersonService.name, () => { }); it('should handle invalid merge ids', async () => { - const auth = AuthFactory.create(); - const person = PersonFactory.create(); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); - mocks.person.getById.mockResolvedValueOnce(person); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['unknown'])); - - await expect(sut.mergePerson(auth, person.id, { ids: ['unknown'] })).resolves.toEqual([ - { id: 'unknown', success: false, error: BulkIdErrorReason.NOT_FOUND }, + await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ + { id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, ]); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); expect(mocks.person.delete).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should handle an error reassigning faces', async () => { - const auth = AuthFactory.create(); - const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()]; - - mocks.person.getById.mockResolvedValueOnce(person); - mocks.person.getById.mockResolvedValueOnce(mergePerson); + mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); + mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson); mocks.person.reassignFaces.mockRejectedValue(new Error('update failed')); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); + mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); - await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([ - { id: mergePerson.id, success: false, error: BulkIdErrorReason.UNKNOWN }, + await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ + { id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN }, ]); expect(mocks.person.delete).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); describe('getStatistics', () => { it('should get correct number of person', async () => { - const auth = AuthFactory.create(); - const person = PersonFactory.create(); - - mocks.person.getById.mockResolvedValue(person); - mocks.person.getStatistics.mockResolvedValue({ assets: 3 }); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - await expect(sut.getStatistics(auth, person.id)).resolves.toEqual({ assets: 3 }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + mocks.person.getById.mockResolvedValue(personStub.primaryPerson); + mocks.person.getStatistics.mockResolvedValue(statistics); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should require person.read permission', async () => { - const auth = AuthFactory.create(); - const person = PersonFactory.create(); - - mocks.person.getById.mockResolvedValue(person); - await expect(sut.getStatistics(auth, person.id)).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + mocks.person.getById.mockResolvedValue(personStub.primaryPerson); + await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); + expect(mocks.access.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 8a902590e3..090b358223 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -595,7 +595,7 @@ export class PersonService extends BaseService { update.birthDate = mergePerson.birthDate; } - if (Object.keys(update).length > 1) { + if (Object.keys(update).length > 0) { primaryPerson = await this.personRepository.update(update); } diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index 62575d0f07..5f1125eaed 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -5,6 +5,7 @@ import { SearchService } from 'src/services/search.service'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; import { authStub } from 'test/fixtures/auth.stub'; +import { personStub } from 'test/fixtures/person.stub'; import { newTestService, ServiceMocks } from 'test/utils'; import { beforeEach, vitest } from 'vitest'; @@ -25,18 +26,17 @@ describe(SearchService.name, () => { describe('searchPerson', () => { it('should pass options to search', async () => { - const auth = AuthFactory.create(); - const name = 'foo'; + const { name } = personStub.withName; mocks.person.getByName.mockResolvedValue([]); - await sut.searchPerson(auth, { name, withHidden: false }); + await sut.searchPerson(authStub.user1, { name, withHidden: false }); - expect(mocks.person.getByName).toHaveBeenCalledWith(auth.user.id, name, { withHidden: false }); + expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false }); - await sut.searchPerson(auth, { name, withHidden: true }); + await sut.searchPerson(authStub.user1, { name, withHidden: true }); - expect(mocks.person.getByName).toHaveBeenCalledWith(auth.user.id, name, { withHidden: true }); + expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: true }); }); }); diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index b0f4f23fad..1ea357850a 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -91,7 +91,6 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.AssetFacesV2, SyncRequestType.UserMetadataV1, SyncRequestType.AssetMetadataV1, - SyncRequestType.AssetEditsV1, ]; const throwSessionRequired = () => { @@ -178,7 +177,6 @@ export class SyncService extends BaseService { [SyncRequestType.PartnersV1]: () => this.syncPartnersV1(options, response, checkpointMap), [SyncRequestType.AssetsV1]: () => this.syncAssetsV1(options, response, checkpointMap), [SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(options, response, checkpointMap), - [SyncRequestType.AssetEditsV1]: () => this.syncAssetEditsV1(options, response, checkpointMap), [SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(options, response, checkpointMap, session.id), [SyncRequestType.AssetMetadataV1]: () => this.syncAssetMetadataV1(options, response, checkpointMap, auth), [SyncRequestType.PartnerAssetExifsV1]: () => @@ -220,7 +218,6 @@ export class SyncService extends BaseService { await this.syncRepository.asset.cleanupAuditTable(pruneThreshold); await this.syncRepository.assetFace.cleanupAuditTable(pruneThreshold); await this.syncRepository.assetMetadata.cleanupAuditTable(pruneThreshold); - await this.syncRepository.assetEdit.cleanupAuditTable(pruneThreshold); await this.syncRepository.memory.cleanupAuditTable(pruneThreshold); await this.syncRepository.memoryToAsset.cleanupAuditTable(pruneThreshold); await this.syncRepository.partner.cleanupAuditTable(pruneThreshold); @@ -359,21 +356,6 @@ export class SyncService extends BaseService { } } - private async syncAssetEditsV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { - const deleteType = SyncEntityType.AssetEditDeleteV1; - const deletes = this.syncRepository.assetEdit.getDeletes({ ...options, ack: checkpointMap[deleteType] }); - - for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); - } - const upsertType = SyncEntityType.AssetEditV1; - const upserts = this.syncRepository.assetEdit.getUpserts({ ...options, ack: checkpointMap[upsertType] }); - - for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); - } - } - private async syncPartnerAssetExifsV1( options: SyncQueryOptions, response: Writable, diff --git a/server/test/factories/asset-edit.factory.ts b/server/test/factories/asset-edit.factory.ts index 897ed26d61..ba15da4b69 100644 --- a/server/test/factories/asset-edit.factory.ts +++ b/server/test/factories/asset-edit.factory.ts @@ -4,7 +4,7 @@ import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; import { AssetFactory } from 'test/factories/asset.factory'; import { build } from 'test/factories/builder.factory'; import { AssetEditLike, AssetLike, FactoryBuilder } from 'test/factories/types'; -import { newDate, newUuid } from 'test/small.factory'; +import { newUuid } from 'test/small.factory'; export class AssetEditFactory { private constructor(private readonly value: Selectable) {} @@ -15,7 +15,6 @@ export class AssetEditFactory { static from(dto: AssetEditLike = {}) { const id = dto.id ?? newUuid(); - const updateId = dto.updateId ?? newUuid(); return new AssetEditFactory({ id, @@ -23,8 +22,6 @@ export class AssetEditFactory { action: AssetEditAction.Crop, parameters: { x: 5, y: 6, width: 200, height: 100 }, sequence: 1, - updateId, - updatedAt: newDate(), ...dto, }); } diff --git a/server/test/fixtures/person.stub.ts b/server/test/fixtures/person.stub.ts index 6ab32e1f02..9d48fcc8f8 100644 --- a/server/test/fixtures/person.stub.ts +++ b/server/test/fixtures/person.stub.ts @@ -2,6 +2,171 @@ import { AssetFileType, AssetType } from 'src/enum'; import { AssetFileFactory } from 'test/factories/asset-file.factory'; import { userStub } from 'test/fixtures/user.stub'; +const updateId = '0d1173e3-4d80-4d76-b41e-57d56de21125'; + +export const personStub = { + noName: Object.freeze({ + id: 'person-1', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + updateId, + ownerId: userStub.admin.id, + name: '', + birthDate: null, + thumbnailPath: '/path/to/thumbnail.jpg', + faces: [], + faceAssetId: null, + faceAsset: null, + isHidden: false, + isFavorite: false, + color: 'red', + }), + hidden: Object.freeze({ + id: 'person-1', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + updateId, + ownerId: userStub.admin.id, + name: '', + birthDate: null, + thumbnailPath: '/path/to/thumbnail.jpg', + faces: [], + faceAssetId: null, + faceAsset: null, + isHidden: true, + isFavorite: false, + color: 'red', + }), + withName: Object.freeze({ + id: 'person-1', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + updateId, + ownerId: userStub.admin.id, + name: 'Person 1', + birthDate: null, + thumbnailPath: '/path/to/thumbnail.jpg', + faces: [], + faceAssetId: 'assetFaceId', + faceAsset: null, + isHidden: false, + isFavorite: false, + color: 'red', + }), + withBirthDate: Object.freeze({ + id: 'person-1', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + updateId, + ownerId: userStub.admin.id, + name: 'Person 1', + birthDate: new Date('1976-06-30'), + thumbnailPath: '/path/to/thumbnail.jpg', + faces: [], + faceAssetId: null, + faceAsset: null, + isHidden: false, + isFavorite: false, + color: 'red', + }), + noThumbnail: Object.freeze({ + id: 'person-1', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + updateId, + ownerId: userStub.admin.id, + name: '', + birthDate: null, + thumbnailPath: '', + faces: [], + faceAssetId: null, + faceAsset: null, + isHidden: false, + isFavorite: false, + color: 'red', + }), + newThumbnail: Object.freeze({ + id: 'person-1', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + updateId, + ownerId: userStub.admin.id, + name: '', + birthDate: null, + thumbnailPath: '/new/path/to/thumbnail.jpg', + faces: [], + faceAssetId: 'asset-id', + faceAsset: null, + isHidden: false, + isFavorite: false, + color: 'red', + }), + primaryPerson: Object.freeze({ + id: 'person-1', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + updateId, + ownerId: userStub.admin.id, + name: 'Person 1', + birthDate: null, + thumbnailPath: '/path/to/thumbnail', + faces: [], + faceAssetId: null, + faceAsset: null, + isHidden: false, + isFavorite: false, + color: 'red', + }), + mergePerson: Object.freeze({ + id: 'person-2', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + updateId, + ownerId: userStub.admin.id, + name: 'Person 2', + birthDate: null, + thumbnailPath: '/path/to/thumbnail', + faces: [], + faceAssetId: null, + faceAsset: null, + isHidden: false, + isFavorite: false, + color: 'red', + }), + randomPerson: Object.freeze({ + id: 'person-3', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + updateId, + ownerId: userStub.admin.id, + name: '', + birthDate: null, + thumbnailPath: '/path/to/thumbnail', + faces: [], + faceAssetId: null, + faceAsset: null, + isHidden: false, + isFavorite: false, + color: 'red', + }), + isFavorite: Object.freeze({ + id: 'person-4', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + updateId, + ownerId: userStub.admin.id, + name: 'Person 1', + birthDate: null, + thumbnailPath: '/path/to/thumbnail.jpg', + faces: [], + faceAssetId: 'assetFaceId', + faceAsset: null, + isHidden: false, + isFavorite: true, + color: 'red', + }), +}; + export const personThumbnailStub = { newThumbnailStart: Object.freeze({ ownerId: userStub.admin.id, diff --git a/server/test/medium/specs/services/audit.database.spec.ts b/server/test/medium/specs/services/audit.database.spec.ts index b4ddf78a4f..7506fcf2c3 100644 --- a/server/test/medium/specs/services/audit.database.spec.ts +++ b/server/test/medium/specs/services/audit.database.spec.ts @@ -1,5 +1,3 @@ -import { AssetEditAction } from 'src/dtos/editing.dto'; -import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { UserRepository } from 'src/repositories/user.repository'; @@ -47,27 +45,6 @@ describe('audit', () => { }); }); - describe('asset_edit_audit', () => { - it('should not cascade asset deletes to asset_edit_audit', async () => { - const assetEditRepo = ctx.get(AssetEditRepository); - const { user } = await ctx.newUser(); - const { asset } = await ctx.newAsset({ ownerId: user.id }); - - await assetEditRepo.replaceAll(asset.id, [ - { - action: AssetEditAction.Crop, - parameters: { x: 10, y: 20, width: 100, height: 200 }, - }, - ]); - - await ctx.database.deleteFrom('asset').where('id', '=', asset.id).execute(); - - await expect( - ctx.database.selectFrom('asset_edit_audit').select(['id']).where('assetId', '=', asset.id).execute(), - ).resolves.toHaveLength(0); - }); - }); - describe('assets_audit', () => { it('should not cascade user deletes to assets_audit', async () => { const userRepo = ctx.get(UserRepository); diff --git a/server/test/medium/specs/services/sync.service.spec.ts b/server/test/medium/specs/services/sync.service.spec.ts index c040d584b8..b5443d7e62 100644 --- a/server/test/medium/specs/services/sync.service.spec.ts +++ b/server/test/medium/specs/services/sync.service.spec.ts @@ -1,10 +1,9 @@ -import { schemaFromCode } from '@immich/sql-tools'; import { Kysely } from 'kysely'; import { DateTime } from 'luxon'; import { AssetMetadataKey, UserMetadataKey } from 'src/enum'; import { DatabaseRepository } from 'src/repositories/database.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { BaseSync, SyncRepository } from 'src/repositories/sync.repository'; +import { SyncRepository } from 'src/repositories/sync.repository'; import { DB } from 'src/schema'; import { SyncService } from 'src/services/sync.service'; import { newMediumService } from 'test/medium.factory'; @@ -223,21 +222,5 @@ describe(SyncService.name, () => { expect(after).toHaveLength(1); expect(after[0].id).toBe(keep.id); }); - - it('should cleanup every table', async () => { - const { sut } = setup(); - - const auditTables = schemaFromCode() - .tables.filter((table) => table.name.endsWith('_audit')) - .map(({ name }) => name); - - const auditCleanupSpy = vi.spyOn(BaseSync.prototype as any, 'auditCleanup'); - await expect(sut.onAuditTableCleanup()).resolves.toBeUndefined(); - - expect(auditCleanupSpy).toHaveBeenCalledTimes(auditTables.length); - for (const table of auditTables) { - expect(auditCleanupSpy, `Audit table ${table} was not cleaned up`).toHaveBeenCalledWith(table, 31); - } - }); }); }); diff --git a/server/test/medium/specs/sync/sync-asset-edit.spec.ts b/server/test/medium/specs/sync/sync-asset-edit.spec.ts deleted file mode 100644 index 43b2450b49..0000000000 --- a/server/test/medium/specs/sync/sync-asset-edit.spec.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { Kysely } from 'kysely'; -import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto'; -import { SyncEntityType, SyncRequestType } from 'src/enum'; -import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; -import { DB } from 'src/schema'; -import { SyncTestContext } from 'test/medium.factory'; -import { factory } from 'test/small.factory'; -import { getKyselyDB } from 'test/utils'; - -let defaultDatabase: Kysely; - -const setup = async (db?: Kysely) => { - const ctx = new SyncTestContext(db || defaultDatabase); - const { auth, user, session } = await ctx.newSyncAuthUser(); - return { auth, user, session, ctx }; -}; - -beforeAll(async () => { - defaultDatabase = await getKyselyDB(); -}); - -describe(SyncRequestType.AssetEditsV1, () => { - it('should detect and sync the first asset edit', async () => { - const { auth, ctx } = await setup(); - const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); - const assetEditRepo = ctx.get(AssetEditRepository); - - await assetEditRepo.replaceAll(asset.id, [ - { - action: AssetEditAction.Crop, - parameters: { x: 10, y: 20, width: 100, height: 200 }, - }, - ]); - - const response = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); - expect(response).toEqual([ - { - ack: expect.any(String), - data: { - id: expect.any(String), - assetId: asset.id, - action: AssetEditAction.Crop, - parameters: { x: 10, y: 20, width: 100, height: 200 }, - sequence: 0, - }, - type: SyncEntityType.AssetEditV1, - }, - expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), - ]); - - await ctx.syncAckAll(auth, response); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); - }); - - it('should detect and sync multiple asset edits for the same asset', async () => { - const { auth, ctx } = await setup(); - const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); - const assetEditRepo = ctx.get(AssetEditRepository); - - await assetEditRepo.replaceAll(asset.id, [ - { - action: AssetEditAction.Crop, - parameters: { x: 10, y: 20, width: 100, height: 200 }, - }, - { - action: AssetEditAction.Rotate, - parameters: { angle: 90 }, - }, - { - action: AssetEditAction.Mirror, - parameters: { axis: MirrorAxis.Horizontal }, - }, - ]); - - const response = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); - expect(response).toEqual( - expect.arrayContaining([ - { - ack: expect.any(String), - data: { - id: expect.any(String), - assetId: asset.id, - action: AssetEditAction.Crop, - parameters: { x: 10, y: 20, width: 100, height: 200 }, - sequence: 0, - }, - type: SyncEntityType.AssetEditV1, - }, - { - ack: expect.any(String), - data: { - id: expect.any(String), - assetId: asset.id, - action: AssetEditAction.Rotate, - parameters: { angle: 90 }, - sequence: 1, - }, - type: SyncEntityType.AssetEditV1, - }, - { - ack: expect.any(String), - data: { - id: expect.any(String), - assetId: asset.id, - action: AssetEditAction.Mirror, - parameters: { axis: MirrorAxis.Horizontal }, - sequence: 2, - }, - type: SyncEntityType.AssetEditV1, - }, - expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), - ]), - ); - - await ctx.syncAckAll(auth, response); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); - }); - - it('should detect and sync updated edits', async () => { - const { auth, ctx } = await setup(); - const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); - const assetEditRepo = ctx.get(AssetEditRepository); - - // Create initial edit - const edits = await assetEditRepo.replaceAll(asset.id, [ - { - action: AssetEditAction.Crop, - parameters: { x: 10, y: 20, width: 100, height: 200 }, - }, - ]); - - const response1 = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); - await ctx.syncAckAll(auth, response1); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); - - // update the existing edit - await ctx.database - .updateTable('asset_edit') - .set({ - parameters: { x: 50, y: 60, width: 150, height: 250 }, - }) - .where('id', '=', edits[0].id) - .execute(); - - const response2 = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); - expect(response2).toEqual( - expect.arrayContaining([ - { - ack: expect.any(String), - data: { - id: expect.any(String), - assetId: asset.id, - action: AssetEditAction.Crop, - parameters: { x: 50, y: 60, width: 150, height: 250 }, - sequence: 0, - }, - type: SyncEntityType.AssetEditV1, - }, - expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), - ]), - ); - - await ctx.syncAckAll(auth, response2); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); - }); - - it('should detect and sync deleted asset edits', async () => { - const { auth, ctx } = await setup(); - const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); - const assetEditRepo = ctx.get(AssetEditRepository); - - // Create initial edit - const edits = await assetEditRepo.replaceAll(asset.id, [ - { - action: AssetEditAction.Crop, - parameters: { x: 10, y: 20, width: 100, height: 200 }, - }, - ]); - - const response1 = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); - await ctx.syncAckAll(auth, response1); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); - - // Delete all edits - await assetEditRepo.replaceAll(asset.id, []); - - const response2 = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); - expect(response2).toEqual( - expect.arrayContaining([ - { - ack: expect.any(String), - data: { - editId: edits[0].id, - }, - type: SyncEntityType.AssetEditDeleteV1, - }, - expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), - ]), - ); - - await ctx.syncAckAll(auth, response2); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); - }); - - it('should only sync asset edits for own user', async () => { - const { auth, ctx } = await setup(); - const { user: user2 } = await ctx.newUser(); - const { asset } = await ctx.newAsset({ ownerId: user2.id }); - const assetEditRepo = ctx.get(AssetEditRepository); - const { session } = await ctx.newSession({ userId: user2.id }); - const auth2 = factory.auth({ session, user: user2 }); - - await assetEditRepo.replaceAll(asset.id, [ - { - action: AssetEditAction.Crop, - parameters: { x: 10, y: 20, width: 100, height: 200 }, - }, - ]); - - // User 2 should see their own edit - await expect(ctx.syncStream(auth2, [SyncRequestType.AssetEditsV1])).resolves.toEqual([ - expect.objectContaining({ type: SyncEntityType.AssetEditV1 }), - expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), - ]); - - // User 1 should not see user 2's edit - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); - }); - - it('should sync edits for multiple assets', async () => { - const { auth, ctx } = await setup(); - const { asset: asset1 } = await ctx.newAsset({ ownerId: auth.user.id }); - const { asset: asset2 } = await ctx.newAsset({ ownerId: auth.user.id }); - const assetEditRepo = ctx.get(AssetEditRepository); - - await assetEditRepo.replaceAll(asset1.id, [ - { - action: AssetEditAction.Crop, - parameters: { x: 10, y: 20, width: 100, height: 200 }, - }, - ]); - - await assetEditRepo.replaceAll(asset2.id, [ - { - action: AssetEditAction.Rotate, - parameters: { angle: 270 }, - }, - ]); - - const response = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); - expect(response).toEqual( - expect.arrayContaining([ - { - ack: expect.any(String), - data: { - id: expect.any(String), - assetId: asset1.id, - action: AssetEditAction.Crop, - parameters: { x: 10, y: 20, width: 100, height: 200 }, - sequence: 0, - }, - type: SyncEntityType.AssetEditV1, - }, - { - ack: expect.any(String), - data: { - id: expect.any(String), - assetId: asset2.id, - action: AssetEditAction.Rotate, - parameters: { angle: 270 }, - sequence: 0, - }, - type: SyncEntityType.AssetEditV1, - }, - expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), - ]), - ); - - await ctx.syncAckAll(auth, response); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); - }); - - it('should not sync edits for partner assets', async () => { - const { auth, ctx } = await setup(); - const { user: partner } = await ctx.newUser(); - await ctx.newPartner({ sharedById: partner.id, sharedWithId: auth.user.id }); - const { asset } = await ctx.newAsset({ ownerId: partner.id }); - const assetEditRepo = ctx.get(AssetEditRepository); - - await assetEditRepo.replaceAll(asset.id, [ - { - action: AssetEditAction.Crop, - parameters: { x: 10, y: 20, width: 100, height: 200 }, - }, - ]); - - // Should not see partner's asset edits in own sync - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); - }); -}); diff --git a/web/src/lib/components/pages/SharedLinkErrorPage.svelte b/web/src/lib/components/pages/SharedLinkErrorPage.svelte index be03417d85..590f4cb270 100644 --- a/web/src/lib/components/pages/SharedLinkErrorPage.svelte +++ b/web/src/lib/components/pages/SharedLinkErrorPage.svelte @@ -1,14 +1,13 @@ - {$t('error')} - Immich + Oops! Error - Immich
-

{$t('errors.page_not_found')}

+

Page not found :/

{#if page.error?.message}

{page.error.message}

{/if} diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 204e44f84e..32aa52fccb 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -12,7 +12,6 @@ import { type MaintenanceStatusResponseDto, type NotificationDto, type ServerVersionResponseDto, - type SyncAssetEditV1, type SyncAssetV1, } from '@immich/sdk'; import { io, type Socket } from 'socket.io-client'; @@ -42,7 +41,7 @@ export interface Events { AppRestartV1: (event: AppRestartEvent) => void; MaintenanceStatusV1: (event: MaintenanceStatusResponseDto) => void; - AssetEditReadyV1: (data: { asset: SyncAssetV1; edit: SyncAssetEditV1[] }) => void; + AssetEditReadyV1: (data: { asset: SyncAssetV1 }) => void; } const websocket: Socket = io({