mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into feat/mobile-ocr
This commit is contained in:
+1
-1
Submodule e2e/test-assets updated: 163c251744...4a0b5a2f69
@@ -1074,6 +1074,7 @@
|
|||||||
"failed_to_update_notification_status": "Failed to update notification status",
|
"failed_to_update_notification_status": "Failed to update notification status",
|
||||||
"incorrect_email_or_password": "Incorrect email or password",
|
"incorrect_email_or_password": "Incorrect email or password",
|
||||||
"library_folder_already_exists": "This import path already exists.",
|
"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",
|
"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.",
|
"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",
|
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode) async* {
|
Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode, {bool evictOnError = true}) async* {
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
this.request = null;
|
this.request = null;
|
||||||
PaintingBinding.instance.imageCache.evict(this);
|
PaintingBinding.instance.imageCache.evict(this);
|
||||||
@@ -57,14 +57,19 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final image = await request.load(decode);
|
final image = await request.load(decode);
|
||||||
if (image == null || isCancelled) {
|
if ((image == null && evictOnError) || isCancelled) {
|
||||||
PaintingBinding.instance.imageCache.evict(this);
|
PaintingBinding.instance.imageCache.evict(this);
|
||||||
return;
|
return;
|
||||||
|
} else if (image == null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
yield image;
|
yield image;
|
||||||
} catch (e) {
|
} catch (e, stack) {
|
||||||
PaintingBinding.instance.imageCache.evict(this);
|
if (evictOnError) {
|
||||||
rethrow;
|
PaintingBinding.instance.imageCache.evict(this);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
_log.warning('Non-fatal image load error', e, stack);
|
||||||
} finally {
|
} finally {
|
||||||
this.request = null;
|
this.request = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
|||||||
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||||
assetType: key.assetType,
|
assetType: key.assetType,
|
||||||
);
|
);
|
||||||
|
|
||||||
yield* loadRequest(request, decode);
|
yield* loadRequest(request, decode);
|
||||||
|
|
||||||
if (!Store.get(StoreKey.loadOriginal, false)) {
|
if (!Store.get(StoreKey.loadOriginal, false)) {
|
||||||
|
|||||||
@@ -93,9 +93,10 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
|||||||
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
|
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
|
||||||
headers: headers,
|
headers: headers,
|
||||||
);
|
);
|
||||||
yield* loadRequest(previewRequest, decode);
|
final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal);
|
||||||
|
yield* loadRequest(previewRequest, decode, evictOnError: !loadOriginal);
|
||||||
|
|
||||||
if (assetType != AssetType.image || !AppSetting.get(Setting.loadOriginal)) {
|
if (!loadOriginal) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+1
@@ -576,6 +576,7 @@ Class | Method | HTTP request | Description
|
|||||||
- [SyncAlbumUserV1](doc//SyncAlbumUserV1.md)
|
- [SyncAlbumUserV1](doc//SyncAlbumUserV1.md)
|
||||||
- [SyncAlbumV1](doc//SyncAlbumV1.md)
|
- [SyncAlbumV1](doc//SyncAlbumV1.md)
|
||||||
- [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md)
|
- [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md)
|
||||||
|
- [SyncAssetEditDeleteV1](doc//SyncAssetEditDeleteV1.md)
|
||||||
- [SyncAssetExifV1](doc//SyncAssetExifV1.md)
|
- [SyncAssetExifV1](doc//SyncAssetExifV1.md)
|
||||||
- [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md)
|
- [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md)
|
||||||
- [SyncAssetFaceV1](doc//SyncAssetFaceV1.md)
|
- [SyncAssetFaceV1](doc//SyncAssetFaceV1.md)
|
||||||
|
|||||||
Generated
+1
@@ -315,6 +315,7 @@ part 'model/sync_album_user_delete_v1.dart';
|
|||||||
part 'model/sync_album_user_v1.dart';
|
part 'model/sync_album_user_v1.dart';
|
||||||
part 'model/sync_album_v1.dart';
|
part 'model/sync_album_v1.dart';
|
||||||
part 'model/sync_asset_delete_v1.dart';
|
part 'model/sync_asset_delete_v1.dart';
|
||||||
|
part 'model/sync_asset_edit_delete_v1.dart';
|
||||||
part 'model/sync_asset_exif_v1.dart';
|
part 'model/sync_asset_exif_v1.dart';
|
||||||
part 'model/sync_asset_face_delete_v1.dart';
|
part 'model/sync_asset_face_delete_v1.dart';
|
||||||
part 'model/sync_asset_face_v1.dart';
|
part 'model/sync_asset_face_v1.dart';
|
||||||
|
|||||||
Generated
+2
@@ -676,6 +676,8 @@ class ApiClient {
|
|||||||
return SyncAlbumV1.fromJson(value);
|
return SyncAlbumV1.fromJson(value);
|
||||||
case 'SyncAssetDeleteV1':
|
case 'SyncAssetDeleteV1':
|
||||||
return SyncAssetDeleteV1.fromJson(value);
|
return SyncAssetDeleteV1.fromJson(value);
|
||||||
|
case 'SyncAssetEditDeleteV1':
|
||||||
|
return SyncAssetEditDeleteV1.fromJson(value);
|
||||||
case 'SyncAssetExifV1':
|
case 'SyncAssetExifV1':
|
||||||
return SyncAssetExifV1.fromJson(value);
|
return SyncAssetExifV1.fromJson(value);
|
||||||
case 'SyncAssetFaceDeleteV1':
|
case 'SyncAssetFaceDeleteV1':
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class SyncAssetEditDeleteV1 {
|
||||||
|
/// Returns a new [SyncAssetEditDeleteV1] instance.
|
||||||
|
SyncAssetEditDeleteV1({
|
||||||
|
required this.editId,
|
||||||
|
});
|
||||||
|
|
||||||
|
String editId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => 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<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
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<String, dynamic>();
|
||||||
|
|
||||||
|
return SyncAssetEditDeleteV1(
|
||||||
|
editId: mapValueOfType<String>(json, r'editId')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<SyncAssetEditDeleteV1> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <SyncAssetEditDeleteV1>[];
|
||||||
|
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<String, SyncAssetEditDeleteV1> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, SyncAssetEditDeleteV1>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // 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<String, List<SyncAssetEditDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<SyncAssetEditDeleteV1>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
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 = <String>{
|
||||||
|
'editId',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
+6
@@ -29,6 +29,8 @@ class SyncEntityType {
|
|||||||
static const assetV1 = SyncEntityType._(r'AssetV1');
|
static const assetV1 = SyncEntityType._(r'AssetV1');
|
||||||
static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1');
|
static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1');
|
||||||
static const assetExifV1 = SyncEntityType._(r'AssetExifV1');
|
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 assetMetadataV1 = SyncEntityType._(r'AssetMetadataV1');
|
||||||
static const assetMetadataDeleteV1 = SyncEntityType._(r'AssetMetadataDeleteV1');
|
static const assetMetadataDeleteV1 = SyncEntityType._(r'AssetMetadataDeleteV1');
|
||||||
static const assetOcrV1 = SyncEntityType._(r'AssetOcrV1');
|
static const assetOcrV1 = SyncEntityType._(r'AssetOcrV1');
|
||||||
@@ -82,6 +84,8 @@ class SyncEntityType {
|
|||||||
assetV1,
|
assetV1,
|
||||||
assetDeleteV1,
|
assetDeleteV1,
|
||||||
assetExifV1,
|
assetExifV1,
|
||||||
|
assetEditV1,
|
||||||
|
assetEditDeleteV1,
|
||||||
assetMetadataV1,
|
assetMetadataV1,
|
||||||
assetMetadataDeleteV1,
|
assetMetadataDeleteV1,
|
||||||
assetOcrV1,
|
assetOcrV1,
|
||||||
@@ -170,6 +174,8 @@ class SyncEntityTypeTypeTransformer {
|
|||||||
case r'AssetV1': return SyncEntityType.assetV1;
|
case r'AssetV1': return SyncEntityType.assetV1;
|
||||||
case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1;
|
case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1;
|
||||||
case r'AssetExifV1': return SyncEntityType.assetExifV1;
|
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'AssetMetadataV1': return SyncEntityType.assetMetadataV1;
|
||||||
case r'AssetMetadataDeleteV1': return SyncEntityType.assetMetadataDeleteV1;
|
case r'AssetMetadataDeleteV1': return SyncEntityType.assetMetadataDeleteV1;
|
||||||
case r'AssetOcrV1': return SyncEntityType.assetOcrV1;
|
case r'AssetOcrV1': return SyncEntityType.assetOcrV1;
|
||||||
|
|||||||
+3
@@ -30,6 +30,7 @@ class SyncRequestType {
|
|||||||
static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1');
|
static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1');
|
||||||
static const assetsV1 = SyncRequestType._(r'AssetsV1');
|
static const assetsV1 = SyncRequestType._(r'AssetsV1');
|
||||||
static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1');
|
static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1');
|
||||||
|
static const assetEditsV1 = SyncRequestType._(r'AssetEditsV1');
|
||||||
static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1');
|
static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1');
|
||||||
static const assetOcrV1 = SyncRequestType._(r'AssetOcrV1');
|
static const assetOcrV1 = SyncRequestType._(r'AssetOcrV1');
|
||||||
static const authUsersV1 = SyncRequestType._(r'AuthUsersV1');
|
static const authUsersV1 = SyncRequestType._(r'AuthUsersV1');
|
||||||
@@ -55,6 +56,7 @@ class SyncRequestType {
|
|||||||
albumAssetExifsV1,
|
albumAssetExifsV1,
|
||||||
assetsV1,
|
assetsV1,
|
||||||
assetExifsV1,
|
assetExifsV1,
|
||||||
|
assetEditsV1,
|
||||||
assetMetadataV1,
|
assetMetadataV1,
|
||||||
assetOcrV1,
|
assetOcrV1,
|
||||||
authUsersV1,
|
authUsersV1,
|
||||||
@@ -115,6 +117,7 @@ class SyncRequestTypeTypeTransformer {
|
|||||||
case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1;
|
case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1;
|
||||||
case r'AssetsV1': return SyncRequestType.assetsV1;
|
case r'AssetsV1': return SyncRequestType.assetsV1;
|
||||||
case r'AssetExifsV1': return SyncRequestType.assetExifsV1;
|
case r'AssetExifsV1': return SyncRequestType.assetExifsV1;
|
||||||
|
case r'AssetEditsV1': return SyncRequestType.assetEditsV1;
|
||||||
case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1;
|
case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1;
|
||||||
case r'AssetOcrV1': return SyncRequestType.assetOcrV1;
|
case r'AssetOcrV1': return SyncRequestType.assetOcrV1;
|
||||||
case r'AuthUsersV1': return SyncRequestType.authUsersV1;
|
case r'AuthUsersV1': return SyncRequestType.authUsersV1;
|
||||||
|
|||||||
@@ -22614,6 +22614,17 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SyncAssetEditDeleteV1": {
|
||||||
|
"properties": {
|
||||||
|
"editId": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"editId"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SyncAssetExifV1": {
|
"SyncAssetExifV1": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"assetId": {
|
"assetId": {
|
||||||
@@ -23263,6 +23274,8 @@
|
|||||||
"AssetV1",
|
"AssetV1",
|
||||||
"AssetDeleteV1",
|
"AssetDeleteV1",
|
||||||
"AssetExifV1",
|
"AssetExifV1",
|
||||||
|
"AssetEditV1",
|
||||||
|
"AssetEditDeleteV1",
|
||||||
"AssetMetadataV1",
|
"AssetMetadataV1",
|
||||||
"AssetMetadataDeleteV1",
|
"AssetMetadataDeleteV1",
|
||||||
"AssetOcrV1",
|
"AssetOcrV1",
|
||||||
@@ -23562,6 +23575,7 @@
|
|||||||
"AlbumAssetExifsV1",
|
"AlbumAssetExifsV1",
|
||||||
"AssetsV1",
|
"AssetsV1",
|
||||||
"AssetExifsV1",
|
"AssetExifsV1",
|
||||||
|
"AssetEditsV1",
|
||||||
"AssetMetadataV1",
|
"AssetMetadataV1",
|
||||||
"AssetOcrV1",
|
"AssetOcrV1",
|
||||||
"AuthUsersV1",
|
"AuthUsersV1",
|
||||||
|
|||||||
@@ -2967,6 +2967,9 @@ export type SyncAssetDeleteV1 = {
|
|||||||
/** Asset ID */
|
/** Asset ID */
|
||||||
assetId: string;
|
assetId: string;
|
||||||
};
|
};
|
||||||
|
export type SyncAssetEditDeleteV1 = {
|
||||||
|
editId: string;
|
||||||
|
};
|
||||||
export type SyncAssetExifV1 = {
|
export type SyncAssetExifV1 = {
|
||||||
/** Asset ID */
|
/** Asset ID */
|
||||||
assetId: string;
|
assetId: string;
|
||||||
@@ -7268,6 +7271,8 @@ export enum SyncEntityType {
|
|||||||
AssetV1 = "AssetV1",
|
AssetV1 = "AssetV1",
|
||||||
AssetDeleteV1 = "AssetDeleteV1",
|
AssetDeleteV1 = "AssetDeleteV1",
|
||||||
AssetExifV1 = "AssetExifV1",
|
AssetExifV1 = "AssetExifV1",
|
||||||
|
AssetEditV1 = "AssetEditV1",
|
||||||
|
AssetEditDeleteV1 = "AssetEditDeleteV1",
|
||||||
AssetMetadataV1 = "AssetMetadataV1",
|
AssetMetadataV1 = "AssetMetadataV1",
|
||||||
AssetMetadataDeleteV1 = "AssetMetadataDeleteV1",
|
AssetMetadataDeleteV1 = "AssetMetadataDeleteV1",
|
||||||
AssetOcrV1 = "AssetOcrV1",
|
AssetOcrV1 = "AssetOcrV1",
|
||||||
@@ -7321,6 +7326,7 @@ export enum SyncRequestType {
|
|||||||
AlbumAssetExifsV1 = "AlbumAssetExifsV1",
|
AlbumAssetExifsV1 = "AlbumAssetExifsV1",
|
||||||
AssetsV1 = "AssetsV1",
|
AssetsV1 = "AssetsV1",
|
||||||
AssetExifsV1 = "AssetExifsV1",
|
AssetExifsV1 = "AssetExifsV1",
|
||||||
|
AssetEditsV1 = "AssetEditsV1",
|
||||||
AssetMetadataV1 = "AssetMetadataV1",
|
AssetMetadataV1 = "AssetMetadataV1",
|
||||||
AssetOcrV1 = "AssetOcrV1",
|
AssetOcrV1 = "AssetOcrV1",
|
||||||
AuthUsersV1 = "AuthUsersV1",
|
AuthUsersV1 = "AuthUsersV1",
|
||||||
|
|||||||
@@ -454,6 +454,13 @@ export const columns = {
|
|||||||
'asset_ocr.updateId',
|
'asset_ocr.updateId',
|
||||||
'asset_ocr.isVisible',
|
'asset_ocr.isVisible',
|
||||||
],
|
],
|
||||||
|
syncAssetEdit: [
|
||||||
|
'asset_edit.id',
|
||||||
|
'asset_edit.assetId',
|
||||||
|
'asset_edit.sequence',
|
||||||
|
'asset_edit.action',
|
||||||
|
'asset_edit.parameters',
|
||||||
|
],
|
||||||
exif: [
|
exif: [
|
||||||
'asset_exif.assetId',
|
'asset_exif.assetId',
|
||||||
'asset_exif.autoStackId',
|
'asset_exif.autoStackId',
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ApiProperty, getSchemaPath } from '@nestjs/swagger';
|
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator';
|
import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator';
|
||||||
import { ExtraModel } from 'src/dtos/sync.dto';
|
|
||||||
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateEnum, ValidateUUID } from 'src/validation';
|
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
export enum AssetEditAction {
|
export enum AssetEditAction {
|
||||||
@@ -15,7 +14,6 @@ export enum MirrorAxis {
|
|||||||
Vertical = 'vertical',
|
Vertical = 'vertical',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExtraModel()
|
|
||||||
export class CropParameters {
|
export class CropParameters {
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
@@ -38,14 +36,12 @@ export class CropParameters {
|
|||||||
height!: number;
|
height!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExtraModel()
|
|
||||||
export class RotateParameters {
|
export class RotateParameters {
|
||||||
@IsAxisAlignedRotation()
|
@IsAxisAlignedRotation()
|
||||||
@ApiProperty({ description: 'Rotation angle in degrees' })
|
@ApiProperty({ description: 'Rotation angle in degrees' })
|
||||||
angle!: number;
|
angle!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExtraModel()
|
|
||||||
export class MirrorParameters {
|
export class MirrorParameters {
|
||||||
@IsEnum(MirrorAxis)
|
@IsEnum(MirrorAxis)
|
||||||
@ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' })
|
@ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' })
|
||||||
@@ -67,6 +63,7 @@ export type AssetEditActionItem =
|
|||||||
parameters: MirrorParameters;
|
parameters: MirrorParameters;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ApiExtraModels(CropParameters, RotateParameters, MirrorParameters)
|
||||||
export class AssetEditActionItemDto {
|
export class AssetEditActionItemDto {
|
||||||
@ValidateEnum({ name: 'AssetEditAction', enum: AssetEditAction, description: 'Type of edit action to perform' })
|
@ValidateEnum({ name: 'AssetEditAction', enum: AssetEditAction, description: 'Type of edit action to perform' })
|
||||||
action!: AssetEditAction;
|
action!: AssetEditAction;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator';
|
import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator';
|
||||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||||
|
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||||
import {
|
import {
|
||||||
AlbumUserRole,
|
AlbumUserRole,
|
||||||
AssetOrder,
|
AssetOrder,
|
||||||
@@ -274,6 +275,22 @@ export class SyncAssetOcrDeleteV1 {
|
|||||||
@ApiProperty({ description: 'Timestamp when the OCR entry was deleted' })
|
@ApiProperty({ description: 'Timestamp when the OCR entry was deleted' })
|
||||||
deletedAt!: Date;
|
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()
|
@ExtraModel()
|
||||||
export class SyncAssetMetadataV1 {
|
export class SyncAssetMetadataV1 {
|
||||||
@@ -539,6 +556,8 @@ export type SyncItem = {
|
|||||||
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
|
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
|
||||||
[SyncEntityType.AssetOcrV1]: SyncAssetOcrV1;
|
[SyncEntityType.AssetOcrV1]: SyncAssetOcrV1;
|
||||||
[SyncEntityType.AssetOcrDeleteV1]: SyncAssetOcrDeleteV1;
|
[SyncEntityType.AssetOcrDeleteV1]: SyncAssetOcrDeleteV1;
|
||||||
|
[SyncEntityType.AssetEditV1]: SyncAssetEditV1;
|
||||||
|
[SyncEntityType.AssetEditDeleteV1]: SyncAssetEditDeleteV1;
|
||||||
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
|
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
|
||||||
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1;
|
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1;
|
||||||
[SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1;
|
[SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1;
|
||||||
|
|||||||
@@ -720,6 +720,7 @@ export enum SyncRequestType {
|
|||||||
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
|
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
|
||||||
AssetsV1 = 'AssetsV1',
|
AssetsV1 = 'AssetsV1',
|
||||||
AssetExifsV1 = 'AssetExifsV1',
|
AssetExifsV1 = 'AssetExifsV1',
|
||||||
|
AssetEditsV1 = 'AssetEditsV1',
|
||||||
AssetMetadataV1 = 'AssetMetadataV1',
|
AssetMetadataV1 = 'AssetMetadataV1',
|
||||||
AssetOcrV1 = 'AssetOcrV1',
|
AssetOcrV1 = 'AssetOcrV1',
|
||||||
AuthUsersV1 = 'AuthUsersV1',
|
AuthUsersV1 = 'AuthUsersV1',
|
||||||
@@ -746,6 +747,8 @@ export enum SyncEntityType {
|
|||||||
AssetV1 = 'AssetV1',
|
AssetV1 = 'AssetV1',
|
||||||
AssetDeleteV1 = 'AssetDeleteV1',
|
AssetDeleteV1 = 'AssetDeleteV1',
|
||||||
AssetExifV1 = 'AssetExifV1',
|
AssetExifV1 = 'AssetExifV1',
|
||||||
|
AssetEditV1 = 'AssetEditV1',
|
||||||
|
AssetEditDeleteV1 = 'AssetEditDeleteV1',
|
||||||
AssetMetadataV1 = 'AssetMetadataV1',
|
AssetMetadataV1 = 'AssetMetadataV1',
|
||||||
AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1',
|
AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1',
|
||||||
AssetOcrV1 = 'AssetOcrV1',
|
AssetOcrV1 = 'AssetOcrV1',
|
||||||
|
|||||||
@@ -18,3 +18,17 @@ where
|
|||||||
"assetId" = $1
|
"assetId" = $1
|
||||||
order by
|
order by
|
||||||
"sequence" asc
|
"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
|
||||||
|
|||||||
@@ -514,6 +514,38 @@ where
|
|||||||
order by
|
order by
|
||||||
"asset_exif"."updateId" asc
|
"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
|
-- SyncRepository.assetFace.getDeletes
|
||||||
select
|
select
|
||||||
"asset_face_audit"."id",
|
"asset_face_audit"."id",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { columns } from 'src/database';
|
||||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { AssetEditActionItem, AssetEditActionItemResponseDto } from 'src/dtos/editing.dto';
|
import { AssetEditActionItem, AssetEditActionItemResponseDto } from 'src/dtos/editing.dto';
|
||||||
import { DB } from 'src/schema';
|
import { DB } from 'src/schema';
|
||||||
@@ -9,9 +10,7 @@ import { DB } from 'src/schema';
|
|||||||
export class AssetEditRepository {
|
export class AssetEditRepository {
|
||||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||||
|
|
||||||
@GenerateSql({
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
params: [DummyValue.UUID],
|
|
||||||
})
|
|
||||||
replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItemResponseDto[]> {
|
replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItemResponseDto[]> {
|
||||||
return this.db.transaction().execute(async (trx) => {
|
return this.db.transaction().execute(async (trx) => {
|
||||||
await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute();
|
await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute();
|
||||||
@@ -28,9 +27,7 @@ export class AssetEditRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
params: [DummyValue.UUID],
|
|
||||||
})
|
|
||||||
getAll(assetId: string): Promise<AssetEditActionItemResponseDto[]> {
|
getAll(assetId: string): Promise<AssetEditActionItemResponseDto[]> {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_edit')
|
.selectFrom('asset_edit')
|
||||||
@@ -39,4 +36,14 @@ export class AssetEditRepository {
|
|||||||
.orderBy('sequence', 'asc')
|
.orderBy('sequence', 'asc')
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
|
getWithSyncInfo(assetId: string) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('asset_edit')
|
||||||
|
.select(columns.syncAssetEdit)
|
||||||
|
.where('assetId', '=', assetId)
|
||||||
|
.orderBy('sequence', 'asc')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,23 +243,26 @@ export class MediaRepository {
|
|||||||
bitrate: this.parseInt(results.format.bit_rate),
|
bitrate: this.parseInt(results.format.bit_rate),
|
||||||
},
|
},
|
||||||
videoStreams: results.streams
|
videoStreams: results.streams
|
||||||
.filter((stream) => stream.codec_type === 'video')
|
.filter((stream) => stream.codec_type === 'video' && !stream.disposition?.attached_pic)
|
||||||
.filter((stream) => !stream.disposition?.attached_pic)
|
.map((stream) => {
|
||||||
.map((stream) => ({
|
const height = this.parseInt(stream.height);
|
||||||
index: stream.index,
|
const dar = this.getDar(stream.display_aspect_ratio);
|
||||||
height: this.parseInt(stream.height),
|
return {
|
||||||
width: this.parseInt(stream.width),
|
index: stream.index,
|
||||||
codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name,
|
height,
|
||||||
codecType: stream.codec_type,
|
width: dar ? Math.round(height * dar) : this.parseInt(stream.width),
|
||||||
frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames),
|
codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name,
|
||||||
rotation: this.parseInt(stream.rotation),
|
codecType: stream.codec_type,
|
||||||
isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67',
|
frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames),
|
||||||
bitrate: this.parseInt(stream.bit_rate),
|
rotation: this.parseInt(stream.rotation),
|
||||||
pixelFormat: stream.pix_fmt || 'yuv420p',
|
isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67',
|
||||||
colorPrimaries: stream.color_primaries,
|
bitrate: this.parseInt(stream.bit_rate),
|
||||||
colorSpace: stream.color_space,
|
pixelFormat: stream.pix_fmt || 'yuv420p',
|
||||||
colorTransfer: stream.color_transfer,
|
colorPrimaries: stream.color_primaries,
|
||||||
})),
|
colorSpace: stream.color_space,
|
||||||
|
colorTransfer: stream.color_transfer,
|
||||||
|
};
|
||||||
|
}),
|
||||||
audioStreams: results.streams
|
audioStreams: results.streams
|
||||||
.filter((stream) => stream.codec_type === 'audio')
|
.filter((stream) => stream.codec_type === 'audio')
|
||||||
.map((stream) => ({
|
.map((stream) => ({
|
||||||
@@ -352,4 +355,15 @@ export class MediaRepository {
|
|||||||
private parseFloat(value: string | number | undefined): number {
|
private parseFloat(value: string | number | undefined): number {
|
||||||
return Number.parseFloat(value as string) || 0;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export class SyncRepository {
|
|||||||
albumUser: AlbumUserSync;
|
albumUser: AlbumUserSync;
|
||||||
asset: AssetSync;
|
asset: AssetSync;
|
||||||
assetExif: AssetExifSync;
|
assetExif: AssetExifSync;
|
||||||
|
assetEdit: AssetEditSync;
|
||||||
assetFace: AssetFaceSync;
|
assetFace: AssetFaceSync;
|
||||||
assetMetadata: AssetMetadataSync;
|
assetMetadata: AssetMetadataSync;
|
||||||
assetOcr: AssetOcrSync;
|
assetOcr: AssetOcrSync;
|
||||||
@@ -76,6 +77,7 @@ export class SyncRepository {
|
|||||||
this.albumUser = new AlbumUserSync(this.db);
|
this.albumUser = new AlbumUserSync(this.db);
|
||||||
this.asset = new AssetSync(this.db);
|
this.asset = new AssetSync(this.db);
|
||||||
this.assetExif = new AssetExifSync(this.db);
|
this.assetExif = new AssetExifSync(this.db);
|
||||||
|
this.assetEdit = new AssetEditSync(this.db);
|
||||||
this.assetFace = new AssetFaceSync(this.db);
|
this.assetFace = new AssetFaceSync(this.db);
|
||||||
this.assetMetadata = new AssetMetadataSync(this.db);
|
this.assetMetadata = new AssetMetadataSync(this.db);
|
||||||
this.assetOcr = new AssetOcrSync(this.db);
|
this.assetOcr = new AssetOcrSync(this.db);
|
||||||
@@ -93,7 +95,7 @@ export class SyncRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class BaseSync {
|
export class BaseSync {
|
||||||
constructor(protected db: Kysely<DB>) {}
|
constructor(protected db: Kysely<DB>) {}
|
||||||
|
|
||||||
protected backfillQuery<T extends keyof DB>(t: T, { nowId, beforeUpdateId, afterUpdateId }: SyncBackfillOptions) {
|
protected backfillQuery<T extends keyof DB>(t: T, { nowId, beforeUpdateId, afterUpdateId }: SyncBackfillOptions) {
|
||||||
@@ -503,6 +505,30 @@ 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 {
|
class MemorySync extends BaseSync {
|
||||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||||
getDeletes(options: SyncQueryOptions) {
|
getDeletes(options: SyncQueryOptions) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
|||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { NotificationDto } from 'src/dtos/notification.dto';
|
import { NotificationDto } from 'src/dtos/notification.dto';
|
||||||
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||||
import { SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto';
|
import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto';
|
||||||
import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository';
|
import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { handlePromiseError } from 'src/utils/misc';
|
import { handlePromiseError } from 'src/utils/misc';
|
||||||
@@ -37,7 +37,7 @@ export interface ClientEventMap {
|
|||||||
|
|
||||||
AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }];
|
AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }];
|
||||||
AppRestartV1: [AppRestartEvent];
|
AppRestartV1: [AppRestartEvent];
|
||||||
AssetEditReadyV1: [{ asset: SyncAssetV1 }];
|
AssetEditReadyV1: [{ asset: SyncAssetV1; edit: SyncAssetEditV1[] }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuthFn = (client: Socket) => Promise<AuthDto>;
|
export type AuthFn = (client: Socket) => Promise<AuthDto>;
|
||||||
|
|||||||
@@ -287,6 +287,19 @@ 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({
|
export const asset_ocr_delete_audit = registerFunction({
|
||||||
name: 'asset_ocr_delete_audit',
|
name: 'asset_ocr_delete_audit',
|
||||||
returnType: 'TRIGGER',
|
returnType: 'TRIGGER',
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { AlbumUserTable } from 'src/schema/tables/album-user.table';
|
|||||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||||
import { ApiKeyTable } from 'src/schema/tables/api-key.table';
|
import { ApiKeyTable } from 'src/schema/tables/api-key.table';
|
||||||
import { AssetAuditTable } from 'src/schema/tables/asset-audit.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 { AssetEditTable } from 'src/schema/tables/asset-edit.table';
|
||||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||||
import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table';
|
import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table';
|
||||||
@@ -90,6 +91,7 @@ export class ImmichDatabase {
|
|||||||
ApiKeyTable,
|
ApiKeyTable,
|
||||||
AssetAuditTable,
|
AssetAuditTable,
|
||||||
AssetEditTable,
|
AssetEditTable,
|
||||||
|
AssetEditAuditTable,
|
||||||
AssetFaceTable,
|
AssetFaceTable,
|
||||||
AssetFaceAuditTable,
|
AssetFaceAuditTable,
|
||||||
AssetMetadataTable,
|
AssetMetadataTable,
|
||||||
@@ -188,6 +190,7 @@ export interface DB {
|
|||||||
asset: AssetTable;
|
asset: AssetTable;
|
||||||
asset_audit: AssetAuditTable;
|
asset_audit: AssetAuditTable;
|
||||||
asset_edit: AssetEditTable;
|
asset_edit: AssetEditTable;
|
||||||
|
asset_edit_audit: AssetEditAuditTable;
|
||||||
asset_exif: AssetExifTable;
|
asset_exif: AssetExifTable;
|
||||||
asset_face: AssetFaceTable;
|
asset_face: AssetFaceTable;
|
||||||
asset_face_audit: AssetFaceAuditTable;
|
asset_face_audit: AssetFaceAuditTable;
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
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<any>): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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<string>;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
editId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', index: true })
|
||||||
|
assetId!: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
|
||||||
|
deletedAt!: Generated<Timestamp>;
|
||||||
|
}
|
||||||
@@ -6,13 +6,17 @@ import {
|
|||||||
Generated,
|
Generated,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
Table,
|
Table,
|
||||||
|
Timestamp,
|
||||||
Unique,
|
Unique,
|
||||||
|
UpdateDateColumn,
|
||||||
} from '@immich/sql-tools';
|
} from '@immich/sql-tools';
|
||||||
|
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||||
import { AssetEditAction, AssetEditParameters } from 'src/dtos/editing.dto';
|
import { AssetEditAction, AssetEditParameters } from 'src/dtos/editing.dto';
|
||||||
import { asset_edit_delete, asset_edit_insert } from 'src/schema/functions';
|
import { asset_edit_audit, asset_edit_delete, asset_edit_insert } from 'src/schema/functions';
|
||||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||||
|
|
||||||
@Table('asset_edit')
|
@Table('asset_edit')
|
||||||
|
@UpdatedAtTrigger('asset_edit_updatedAt')
|
||||||
@AfterInsertTrigger({ scope: 'statement', function: asset_edit_insert, referencingNewTableAs: 'inserted_edit' })
|
@AfterInsertTrigger({ scope: 'statement', function: asset_edit_insert, referencingNewTableAs: 'inserted_edit' })
|
||||||
@AfterDeleteTrigger({
|
@AfterDeleteTrigger({
|
||||||
scope: 'statement',
|
scope: 'statement',
|
||||||
@@ -20,6 +24,12 @@ import { AssetTable } from 'src/schema/tables/asset.table';
|
|||||||
referencingOldTableAs: 'deleted_edit',
|
referencingOldTableAs: 'deleted_edit',
|
||||||
when: 'pg_trigger_depth() = 0',
|
when: 'pg_trigger_depth() = 0',
|
||||||
})
|
})
|
||||||
|
@AfterDeleteTrigger({
|
||||||
|
scope: 'statement',
|
||||||
|
function: asset_edit_audit,
|
||||||
|
referencingOldTableAs: 'old',
|
||||||
|
when: 'pg_trigger_depth() = 0',
|
||||||
|
})
|
||||||
@Unique({ columns: ['assetId', 'sequence'] })
|
@Unique({ columns: ['assetId', 'sequence'] })
|
||||||
export class AssetEditTable {
|
export class AssetEditTable {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
@@ -36,4 +46,10 @@ export class AssetEditTable {
|
|||||||
|
|
||||||
@Column({ type: 'integer' })
|
@Column({ type: 'integer' })
|
||||||
sequence!: number;
|
sequence!: number;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt!: Generated<Timestamp>;
|
||||||
|
|
||||||
|
@UpdateIdColumn({ index: true })
|
||||||
|
updateId!: Generated<string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -546,6 +546,7 @@ export class AssetService extends BaseService {
|
|||||||
async getAssetEdits(auth: AuthDto, id: string): Promise<AssetEditsResponseDto> {
|
async getAssetEdits(auth: AuthDto, id: string): Promise<AssetEditsResponseDto> {
|
||||||
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
|
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
|
||||||
const edits = await this.assetEditRepository.getAll(id);
|
const edits = await this.assetEditRepository.getAll(id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
assetId: id,
|
assetId: id,
|
||||||
edits,
|
edits,
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export class JobService extends BaseService {
|
|||||||
|
|
||||||
case JobName.AssetEditThumbnailGeneration: {
|
case JobName.AssetEditThumbnailGeneration: {
|
||||||
const asset = await this.assetRepository.getById(item.data.id);
|
const asset = await this.assetRepository.getById(item.data.id);
|
||||||
|
const edits = await this.assetEditRepository.getWithSyncInfo(item.data.id);
|
||||||
|
|
||||||
if (asset) {
|
if (asset) {
|
||||||
this.websocketRepository.clientSend('AssetEditReadyV1', asset.ownerId, {
|
this.websocketRepository.clientSend('AssetEditReadyV1', asset.ownerId, {
|
||||||
@@ -122,6 +123,7 @@ export class JobService extends BaseService {
|
|||||||
height: asset.height,
|
height: asset.height,
|
||||||
isEdited: asset.isEdited,
|
isEdited: asset.isEdited,
|
||||||
},
|
},
|
||||||
|
edit: edits,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ import { MediaService } from 'src/services/media.service';
|
|||||||
import { JobCounts, RawImageInfo } from 'src/types';
|
import { JobCounts, RawImageInfo } from 'src/types';
|
||||||
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
|
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
|
||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
|
import { PersonFactory } from 'test/factories/person.factory';
|
||||||
import { probeStub } from 'test/fixtures/media.stub';
|
import { probeStub } from 'test/fixtures/media.stub';
|
||||||
import { personStub, personThumbnailStub } from 'test/fixtures/person.stub';
|
import { personThumbnailStub } from 'test/fixtures/person.stub';
|
||||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||||
import { factory } from 'test/small.factory';
|
import { factory, newUuid } from 'test/small.factory';
|
||||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
const fullsizeBuffer = Buffer.from('embedded image data');
|
const fullsizeBuffer = Buffer.from('embedded image data');
|
||||||
@@ -50,9 +51,10 @@ describe(MediaService.name, () => {
|
|||||||
describe('handleQueueGenerateThumbnails', () => {
|
describe('handleQueueGenerateThumbnails', () => {
|
||||||
it('should queue all assets', async () => {
|
it('should queue all assets', async () => {
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
|
const person = PersonFactory.create({ faceAssetId: newUuid() });
|
||||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
|
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
|
||||||
|
|
||||||
mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail]));
|
mocks.person.getAll.mockReturnValue(makeStream([person]));
|
||||||
|
|
||||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||||
|
|
||||||
@@ -68,7 +70,7 @@ describe(MediaService.name, () => {
|
|||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.PersonGenerateThumbnail,
|
name: JobName.PersonGenerateThumbnail,
|
||||||
data: { id: personStub.newThumbnail.id },
|
data: { id: person.id },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -106,8 +108,13 @@ describe(MediaService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should queue all people with missing thumbnail path', async () => {
|
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.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([AssetFactory.create()]));
|
||||||
mocks.person.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail]));
|
mocks.person.getAll.mockReturnValue(makeStream([person1, person2]));
|
||||||
mocks.person.getRandomFace.mockResolvedValueOnce(AssetFaceFactory.create());
|
mocks.person.getRandomFace.mockResolvedValueOnce(AssetFaceFactory.create());
|
||||||
|
|
||||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||||
@@ -120,7 +127,7 @@ describe(MediaService.name, () => {
|
|||||||
{
|
{
|
||||||
name: JobName.PersonGenerateThumbnail,
|
name: JobName.PersonGenerateThumbnail,
|
||||||
data: {
|
data: {
|
||||||
id: personStub.newThumbnail.id,
|
id: person1.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -276,17 +283,17 @@ describe(MediaService.name, () => {
|
|||||||
describe('handleQueueMigration', () => {
|
describe('handleQueueMigration', () => {
|
||||||
it('should remove empty directories and queue jobs', async () => {
|
it('should remove empty directories and queue jobs', async () => {
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.assetJob.streamForMigrationJob.mockReturnValue(makeStream([asset]));
|
mocks.assetJob.streamForMigrationJob.mockReturnValue(makeStream([asset]));
|
||||||
mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts);
|
mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts);
|
||||||
mocks.person.getAll.mockReturnValue(makeStream([personStub.withName]));
|
mocks.person.getAll.mockReturnValue(makeStream([person]));
|
||||||
|
|
||||||
await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.Success);
|
await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.Success);
|
||||||
|
|
||||||
expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2);
|
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.AssetFileMigration, data: { id: asset.id } }]);
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.PersonFileMigration, data: { id: person.id } }]);
|
||||||
{ name: JobName.PersonFileMigration, data: { id: personStub.withName.id } },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1479,8 +1486,9 @@ describe(MediaService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should skip a person without a face asset id', async () => {
|
it('should skip a person without a face asset id', async () => {
|
||||||
mocks.person.getById.mockResolvedValue(personStub.noThumbnail);
|
const person = PersonFactory.create({ faceAssetId: null });
|
||||||
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
mocks.person.getById.mockResolvedValue(person);
|
||||||
|
await sut.handleGeneratePersonThumbnail({ id: person.id });
|
||||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1490,17 +1498,17 @@ describe(MediaService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should generate a thumbnail', async () => {
|
it('should generate a thumbnail', async () => {
|
||||||
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
|
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
|
||||||
mocks.media.generateThumbnail.mockResolvedValue();
|
mocks.media.generateThumbnail.mockResolvedValue();
|
||||||
const data = Buffer.from('');
|
const data = Buffer.from('');
|
||||||
const info = { width: 1000, height: 1000 } as OutputInfo;
|
const info = { width: 1000, height: 1000 } as OutputInfo;
|
||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||||
JobStatus.Success,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id);
|
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id);
|
||||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
|
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.originalPath, {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.originalPath, {
|
||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
@@ -1531,21 +1539,21 @@ describe(MediaService.name, () => {
|
|||||||
},
|
},
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
);
|
);
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', thumbnailPath: expect.any(String) });
|
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use preview path if video', async () => {
|
it('should use preview path if video', async () => {
|
||||||
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.videoThumbnail);
|
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.videoThumbnail);
|
||||||
mocks.media.generateThumbnail.mockResolvedValue();
|
mocks.media.generateThumbnail.mockResolvedValue();
|
||||||
const data = Buffer.from('');
|
const data = Buffer.from('');
|
||||||
const info = { width: 1000, height: 1000 } as OutputInfo;
|
const info = { width: 1000, height: 1000 } as OutputInfo;
|
||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||||
JobStatus.Success,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id);
|
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id);
|
||||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
|
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(expect.any(String), {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(expect.any(String), {
|
||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
@@ -1576,19 +1584,19 @@ describe(MediaService.name, () => {
|
|||||||
},
|
},
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
);
|
);
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', thumbnailPath: expect.any(String) });
|
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate a thumbnail without going negative', async () => {
|
it('should generate a thumbnail without going negative', async () => {
|
||||||
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailStart);
|
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailStart);
|
||||||
mocks.media.generateThumbnail.mockResolvedValue();
|
mocks.media.generateThumbnail.mockResolvedValue();
|
||||||
const data = Buffer.from('');
|
const data = Buffer.from('');
|
||||||
const info = { width: 2160, height: 3840 } as OutputInfo;
|
const info = { width: 2160, height: 3840 } as OutputInfo;
|
||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||||
JobStatus.Success,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailStart.originalPath, {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailStart.originalPath, {
|
||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
@@ -1622,16 +1630,16 @@ describe(MediaService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should generate a thumbnail without overflowing', async () => {
|
it('should generate a thumbnail without overflowing', async () => {
|
||||||
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailEnd);
|
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailEnd);
|
||||||
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
mocks.media.generateThumbnail.mockResolvedValue();
|
mocks.media.generateThumbnail.mockResolvedValue();
|
||||||
const data = Buffer.from('');
|
const data = Buffer.from('');
|
||||||
const info = { width: 1000, height: 1000 } as OutputInfo;
|
const info = { width: 1000, height: 1000 } as OutputInfo;
|
||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||||
JobStatus.Success,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailEnd.originalPath, {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailEnd.originalPath, {
|
||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
@@ -1665,16 +1673,16 @@ describe(MediaService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle negative coordinates', async () => {
|
it('should handle negative coordinates', async () => {
|
||||||
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.negativeCoordinate);
|
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.negativeCoordinate);
|
||||||
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
mocks.media.generateThumbnail.mockResolvedValue();
|
mocks.media.generateThumbnail.mockResolvedValue();
|
||||||
const data = Buffer.from('');
|
const data = Buffer.from('');
|
||||||
const info = { width: 4624, height: 3080 } as OutputInfo;
|
const info = { width: 4624, height: 3080 } as OutputInfo;
|
||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||||
JobStatus.Success,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.negativeCoordinate.originalPath, {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.negativeCoordinate.originalPath, {
|
||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
@@ -1708,16 +1716,16 @@ describe(MediaService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle overflowing coordinate', async () => {
|
it('should handle overflowing coordinate', async () => {
|
||||||
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.overflowingCoordinate);
|
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.overflowingCoordinate);
|
||||||
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
mocks.media.generateThumbnail.mockResolvedValue();
|
mocks.media.generateThumbnail.mockResolvedValue();
|
||||||
const data = Buffer.from('');
|
const data = Buffer.from('');
|
||||||
const info = { width: 4624, height: 3080 } as OutputInfo;
|
const info = { width: 4624, height: 3080 } as OutputInfo;
|
||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||||
JobStatus.Success,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.overflowingCoordinate.originalPath, {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.overflowingCoordinate.originalPath, {
|
||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
@@ -1751,9 +1759,11 @@ describe(MediaService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should use embedded preview if enabled and raw image', async () => {
|
it('should use embedded preview if enabled and raw image', async () => {
|
||||||
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
|
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
|
||||||
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
mocks.media.generateThumbnail.mockResolvedValue();
|
mocks.media.generateThumbnail.mockResolvedValue();
|
||||||
const extracted = Buffer.from('');
|
const extracted = Buffer.from('');
|
||||||
const data = Buffer.from('');
|
const data = Buffer.from('');
|
||||||
@@ -1762,9 +1772,7 @@ describe(MediaService.name, () => {
|
|||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 2160, height: 3840, isTransparent: false });
|
mocks.media.getImageMetadata.mockResolvedValue({ width: 2160, height: 3840, isTransparent: false });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||||
JobStatus.Success,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
|
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(extracted, {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(extracted, {
|
||||||
@@ -1799,21 +1807,23 @@ describe(MediaService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not use embedded preview if enabled and not raw image', async () => {
|
it('should not use embedded preview if enabled and not raw image', async () => {
|
||||||
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
|
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
|
||||||
mocks.media.generateThumbnail.mockResolvedValue();
|
mocks.media.generateThumbnail.mockResolvedValue();
|
||||||
const data = Buffer.from('');
|
const data = Buffer.from('');
|
||||||
const info = { width: 2160, height: 3840 } as OutputInfo;
|
const info = { width: 2160, height: 3840 } as OutputInfo;
|
||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||||
JobStatus.Success,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.media.extract).not.toHaveBeenCalled();
|
expect(mocks.media.extract).not.toHaveBeenCalled();
|
||||||
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
|
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not use embedded preview if enabled and raw image if not exists', async () => {
|
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.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
|
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
|
||||||
mocks.media.generateThumbnail.mockResolvedValue();
|
mocks.media.generateThumbnail.mockResolvedValue();
|
||||||
@@ -1821,9 +1831,7 @@ describe(MediaService.name, () => {
|
|||||||
const info = { width: 2160, height: 3840 } as OutputInfo;
|
const info = { width: 2160, height: 3840 } as OutputInfo;
|
||||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||||
JobStatus.Success,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
|
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {
|
||||||
@@ -1835,6 +1843,8 @@ describe(MediaService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not use embedded preview if enabled and raw image if low resolution', async () => {
|
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.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
|
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
|
||||||
mocks.media.generateThumbnail.mockResolvedValue();
|
mocks.media.generateThumbnail.mockResolvedValue();
|
||||||
@@ -1845,9 +1855,7 @@ describe(MediaService.name, () => {
|
|||||||
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg });
|
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg });
|
||||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false });
|
mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false });
|
||||||
|
|
||||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||||
JobStatus.Success,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
|
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
|
||||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {
|
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
import { ImmichTags } from 'src/repositories/metadata.repository';
|
import { ImmichTags } from 'src/repositories/metadata.repository';
|
||||||
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
|
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
|
||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
|
import { PersonFactory } from 'test/factories/person.factory';
|
||||||
import { probeStub } from 'test/fixtures/media.stub';
|
import { probeStub } from 'test/fixtures/media.stub';
|
||||||
import { personStub } from 'test/fixtures/person.stub';
|
|
||||||
import { tagStub } from 'test/fixtures/tag.stub';
|
import { tagStub } from 'test/fixtures/tag.stub';
|
||||||
import { factory } from 'test/small.factory';
|
import { factory } from 'test/small.factory';
|
||||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||||
@@ -1208,18 +1208,18 @@ describe(MetadataService.name, () => {
|
|||||||
|
|
||||||
it('should apply metadata face tags creating new people', async () => {
|
it('should apply metadata face tags creating new people', async () => {
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
|
mockReadTags(makeFaceTags({ Name: person.name }));
|
||||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||||
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
|
mocks.person.createAll.mockResolvedValue([person.id]);
|
||||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
await sut.handleMetadataExtraction({ id: asset.id });
|
await sut.handleMetadataExtraction({ id: asset.id });
|
||||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||||
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true });
|
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true });
|
||||||
expect(mocks.person.createAll).toHaveBeenCalledWith([
|
expect(mocks.person.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: person.name })]);
|
||||||
expect.objectContaining({ name: personStub.withName.name }),
|
|
||||||
]);
|
|
||||||
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
|
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -1243,19 +1243,21 @@ describe(MetadataService.name, () => {
|
|||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.PersonGenerateThumbnail,
|
name: JobName.PersonGenerateThumbnail,
|
||||||
data: { id: personStub.withName.id },
|
data: { id: person.id },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should assign metadata face tags to existing persons', async () => {
|
it('should assign metadata face tags to existing persons', async () => {
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
|
mockReadTags(makeFaceTags({ Name: person.name }));
|
||||||
mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]);
|
mocks.person.getDistinctNames.mockResolvedValue([{ id: person.id, name: person.name }]);
|
||||||
mocks.person.createAll.mockResolvedValue([]);
|
mocks.person.createAll.mockResolvedValue([]);
|
||||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
await sut.handleMetadataExtraction({ id: asset.id });
|
await sut.handleMetadataExtraction({ id: asset.id });
|
||||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||||
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true });
|
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true });
|
||||||
@@ -1265,7 +1267,7 @@ describe(MetadataService.name, () => {
|
|||||||
{
|
{
|
||||||
id: 'random-uuid',
|
id: 'random-uuid',
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
personId: personStub.withName.id,
|
personId: person.id,
|
||||||
imageHeight: 100,
|
imageHeight: 100,
|
||||||
imageWidth: 1000,
|
imageWidth: 1000,
|
||||||
boundingBoxX1: 0,
|
boundingBoxX1: 0,
|
||||||
@@ -1335,21 +1337,20 @@ describe(MetadataService.name, () => {
|
|||||||
async ({ orientation, expected }) => {
|
async ({ orientation, expected }) => {
|
||||||
const { imgW, imgH, x1, x2, y1, y2 } = expected;
|
const { imgW, imgH, x1, x2, y1, y2 } = expected;
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
mockReadTags(makeFaceTags({ Name: personStub.withName.name }, orientation));
|
mockReadTags(makeFaceTags({ Name: person.name }, orientation));
|
||||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||||
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
|
mocks.person.createAll.mockResolvedValue([person.id]);
|
||||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
await sut.handleMetadataExtraction({ id: asset.id });
|
await sut.handleMetadataExtraction({ id: asset.id });
|
||||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||||
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, {
|
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, {
|
||||||
withHidden: true,
|
withHidden: true,
|
||||||
});
|
});
|
||||||
expect(mocks.person.createAll).toHaveBeenCalledWith([
|
expect(mocks.person.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: person.name })]);
|
||||||
expect.objectContaining({ name: personStub.withName.name }),
|
|
||||||
]);
|
|
||||||
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
|
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -1373,7 +1374,7 @@ describe(MetadataService.name, () => {
|
|||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.PersonGenerateThumbnail,
|
name: JobName.PersonGenerateThumbnail,
|
||||||
data: { id: personStub.withName.id },
|
data: { id: person.id },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
|
import { mapFaces, mapPerson } from 'src/dtos/person.dto';
|
||||||
import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum';
|
import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum';
|
||||||
import { FaceSearchResult } from 'src/repositories/search.repository';
|
import { FaceSearchResult } from 'src/repositories/search.repository';
|
||||||
import { PersonService } from 'src/services/person.service';
|
import { PersonService } from 'src/services/person.service';
|
||||||
@@ -11,25 +11,11 @@ import { AuthFactory } from 'test/factories/auth.factory';
|
|||||||
import { PersonFactory } from 'test/factories/person.factory';
|
import { PersonFactory } from 'test/factories/person.factory';
|
||||||
import { UserFactory } from 'test/factories/user.factory';
|
import { UserFactory } from 'test/factories/user.factory';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { personStub } from 'test/fixtures/person.stub';
|
|
||||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||||
import { getAsDetectedFace, getForFacialRecognitionJob } from 'test/mappers';
|
import { getAsDetectedFace, getForFacialRecognitionJob } from 'test/mappers';
|
||||||
import { newDate, newUuid } from 'test/small.factory';
|
import { newDate, newUuid } from 'test/small.factory';
|
||||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
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, () => {
|
describe(PersonService.name, () => {
|
||||||
let sut: PersonService;
|
let sut: PersonService;
|
||||||
let mocks: ServiceMocks;
|
let mocks: ServiceMocks;
|
||||||
@@ -44,60 +30,54 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
describe('getAll', () => {
|
describe('getAll', () => {
|
||||||
it('should get all hidden and visible people with thumbnails', async () => {
|
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({
|
mocks.person.getAllForUser.mockResolvedValue({
|
||||||
items: [personStub.withName, personStub.hidden],
|
items: [person, hiddenPerson],
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
});
|
});
|
||||||
mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
|
mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
|
||||||
await expect(sut.getAll(authStub.admin, { withHidden: true, page: 1, size: 10 })).resolves.toEqual({
|
await expect(sut.getAll(auth, { withHidden: true, page: 1, size: 10 })).resolves.toEqual({
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
total: 2,
|
total: 2,
|
||||||
hidden: 1,
|
hidden: 1,
|
||||||
people: [
|
people: [
|
||||||
responseDto,
|
expect.objectContaining({ id: person.id, isHidden: false }),
|
||||||
{
|
expect.objectContaining({
|
||||||
id: 'person-1',
|
id: hiddenPerson.id,
|
||||||
name: '',
|
|
||||||
birthDate: null,
|
|
||||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
|
||||||
isHidden: true,
|
isHidden: true,
|
||||||
isFavorite: false,
|
}),
|
||||||
updatedAt: expect.any(Date),
|
|
||||||
color: expect.any(String),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, {
|
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
|
||||||
minimumFaceCount: 3,
|
minimumFaceCount: 3,
|
||||||
withHidden: true,
|
withHidden: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get all visible people and favorites should be first in the array', async () => {
|
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({
|
mocks.person.getAllForUser.mockResolvedValue({
|
||||||
items: [personStub.isFavorite, personStub.withName],
|
items: [isFavorite, person],
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
});
|
});
|
||||||
mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
|
mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
|
||||||
await expect(sut.getAll(authStub.admin, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({
|
await expect(sut.getAll(auth, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
total: 2,
|
total: 2,
|
||||||
hidden: 1,
|
hidden: 1,
|
||||||
people: [
|
people: [
|
||||||
{
|
expect.objectContaining({
|
||||||
id: 'person-4',
|
id: isFavorite.id,
|
||||||
name: personStub.isFavorite.name,
|
|
||||||
birthDate: null,
|
|
||||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
|
||||||
isHidden: false,
|
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
updatedAt: expect.any(Date),
|
}),
|
||||||
color: personStub.isFavorite.color,
|
expect.objectContaining({ id: person.id, isFavorite: false }),
|
||||||
},
|
|
||||||
responseDto,
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, {
|
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
|
||||||
minimumFaceCount: 3,
|
minimumFaceCount: 3,
|
||||||
withHidden: false,
|
withHidden: false,
|
||||||
});
|
});
|
||||||
@@ -106,71 +86,89 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
describe('getById', () => {
|
describe('getById', () => {
|
||||||
it('should require person.read permission', async () => {
|
it('should require person.read permission', async () => {
|
||||||
mocks.person.getById.mockResolvedValue(personStub.withName);
|
const auth = AuthFactory.create();
|
||||||
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
const person = PersonFactory.create();
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
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]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw a bad request when person is not found', async () => {
|
it('should throw a bad request when person is not found', async () => {
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
const auth = AuthFactory.create();
|
||||||
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['unknown']));
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
await expect(sut.getById(auth, 'unknown')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['unknown']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get a person by id', async () => {
|
it('should get a person by id', async () => {
|
||||||
mocks.person.getById.mockResolvedValue(personStub.withName);
|
const auth = AuthFactory.create();
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
const person = PersonFactory.create();
|
||||||
await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto);
|
|
||||||
expect(mocks.person.getById).toHaveBeenCalledWith('person-1');
|
mocks.person.getById.mockResolvedValue(person);
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
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]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getThumbnail', () => {
|
describe('getThumbnail', () => {
|
||||||
it('should require person.read permission', async () => {
|
it('should require person.read permission', async () => {
|
||||||
mocks.person.getById.mockResolvedValue(personStub.noName);
|
const auth = AuthFactory.create();
|
||||||
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
|
mocks.person.getById.mockResolvedValue(person);
|
||||||
|
await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error when personId is invalid', async () => {
|
it('should throw an error when personId is invalid', async () => {
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
const auth = AuthFactory.create();
|
||||||
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
|
|
||||||
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['unknown']));
|
||||||
|
await expect(sut.getThumbnail(auth, 'unknown')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['unknown']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error when person has no thumbnail', async () => {
|
it('should throw an error when person has no thumbnail', async () => {
|
||||||
mocks.person.getById.mockResolvedValue(personStub.noThumbnail);
|
const auth = AuthFactory.create();
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
const person = PersonFactory.create({ thumbnailPath: '' });
|
||||||
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
|
|
||||||
|
mocks.person.getById.mockResolvedValue(person);
|
||||||
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||||
|
await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should serve the thumbnail', async () => {
|
it('should serve the thumbnail', async () => {
|
||||||
mocks.person.getById.mockResolvedValue(personStub.noName);
|
const auth = AuthFactory.create();
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
const person = PersonFactory.create();
|
||||||
await expect(sut.getThumbnail(authStub.admin, 'person-1')).resolves.toEqual(
|
|
||||||
|
mocks.person.getById.mockResolvedValue(person);
|
||||||
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||||
|
await expect(sut.getThumbnail(auth, person.id)).resolves.toEqual(
|
||||||
new ImmichFileResponse({
|
new ImmichFileResponse({
|
||||||
path: '/path/to/thumbnail.jpg',
|
path: person.thumbnailPath,
|
||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
cacheControl: CacheControl.PrivateWithoutCache,
|
cacheControl: CacheControl.PrivateWithoutCache,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('update', () => {
|
describe('update', () => {
|
||||||
it('should require person.write permission', async () => {
|
it('should require person.write permission', async () => {
|
||||||
mocks.person.getById.mockResolvedValue(personStub.noName);
|
const auth = AuthFactory.create();
|
||||||
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(
|
const person = PersonFactory.create();
|
||||||
BadRequestException,
|
|
||||||
);
|
mocks.person.getById.mockResolvedValue(person);
|
||||||
|
await expect(sut.update(auth, person.id, { name: 'Person 1' })).rejects.toBeInstanceOf(BadRequestException);
|
||||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error when personId is invalid', async () => {
|
it('should throw an error when personId is invalid', async () => {
|
||||||
@@ -183,86 +181,108 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should update a person's name", async () => {
|
it("should update a person's name", async () => {
|
||||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
const auth = AuthFactory.create();
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
const person = PersonFactory.create({ name: 'Person 1' });
|
||||||
|
|
||||||
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||||
|
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' });
|
await expect(sut.update(auth, person.id, { name: 'Person 1' })).resolves.toEqual(
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
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]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update a person's date of birth", async () => {
|
it("should update a person's date of birth", async () => {
|
||||||
mocks.person.update.mockResolvedValue(personStub.withBirthDate);
|
const auth = AuthFactory.create();
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
const person = PersonFactory.create({ birthDate: new Date('1976-06-30') });
|
||||||
|
|
||||||
await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({
|
mocks.person.update.mockResolvedValue(person);
|
||||||
id: 'person-1',
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||||
name: 'Person 1',
|
|
||||||
|
await expect(sut.update(auth, person.id, { birthDate: new Date('1976-06-30') })).resolves.toEqual({
|
||||||
|
id: person.id,
|
||||||
|
name: person.name,
|
||||||
birthDate: '1976-06-30',
|
birthDate: '1976-06-30',
|
||||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
thumbnailPath: person.thumbnailPath,
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
color: expect.any(String),
|
|
||||||
});
|
});
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') });
|
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, birthDate: new Date('1976-06-30') });
|
||||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||||
expect(mocks.job.queueAll).not.toHaveBeenCalled();
|
expect(mocks.job.queueAll).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update a person visibility', async () => {
|
it('should update a person visibility', async () => {
|
||||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
const auth = AuthFactory.create();
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
const person = PersonFactory.create({ isHidden: true });
|
||||||
|
|
||||||
await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||||
|
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false });
|
await expect(sut.update(auth, person.id, { isHidden: true })).resolves.toEqual(
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
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]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update a person favorite status', async () => {
|
it('should update a person favorite status', async () => {
|
||||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
const auth = AuthFactory.create();
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
const person = PersonFactory.create({ isFavorite: true });
|
||||||
|
|
||||||
await expect(sut.update(authStub.admin, 'person-1', { isFavorite: true })).resolves.toEqual(responseDto);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||||
|
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', isFavorite: true });
|
await expect(sut.update(auth, person.id, { isFavorite: true })).resolves.toEqual(
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
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]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update a person's thumbnailPath", async () => {
|
it("should update a person's thumbnailPath", async () => {
|
||||||
const face = AssetFaceFactory.create();
|
const face = AssetFaceFactory.create();
|
||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
|
mocks.person.update.mockResolvedValue(person);
|
||||||
mocks.person.getForFeatureFaceUpdate.mockResolvedValue(face);
|
mocks.person.getForFeatureFaceUpdate.mockResolvedValue(face);
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([face.assetId]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([face.assetId]));
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||||
|
|
||||||
await expect(sut.update(auth, 'person-1', { featureFaceAssetId: face.assetId })).resolves.toEqual(responseDto);
|
await expect(sut.update(auth, person.id, { featureFaceAssetId: face.assetId })).resolves.toEqual(
|
||||||
|
expect.objectContaining({ id: person.id }),
|
||||||
|
);
|
||||||
|
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: face.id });
|
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: face.id });
|
||||||
expect(mocks.person.getForFeatureFaceUpdate).toHaveBeenCalledWith({
|
expect(mocks.person.getForFeatureFaceUpdate).toHaveBeenCalledWith({
|
||||||
assetId: face.assetId,
|
assetId: face.assetId,
|
||||||
personId: 'person-1',
|
personId: person.id,
|
||||||
});
|
});
|
||||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.PersonGenerateThumbnail,
|
name: JobName.PersonGenerateThumbnail,
|
||||||
data: { id: 'person-1' },
|
data: { id: person.id },
|
||||||
});
|
});
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['person-1']));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error when the face feature assetId is invalid', async () => {
|
it('should throw an error when the face feature assetId is invalid', async () => {
|
||||||
mocks.person.getById.mockResolvedValue(personStub.withName);
|
const auth = AuthFactory.create();
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow(
|
mocks.person.getById.mockResolvedValue(person);
|
||||||
BadRequestException,
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||||
);
|
|
||||||
|
await expect(sut.update(auth, person.id, { featureFaceAssetId: '-1' })).rejects.toThrow(BadRequestException);
|
||||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -283,36 +303,39 @@ describe(PersonService.name, () => {
|
|||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set());
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.reassignFaces(authStub.admin, personStub.noName.id, {
|
sut.reassignFaces(AuthFactory.create(), 'person-id', {
|
||||||
data: [{ personId: 'asset-face-1', assetId: '' }],
|
data: [{ personId: 'asset-face-1', assetId: '' }],
|
||||||
}),
|
}),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
expect(mocks.job.queue).not.toHaveBeenCalledWith();
|
expect(mocks.job.queue).not.toHaveBeenCalledWith();
|
||||||
expect(mocks.job.queueAll).not.toHaveBeenCalledWith();
|
expect(mocks.job.queueAll).not.toHaveBeenCalledWith();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reassign a face', async () => {
|
it('should reassign a face', async () => {
|
||||||
const face = AssetFaceFactory.create();
|
const face = AssetFaceFactory.create();
|
||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id]));
|
const person = PersonFactory.create();
|
||||||
mocks.person.getById.mockResolvedValue(personStub.noName);
|
|
||||||
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||||
|
mocks.person.getById.mockResolvedValue(person);
|
||||||
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
|
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
|
||||||
mocks.person.getFacesByIds.mockResolvedValue([face]);
|
mocks.person.getFacesByIds.mockResolvedValue([face]);
|
||||||
mocks.person.reassignFace.mockResolvedValue(1);
|
mocks.person.reassignFace.mockResolvedValue(1);
|
||||||
mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create());
|
mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create());
|
||||||
mocks.person.refreshFaces.mockResolvedValue();
|
mocks.person.refreshFaces.mockResolvedValue();
|
||||||
mocks.person.reassignFace.mockResolvedValue(5);
|
mocks.person.reassignFace.mockResolvedValue(5);
|
||||||
mocks.person.update.mockResolvedValue(personStub.noName);
|
mocks.person.update.mockResolvedValue(person);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.reassignFaces(auth, personStub.noName.id, {
|
sut.reassignFaces(auth, person.id, {
|
||||||
data: [{ personId: personStub.withName.id, assetId: face.assetId }],
|
data: [{ personId: person.id, assetId: face.assetId }],
|
||||||
}),
|
}),
|
||||||
).resolves.toBeDefined();
|
).resolves.toBeDefined();
|
||||||
|
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.PersonGenerateThumbnail,
|
name: JobName.PersonGenerateThumbnail,
|
||||||
data: { id: personStub.newThumbnail.id },
|
data: { id: person.id },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -320,7 +343,7 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
describe('handlePersonMigration', () => {
|
describe('handlePersonMigration', () => {
|
||||||
it('should not move person files', async () => {
|
it('should not move person files', async () => {
|
||||||
await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.Failed);
|
await expect(sut.handlePersonMigration(PersonFactory.create())).resolves.toBe(JobStatus.Failed);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -347,12 +370,14 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
describe('createNewFeaturePhoto', () => {
|
describe('createNewFeaturePhoto', () => {
|
||||||
it('should change person feature photo', async () => {
|
it('should change person feature photo', async () => {
|
||||||
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create());
|
mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create());
|
||||||
await sut.createNewFeaturePhoto([personStub.newThumbnail.id]);
|
await sut.createNewFeaturePhoto([person.id]);
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.PersonGenerateThumbnail,
|
name: JobName.PersonGenerateThumbnail,
|
||||||
data: { id: personStub.newThumbnail.id },
|
data: { id: person.id },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -361,23 +386,22 @@ describe(PersonService.name, () => {
|
|||||||
describe('reassignFacesById', () => {
|
describe('reassignFacesById', () => {
|
||||||
it('should create a new person', async () => {
|
it('should create a new person', async () => {
|
||||||
const face = AssetFaceFactory.create();
|
const face = AssetFaceFactory.create();
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||||
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
|
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
|
||||||
mocks.person.getFaceById.mockResolvedValue(face);
|
mocks.person.getFaceById.mockResolvedValue(face);
|
||||||
mocks.person.reassignFace.mockResolvedValue(1);
|
mocks.person.reassignFace.mockResolvedValue(1);
|
||||||
mocks.person.getById.mockResolvedValue(personStub.noName);
|
mocks.person.getById.mockResolvedValue(person);
|
||||||
await expect(sut.reassignFacesById(AuthFactory.create(), personStub.noName.id, { id: face.id })).resolves.toEqual(
|
await expect(sut.reassignFacesById(AuthFactory.create(), person.id, { id: face.id })).resolves.toEqual({
|
||||||
{
|
birthDate: person.birthDate,
|
||||||
birthDate: personStub.noName.birthDate,
|
isHidden: person.isHidden,
|
||||||
isHidden: personStub.noName.isHidden,
|
isFavorite: person.isFavorite,
|
||||||
isFavorite: personStub.noName.isFavorite,
|
id: person.id,
|
||||||
id: personStub.noName.id,
|
name: person.name,
|
||||||
name: personStub.noName.name,
|
thumbnailPath: person.thumbnailPath,
|
||||||
thumbnailPath: personStub.noName.thumbnailPath,
|
updatedAt: expect.any(Date),
|
||||||
updatedAt: expect.any(Date),
|
});
|
||||||
color: personStub.noName.color,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.job.queue).not.toHaveBeenCalledWith();
|
expect(mocks.job.queue).not.toHaveBeenCalledWith();
|
||||||
expect(mocks.job.queueAll).not.toHaveBeenCalledWith();
|
expect(mocks.job.queueAll).not.toHaveBeenCalledWith();
|
||||||
@@ -385,12 +409,14 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
it('should fail if user has not the correct permissions on the asset', async () => {
|
it('should fail if user has not the correct permissions on the asset', async () => {
|
||||||
const face = AssetFaceFactory.create();
|
const face = AssetFaceFactory.create();
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
|
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||||
mocks.person.getFaceById.mockResolvedValue(face);
|
mocks.person.getFaceById.mockResolvedValue(face);
|
||||||
mocks.person.reassignFace.mockResolvedValue(1);
|
mocks.person.reassignFace.mockResolvedValue(1);
|
||||||
mocks.person.getById.mockResolvedValue(personStub.noName);
|
mocks.person.getById.mockResolvedValue(person);
|
||||||
await expect(
|
await expect(
|
||||||
sut.reassignFacesById(AuthFactory.create(), personStub.noName.id, {
|
sut.reassignFacesById(AuthFactory.create(), person.id, {
|
||||||
id: face.id,
|
id: face.id,
|
||||||
}),
|
}),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
@@ -402,22 +428,25 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
describe('createPerson', () => {
|
describe('createPerson', () => {
|
||||||
it('should create a new person', async () => {
|
it('should create a new person', async () => {
|
||||||
mocks.person.create.mockResolvedValue(personStub.primaryPerson);
|
const auth = AuthFactory.create();
|
||||||
|
|
||||||
await expect(sut.create(authStub.admin, {})).resolves.toBeDefined();
|
mocks.person.create.mockResolvedValue(PersonFactory.create());
|
||||||
|
await expect(sut.create(auth, {})).resolves.toBeDefined();
|
||||||
|
|
||||||
expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id });
|
expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: auth.user.id });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handlePersonCleanup', () => {
|
describe('handlePersonCleanup', () => {
|
||||||
it('should delete people without faces', async () => {
|
it('should delete people without faces', async () => {
|
||||||
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.noName]);
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
|
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
||||||
|
|
||||||
await sut.handlePersonCleanup();
|
await sut.handlePersonCleanup();
|
||||||
|
|
||||||
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.noName.id]);
|
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
|
||||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.noName.thumbnailPath);
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -449,15 +478,17 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
it('should queue all assets', async () => {
|
it('should queue all assets', async () => {
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset]));
|
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset]));
|
||||||
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.withName]);
|
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
||||||
|
|
||||||
await sut.handleQueueDetectFaces({ force: true });
|
await sut.handleQueueDetectFaces({ force: true });
|
||||||
|
|
||||||
expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MachineLearning });
|
expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MachineLearning });
|
||||||
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]);
|
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
|
||||||
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
|
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
|
||||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath);
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||||
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true);
|
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true);
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
@@ -490,10 +521,12 @@ describe(PersonService.name, () => {
|
|||||||
it('should delete existing people and faces if forced', async () => {
|
it('should delete existing people and faces if forced', async () => {
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
const face = AssetFaceFactory.from().person().build();
|
const face = AssetFaceFactory.from().person().build();
|
||||||
mocks.person.getAll.mockReturnValue(makeStream([face.person!, personStub.randomPerson]));
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
|
mocks.person.getAll.mockReturnValue(makeStream([face.person!, person]));
|
||||||
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
|
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
|
||||||
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset]));
|
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset]));
|
||||||
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]);
|
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
||||||
mocks.person.deleteFaces.mockResolvedValue();
|
mocks.person.deleteFaces.mockResolvedValue();
|
||||||
|
|
||||||
await sut.handleQueueDetectFaces({ force: true });
|
await sut.handleQueueDetectFaces({ force: true });
|
||||||
@@ -505,8 +538,8 @@ describe(PersonService.name, () => {
|
|||||||
data: { id: asset.id },
|
data: { id: asset.id },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]);
|
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
|
||||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath);
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||||
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
|
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -661,6 +694,8 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
it('should delete existing people if forced', async () => {
|
it('should delete existing people if forced', async () => {
|
||||||
const face = AssetFaceFactory.from().person().build();
|
const face = AssetFaceFactory.from().person().build();
|
||||||
|
const person = PersonFactory.create();
|
||||||
|
|
||||||
mocks.job.getJobCounts.mockResolvedValue({
|
mocks.job.getJobCounts.mockResolvedValue({
|
||||||
active: 1,
|
active: 1,
|
||||||
waiting: 0,
|
waiting: 0,
|
||||||
@@ -669,9 +704,9 @@ describe(PersonService.name, () => {
|
|||||||
failed: 0,
|
failed: 0,
|
||||||
delayed: 0,
|
delayed: 0,
|
||||||
});
|
});
|
||||||
mocks.person.getAll.mockReturnValue(makeStream([face.person!, personStub.randomPerson]));
|
mocks.person.getAll.mockReturnValue(makeStream([face.person!, person]));
|
||||||
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
|
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
|
||||||
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]);
|
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
||||||
mocks.person.unassignFaces.mockResolvedValue();
|
mocks.person.unassignFaces.mockResolvedValue();
|
||||||
|
|
||||||
await sut.handleQueueRecognizeFaces({ force: true });
|
await sut.handleQueueRecognizeFaces({ force: true });
|
||||||
@@ -684,8 +719,8 @@ describe(PersonService.name, () => {
|
|||||||
data: { id: face.id, deferred: false },
|
data: { id: face.id, deferred: false },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]);
|
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
|
||||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath);
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||||
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false });
|
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1059,59 +1094,71 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
describe('mergePerson', () => {
|
describe('mergePerson', () => {
|
||||||
it('should require person.write and person.merge permission', async () => {
|
it('should require person.write and person.merge permission', async () => {
|
||||||
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
const auth = AuthFactory.create();
|
||||||
mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson);
|
const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()];
|
||||||
|
|
||||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
|
mocks.person.getById.mockResolvedValueOnce(person);
|
||||||
|
mocks.person.getById.mockResolvedValueOnce(mergePerson);
|
||||||
|
|
||||||
|
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.person.reassignFaces).not.toHaveBeenCalled();
|
expect(mocks.person.reassignFaces).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(mocks.person.delete).not.toHaveBeenCalled();
|
expect(mocks.person.delete).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should merge two people without smart merge', async () => {
|
it('should merge two people without smart merge', async () => {
|
||||||
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
const auth = AuthFactory.create();
|
||||||
mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson);
|
const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()];
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
|
|
||||||
|
|
||||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
mocks.person.getById.mockResolvedValueOnce(person);
|
||||||
{ id: 'person-2', success: true },
|
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 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||||
newPersonId: personStub.primaryPerson.id,
|
newPersonId: person.id,
|
||||||
oldPersonId: personStub.mergePerson.id,
|
oldPersonId: mergePerson.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should merge two people with smart merge', async () => {
|
it('should merge two people with smart merge', async () => {
|
||||||
mocks.person.getById.mockResolvedValueOnce(personStub.randomPerson);
|
const auth = AuthFactory.create();
|
||||||
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
const [person, mergePerson] = [
|
||||||
mocks.person.update.mockResolvedValue({ ...personStub.randomPerson, name: personStub.primaryPerson.name });
|
PersonFactory.create({ name: undefined }),
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3']));
|
PersonFactory.create({ name: 'Merge person' }),
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
];
|
||||||
|
|
||||||
await expect(sut.mergePerson(authStub.admin, 'person-3', { ids: ['person-1'] })).resolves.toEqual([
|
mocks.person.getById.mockResolvedValueOnce(person);
|
||||||
{ id: 'person-1', success: true },
|
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 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||||
newPersonId: personStub.randomPerson.id,
|
newPersonId: person.id,
|
||||||
oldPersonId: personStub.primaryPerson.id,
|
oldPersonId: mergePerson.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({
|
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||||
id: personStub.randomPerson.id,
|
id: person.id,
|
||||||
name: personStub.primaryPerson.name,
|
name: mergePerson.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error when the primary person is not found', async () => {
|
it('should throw an error when the primary person is not found', async () => {
|
||||||
@@ -1126,48 +1173,60 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle invalid merge ids', async () => {
|
it('should handle invalid merge ids', async () => {
|
||||||
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
const auth = AuthFactory.create();
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
const person = PersonFactory.create();
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
|
|
||||||
|
|
||||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
mocks.person.getById.mockResolvedValueOnce(person);
|
||||||
{ id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
|
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 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(mocks.person.reassignFaces).not.toHaveBeenCalled();
|
expect(mocks.person.reassignFaces).not.toHaveBeenCalled();
|
||||||
expect(mocks.person.delete).not.toHaveBeenCalled();
|
expect(mocks.person.delete).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle an error reassigning faces', async () => {
|
it('should handle an error reassigning faces', async () => {
|
||||||
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
const auth = AuthFactory.create();
|
||||||
mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson);
|
const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()];
|
||||||
mocks.person.reassignFaces.mockRejectedValue(new Error('update failed'));
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
|
|
||||||
|
|
||||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
mocks.person.getById.mockResolvedValueOnce(person);
|
||||||
{ id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN },
|
mocks.person.getById.mockResolvedValueOnce(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]));
|
||||||
|
|
||||||
|
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([
|
||||||
|
{ id: mergePerson.id, success: false, error: BulkIdErrorReason.UNKNOWN },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(mocks.person.delete).not.toHaveBeenCalled();
|
expect(mocks.person.delete).not.toHaveBeenCalled();
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getStatistics', () => {
|
describe('getStatistics', () => {
|
||||||
it('should get correct number of person', async () => {
|
it('should get correct number of person', async () => {
|
||||||
mocks.person.getById.mockResolvedValue(personStub.primaryPerson);
|
const auth = AuthFactory.create();
|
||||||
mocks.person.getStatistics.mockResolvedValue(statistics);
|
const person = PersonFactory.create();
|
||||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
|
||||||
await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 });
|
mocks.person.getById.mockResolvedValue(person);
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
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]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require person.read permission', async () => {
|
it('should require person.read permission', async () => {
|
||||||
mocks.person.getById.mockResolvedValue(personStub.primaryPerson);
|
const auth = AuthFactory.create();
|
||||||
await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
const person = PersonFactory.create();
|
||||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
|
||||||
|
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]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -595,7 +595,7 @@ export class PersonService extends BaseService {
|
|||||||
update.birthDate = mergePerson.birthDate;
|
update.birthDate = mergePerson.birthDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(update).length > 0) {
|
if (Object.keys(update).length > 1) {
|
||||||
primaryPerson = await this.personRepository.update(update);
|
primaryPerson = await this.personRepository.update(update);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { SearchService } from 'src/services/search.service';
|
|||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
import { AuthFactory } from 'test/factories/auth.factory';
|
import { AuthFactory } from 'test/factories/auth.factory';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { personStub } from 'test/fixtures/person.stub';
|
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
import { beforeEach, vitest } from 'vitest';
|
import { beforeEach, vitest } from 'vitest';
|
||||||
|
|
||||||
@@ -26,17 +25,18 @@ describe(SearchService.name, () => {
|
|||||||
|
|
||||||
describe('searchPerson', () => {
|
describe('searchPerson', () => {
|
||||||
it('should pass options to search', async () => {
|
it('should pass options to search', async () => {
|
||||||
const { name } = personStub.withName;
|
const auth = AuthFactory.create();
|
||||||
|
const name = 'foo';
|
||||||
|
|
||||||
mocks.person.getByName.mockResolvedValue([]);
|
mocks.person.getByName.mockResolvedValue([]);
|
||||||
|
|
||||||
await sut.searchPerson(authStub.user1, { name, withHidden: false });
|
await sut.searchPerson(auth, { name, withHidden: false });
|
||||||
|
|
||||||
expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false });
|
expect(mocks.person.getByName).toHaveBeenCalledWith(auth.user.id, name, { withHidden: false });
|
||||||
|
|
||||||
await sut.searchPerson(authStub.user1, { name, withHidden: true });
|
await sut.searchPerson(auth, { name, withHidden: true });
|
||||||
|
|
||||||
expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: true });
|
expect(mocks.person.getByName).toHaveBeenCalledWith(auth.user.id, name, { withHidden: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const SYNC_TYPES_ORDER = [
|
|||||||
SyncRequestType.AssetFacesV2,
|
SyncRequestType.AssetFacesV2,
|
||||||
SyncRequestType.UserMetadataV1,
|
SyncRequestType.UserMetadataV1,
|
||||||
SyncRequestType.AssetMetadataV1,
|
SyncRequestType.AssetMetadataV1,
|
||||||
|
SyncRequestType.AssetEditsV1,
|
||||||
];
|
];
|
||||||
|
|
||||||
const throwSessionRequired = () => {
|
const throwSessionRequired = () => {
|
||||||
@@ -177,6 +178,7 @@ export class SyncService extends BaseService {
|
|||||||
[SyncRequestType.PartnersV1]: () => this.syncPartnersV1(options, response, checkpointMap),
|
[SyncRequestType.PartnersV1]: () => this.syncPartnersV1(options, response, checkpointMap),
|
||||||
[SyncRequestType.AssetsV1]: () => this.syncAssetsV1(options, response, checkpointMap),
|
[SyncRequestType.AssetsV1]: () => this.syncAssetsV1(options, response, checkpointMap),
|
||||||
[SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(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.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(options, response, checkpointMap, session.id),
|
||||||
[SyncRequestType.AssetMetadataV1]: () => this.syncAssetMetadataV1(options, response, checkpointMap, auth),
|
[SyncRequestType.AssetMetadataV1]: () => this.syncAssetMetadataV1(options, response, checkpointMap, auth),
|
||||||
[SyncRequestType.PartnerAssetExifsV1]: () =>
|
[SyncRequestType.PartnerAssetExifsV1]: () =>
|
||||||
@@ -218,6 +220,7 @@ export class SyncService extends BaseService {
|
|||||||
await this.syncRepository.asset.cleanupAuditTable(pruneThreshold);
|
await this.syncRepository.asset.cleanupAuditTable(pruneThreshold);
|
||||||
await this.syncRepository.assetFace.cleanupAuditTable(pruneThreshold);
|
await this.syncRepository.assetFace.cleanupAuditTable(pruneThreshold);
|
||||||
await this.syncRepository.assetMetadata.cleanupAuditTable(pruneThreshold);
|
await this.syncRepository.assetMetadata.cleanupAuditTable(pruneThreshold);
|
||||||
|
await this.syncRepository.assetEdit.cleanupAuditTable(pruneThreshold);
|
||||||
await this.syncRepository.memory.cleanupAuditTable(pruneThreshold);
|
await this.syncRepository.memory.cleanupAuditTable(pruneThreshold);
|
||||||
await this.syncRepository.memoryToAsset.cleanupAuditTable(pruneThreshold);
|
await this.syncRepository.memoryToAsset.cleanupAuditTable(pruneThreshold);
|
||||||
await this.syncRepository.partner.cleanupAuditTable(pruneThreshold);
|
await this.syncRepository.partner.cleanupAuditTable(pruneThreshold);
|
||||||
@@ -356,6 +359,21 @@ 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(
|
private async syncPartnerAssetExifsV1(
|
||||||
options: SyncQueryOptions,
|
options: SyncQueryOptions,
|
||||||
response: Writable,
|
response: Writable,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
|
|||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
import { build } from 'test/factories/builder.factory';
|
import { build } from 'test/factories/builder.factory';
|
||||||
import { AssetEditLike, AssetLike, FactoryBuilder } from 'test/factories/types';
|
import { AssetEditLike, AssetLike, FactoryBuilder } from 'test/factories/types';
|
||||||
import { newUuid } from 'test/small.factory';
|
import { newDate, newUuid } from 'test/small.factory';
|
||||||
|
|
||||||
export class AssetEditFactory {
|
export class AssetEditFactory {
|
||||||
private constructor(private readonly value: Selectable<AssetEditTable>) {}
|
private constructor(private readonly value: Selectable<AssetEditTable>) {}
|
||||||
@@ -15,6 +15,7 @@ export class AssetEditFactory {
|
|||||||
|
|
||||||
static from(dto: AssetEditLike = {}) {
|
static from(dto: AssetEditLike = {}) {
|
||||||
const id = dto.id ?? newUuid();
|
const id = dto.id ?? newUuid();
|
||||||
|
const updateId = dto.updateId ?? newUuid();
|
||||||
|
|
||||||
return new AssetEditFactory({
|
return new AssetEditFactory({
|
||||||
id,
|
id,
|
||||||
@@ -22,6 +23,8 @@ export class AssetEditFactory {
|
|||||||
action: AssetEditAction.Crop,
|
action: AssetEditAction.Crop,
|
||||||
parameters: { x: 5, y: 6, width: 200, height: 100 },
|
parameters: { x: 5, y: 6, width: 200, height: 100 },
|
||||||
sequence: 1,
|
sequence: 1,
|
||||||
|
updateId,
|
||||||
|
updatedAt: newDate(),
|
||||||
...dto,
|
...dto,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-165
@@ -2,171 +2,6 @@ import { AssetFileType, AssetType } from 'src/enum';
|
|||||||
import { AssetFileFactory } from 'test/factories/asset-file.factory';
|
import { AssetFileFactory } from 'test/factories/asset-file.factory';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
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 = {
|
export const personThumbnailStub = {
|
||||||
newThumbnailStart: Object.freeze({
|
newThumbnailStart: Object.freeze({
|
||||||
ownerId: userStub.admin.id,
|
ownerId: userStub.admin.id,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||||
|
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { PartnerRepository } from 'src/repositories/partner.repository';
|
import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||||
import { UserRepository } from 'src/repositories/user.repository';
|
import { UserRepository } from 'src/repositories/user.repository';
|
||||||
@@ -45,6 +47,27 @@ 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', () => {
|
describe('assets_audit', () => {
|
||||||
it('should not cascade user deletes to assets_audit', async () => {
|
it('should not cascade user deletes to assets_audit', async () => {
|
||||||
const userRepo = ctx.get(UserRepository);
|
const userRepo = ctx.get(UserRepository);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { schemaFromCode } from '@immich/sql-tools';
|
||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { AssetMetadataKey, UserMetadataKey } from 'src/enum';
|
import { AssetMetadataKey, UserMetadataKey } from 'src/enum';
|
||||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { SyncRepository } from 'src/repositories/sync.repository';
|
import { BaseSync, SyncRepository } from 'src/repositories/sync.repository';
|
||||||
import { DB } from 'src/schema';
|
import { DB } from 'src/schema';
|
||||||
import { SyncService } from 'src/services/sync.service';
|
import { SyncService } from 'src/services/sync.service';
|
||||||
import { newMediumService } from 'test/medium.factory';
|
import { newMediumService } from 'test/medium.factory';
|
||||||
@@ -222,5 +223,21 @@ describe(SyncService.name, () => {
|
|||||||
expect(after).toHaveLength(1);
|
expect(after).toHaveLength(1);
|
||||||
expect(after[0].id).toBe(keep.id);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
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<DB>;
|
||||||
|
|
||||||
|
const setup = async (db?: Kysely<DB>) => {
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Oops! Error - Immich</title>
|
<title>{$t('error')} - Immich</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<section class="flex flex-col px-4 h-dvh w-dvw place-content-center place-items-center">
|
<section class="flex flex-col px-4 h-dvh w-dvw place-content-center place-items-center">
|
||||||
<h1 class="py-10 text-4xl text-primary">Page not found :/</h1>
|
<h1 class="py-10 text-4xl text-primary">{$t('errors.page_not_found')}</h1>
|
||||||
{#if page.error?.message}
|
{#if page.error?.message}
|
||||||
<h2 class="text-xl text-immich-fg dark:text-immich-dark-fg">{page.error.message}</h2>
|
<h2 class="text-xl text-immich-fg dark:text-immich-dark-fg">{page.error.message}</h2>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type MaintenanceStatusResponseDto,
|
type MaintenanceStatusResponseDto,
|
||||||
type NotificationDto,
|
type NotificationDto,
|
||||||
type ServerVersionResponseDto,
|
type ServerVersionResponseDto,
|
||||||
|
type SyncAssetEditV1,
|
||||||
type SyncAssetV1,
|
type SyncAssetV1,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { io, type Socket } from 'socket.io-client';
|
import { io, type Socket } from 'socket.io-client';
|
||||||
@@ -41,7 +42,7 @@ export interface Events {
|
|||||||
AppRestartV1: (event: AppRestartEvent) => void;
|
AppRestartV1: (event: AppRestartEvent) => void;
|
||||||
|
|
||||||
MaintenanceStatusV1: (event: MaintenanceStatusResponseDto) => void;
|
MaintenanceStatusV1: (event: MaintenanceStatusResponseDto) => void;
|
||||||
AssetEditReadyV1: (data: { asset: SyncAssetV1 }) => void;
|
AssetEditReadyV1: (data: { asset: SyncAssetV1; edit: SyncAssetEditV1[] }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const websocket: Socket<Events> = io({
|
const websocket: Socket<Events> = io({
|
||||||
|
|||||||
Reference in New Issue
Block a user