diff --git a/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts b/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts index 6314688abbfaa..5faf8380d14fd 100644 --- a/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts +++ b/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts @@ -463,7 +463,7 @@ test.describe('Timeline', () => { }); changes.albumAdditions.push(...requestJson.ids); }); - await page.getByText('Done').click(); + await page.getByText('Add assets').click(); await expect(put).resolves.toEqual({ ids: [ 'c077ea7b-cfa1-45e4-8554-f86c00ee5658', diff --git a/i18n/en.json b/i18n/en.json index 13fc965b6581a..473bd6f37b231 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -18,6 +18,7 @@ "add_a_title": "Add a title", "add_action": "Add action", "add_action_description": "Click to add an action to perform", + "add_assets": "Add assets", "add_birthday": "Add a birthday", "add_endpoint": "Add endpoint", "add_exclusion_pattern": "Add exclusion pattern", @@ -478,6 +479,7 @@ "album_summary": "Album summary", "album_updated": "Album updated", "album_updated_setting_description": "Receive an email notification when a shared album has new assets", + "album_upload_assets": "Upload assets from your computer and add to album", "album_user_left": "Left {album}", "album_user_removed": "Removed {user}", "album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 7277a99ac8bd2..657e62bf6d9bc 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -100,6 +100,7 @@ Class | Method | HTTP request | Description *AssetsApi* | [**copyAsset**](doc//AssetsApi.md#copyasset) | **PUT** /assets/copy | Copy asset *AssetsApi* | [**deleteAssetMetadata**](doc//AssetsApi.md#deleteassetmetadata) | **DELETE** /assets/{id}/metadata/{key} | Delete asset metadata by key *AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets | Delete assets +*AssetsApi* | [**deleteBulkAssetMetadata**](doc//AssetsApi.md#deletebulkassetmetadata) | **DELETE** /assets/metadata | Delete asset metadata *AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | Download original asset *AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID *AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | Retrieve an asset @@ -114,6 +115,7 @@ Class | Method | HTTP request | Description *AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | Update an asset *AssetsApi* | [**updateAssetMetadata**](doc//AssetsApi.md#updateassetmetadata) | **PUT** /assets/{id}/metadata | Update asset metadata *AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets | Update assets +*AssetsApi* | [**updateBulkAssetMetadata**](doc//AssetsApi.md#updatebulkassetmetadata) | **PUT** /assets/metadata | Upsert asset metadata *AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | Upload asset *AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | View asset thumbnail *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | Change password @@ -358,7 +360,11 @@ Class | Method | HTTP request | Description - [AssetMediaResponseDto](doc//AssetMediaResponseDto.md) - [AssetMediaSize](doc//AssetMediaSize.md) - [AssetMediaStatus](doc//AssetMediaStatus.md) - - [AssetMetadataKey](doc//AssetMetadataKey.md) + - [AssetMetadataBulkDeleteDto](doc//AssetMetadataBulkDeleteDto.md) + - [AssetMetadataBulkDeleteItemDto](doc//AssetMetadataBulkDeleteItemDto.md) + - [AssetMetadataBulkResponseDto](doc//AssetMetadataBulkResponseDto.md) + - [AssetMetadataBulkUpsertDto](doc//AssetMetadataBulkUpsertDto.md) + - [AssetMetadataBulkUpsertItemDto](doc//AssetMetadataBulkUpsertItemDto.md) - [AssetMetadataResponseDto](doc//AssetMetadataResponseDto.md) - [AssetMetadataUpsertDto](doc//AssetMetadataUpsertDto.md) - [AssetMetadataUpsertItemDto](doc//AssetMetadataUpsertItemDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 05d1803979be9..59f31d1392e05 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -109,7 +109,11 @@ part 'model/asset_jobs_dto.dart'; part 'model/asset_media_response_dto.dart'; part 'model/asset_media_size.dart'; part 'model/asset_media_status.dart'; -part 'model/asset_metadata_key.dart'; +part 'model/asset_metadata_bulk_delete_dto.dart'; +part 'model/asset_metadata_bulk_delete_item_dto.dart'; +part 'model/asset_metadata_bulk_response_dto.dart'; +part 'model/asset_metadata_bulk_upsert_dto.dart'; +part 'model/asset_metadata_bulk_upsert_item_dto.dart'; part 'model/asset_metadata_response_dto.dart'; part 'model/asset_metadata_upsert_dto.dart'; part 'model/asset_metadata_upsert_item_dto.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index 5020afc4b255b..ac50d015ed9ca 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -186,12 +186,12 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetMetadataKey] key (required): - Future deleteAssetMetadataWithHttpInfo(String id, AssetMetadataKey key,) async { + /// * [String] key (required): + Future deleteAssetMetadataWithHttpInfo(String id, String key,) async { // ignore: prefer_const_declarations final apiPath = r'/assets/{id}/metadata/{key}' .replaceAll('{id}', id) - .replaceAll('{key}', key.toString()); + .replaceAll('{key}', key); // ignore: prefer_final_locals Object? postBody; @@ -222,8 +222,8 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetMetadataKey] key (required): - Future deleteAssetMetadata(String id, AssetMetadataKey key,) async { + /// * [String] key (required): + Future deleteAssetMetadata(String id, String key,) async { final response = await deleteAssetMetadataWithHttpInfo(id, key,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -278,6 +278,54 @@ class AssetsApi { } } + /// Delete asset metadata + /// + /// Delete metadata key-value pairs for multiple assets. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [AssetMetadataBulkDeleteDto] assetMetadataBulkDeleteDto (required): + Future deleteBulkAssetMetadataWithHttpInfo(AssetMetadataBulkDeleteDto assetMetadataBulkDeleteDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/metadata'; + + // ignore: prefer_final_locals + Object? postBody = assetMetadataBulkDeleteDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Delete asset metadata + /// + /// Delete metadata key-value pairs for multiple assets. + /// + /// Parameters: + /// + /// * [AssetMetadataBulkDeleteDto] assetMetadataBulkDeleteDto (required): + Future deleteBulkAssetMetadata(AssetMetadataBulkDeleteDto assetMetadataBulkDeleteDto,) async { + final response = await deleteBulkAssetMetadataWithHttpInfo(assetMetadataBulkDeleteDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Download original asset /// /// Downloads the original file of the specified asset. @@ -552,12 +600,12 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetMetadataKey] key (required): - Future getAssetMetadataByKeyWithHttpInfo(String id, AssetMetadataKey key,) async { + /// * [String] key (required): + Future getAssetMetadataByKeyWithHttpInfo(String id, String key,) async { // ignore: prefer_const_declarations final apiPath = r'/assets/{id}/metadata/{key}' .replaceAll('{id}', id) - .replaceAll('{key}', key.toString()); + .replaceAll('{key}', key); // ignore: prefer_final_locals Object? postBody; @@ -588,8 +636,8 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetMetadataKey] key (required): - Future getAssetMetadataByKey(String id, AssetMetadataKey key,) async { + /// * [String] key (required): + Future getAssetMetadataByKey(String id, String key,) async { final response = await getAssetMetadataByKeyWithHttpInfo(id, key,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -1228,6 +1276,65 @@ class AssetsApi { } } + /// Upsert asset metadata + /// + /// Upsert metadata key-value pairs for multiple assets. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [AssetMetadataBulkUpsertDto] assetMetadataBulkUpsertDto (required): + Future updateBulkAssetMetadataWithHttpInfo(AssetMetadataBulkUpsertDto assetMetadataBulkUpsertDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/metadata'; + + // ignore: prefer_final_locals + Object? postBody = assetMetadataBulkUpsertDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Upsert asset metadata + /// + /// Upsert metadata key-value pairs for multiple assets. + /// + /// Parameters: + /// + /// * [AssetMetadataBulkUpsertDto] assetMetadataBulkUpsertDto (required): + Future?> updateBulkAssetMetadata(AssetMetadataBulkUpsertDto assetMetadataBulkUpsertDto,) async { + final response = await updateBulkAssetMetadataWithHttpInfo(assetMetadataBulkUpsertDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// Upload asset /// /// Uploads a new asset to the server. @@ -1246,8 +1353,6 @@ class AssetsApi { /// /// * [DateTime] fileModifiedAt (required): /// - /// * [List] metadata (required): - /// /// * [String] key: /// /// * [String] slug: @@ -1263,10 +1368,12 @@ class AssetsApi { /// /// * [String] livePhotoVideoId: /// + /// * [List] metadata: + /// /// * [MultipartFile] sidecarData: /// /// * [AssetVisibility] visibility: - Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, List metadata, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { + Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { // ignore: prefer_const_declarations final apiPath = r'/assets'; @@ -1373,8 +1480,6 @@ class AssetsApi { /// /// * [DateTime] fileModifiedAt (required): /// - /// * [List] metadata (required): - /// /// * [String] key: /// /// * [String] slug: @@ -1390,11 +1495,13 @@ class AssetsApi { /// /// * [String] livePhotoVideoId: /// + /// * [List] metadata: + /// /// * [MultipartFile] sidecarData: /// /// * [AssetVisibility] visibility: - Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, List metadata, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { - final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, metadata, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, visibility: visibility, ); + Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { + final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 39aea82c89499..6f68e44cefb77 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -266,8 +266,16 @@ class ApiClient { return AssetMediaSizeTypeTransformer().decode(value); case 'AssetMediaStatus': return AssetMediaStatusTypeTransformer().decode(value); - case 'AssetMetadataKey': - return AssetMetadataKeyTypeTransformer().decode(value); + case 'AssetMetadataBulkDeleteDto': + return AssetMetadataBulkDeleteDto.fromJson(value); + case 'AssetMetadataBulkDeleteItemDto': + return AssetMetadataBulkDeleteItemDto.fromJson(value); + case 'AssetMetadataBulkResponseDto': + return AssetMetadataBulkResponseDto.fromJson(value); + case 'AssetMetadataBulkUpsertDto': + return AssetMetadataBulkUpsertDto.fromJson(value); + case 'AssetMetadataBulkUpsertItemDto': + return AssetMetadataBulkUpsertItemDto.fromJson(value); case 'AssetMetadataResponseDto': return AssetMetadataResponseDto.fromJson(value); case 'AssetMetadataUpsertDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 39caa4534f1be..1a5f703c78671 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -67,9 +67,6 @@ String parameterToString(dynamic value) { if (value is AssetMediaStatus) { return AssetMediaStatusTypeTransformer().encode(value).toString(); } - if (value is AssetMetadataKey) { - return AssetMetadataKeyTypeTransformer().encode(value).toString(); - } if (value is AssetOrder) { return AssetOrderTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_delete_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_delete_dto.dart new file mode 100644 index 0000000000000..23c34d7152c5f --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_delete_dto.dart @@ -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 AssetMetadataBulkDeleteDto { + /// Returns a new [AssetMetadataBulkDeleteDto] instance. + AssetMetadataBulkDeleteDto({ + this.items = const [], + }); + + List items; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkDeleteDto && + _deepEquality.equals(other.items, items); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (items.hashCode); + + @override + String toString() => 'AssetMetadataBulkDeleteDto[items=$items]'; + + Map toJson() { + final json = {}; + json[r'items'] = this.items; + return json; + } + + /// Returns a new [AssetMetadataBulkDeleteDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkDeleteDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkDeleteDto( + items: AssetMetadataBulkDeleteItemDto.listFromJson(json[r'items']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataBulkDeleteDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataBulkDeleteDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkDeleteDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataBulkDeleteDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'items', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_delete_item_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_delete_item_dto.dart new file mode 100644 index 0000000000000..a3a111f9f72c8 --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_delete_item_dto.dart @@ -0,0 +1,107 @@ +// +// 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 AssetMetadataBulkDeleteItemDto { + /// Returns a new [AssetMetadataBulkDeleteItemDto] instance. + AssetMetadataBulkDeleteItemDto({ + required this.assetId, + required this.key, + }); + + String assetId; + + String key; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkDeleteItemDto && + other.assetId == assetId && + other.key == key; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (key.hashCode); + + @override + String toString() => 'AssetMetadataBulkDeleteItemDto[assetId=$assetId, key=$key]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'key'] = this.key; + return json; + } + + /// Returns a new [AssetMetadataBulkDeleteItemDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkDeleteItemDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkDeleteItemDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkDeleteItemDto( + assetId: mapValueOfType(json, r'assetId')!, + key: mapValueOfType(json, r'key')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataBulkDeleteItemDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataBulkDeleteItemDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkDeleteItemDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataBulkDeleteItemDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'key', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_response_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_response_dto.dart new file mode 100644 index 0000000000000..15c130930bef1 --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_response_dto.dart @@ -0,0 +1,123 @@ +// +// 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 AssetMetadataBulkResponseDto { + /// Returns a new [AssetMetadataBulkResponseDto] instance. + AssetMetadataBulkResponseDto({ + required this.assetId, + required this.key, + required this.updatedAt, + required this.value, + }); + + String assetId; + + String key; + + DateTime updatedAt; + + Object value; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkResponseDto && + other.assetId == assetId && + other.key == key && + other.updatedAt == updatedAt && + other.value == value; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (key.hashCode) + + (updatedAt.hashCode) + + (value.hashCode); + + @override + String toString() => 'AssetMetadataBulkResponseDto[assetId=$assetId, key=$key, updatedAt=$updatedAt, value=$value]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'key'] = this.key; + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + json[r'value'] = this.value; + return json; + } + + /// Returns a new [AssetMetadataBulkResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkResponseDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkResponseDto( + assetId: mapValueOfType(json, r'assetId')!, + key: mapValueOfType(json, r'key')!, + updatedAt: mapDateTime(json, r'updatedAt', r'')!, + value: mapValueOfType(json, r'value')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataBulkResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataBulkResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataBulkResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'key', + 'updatedAt', + 'value', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_upsert_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_upsert_dto.dart new file mode 100644 index 0000000000000..fe9d9ed251122 --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_upsert_dto.dart @@ -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 AssetMetadataBulkUpsertDto { + /// Returns a new [AssetMetadataBulkUpsertDto] instance. + AssetMetadataBulkUpsertDto({ + this.items = const [], + }); + + List items; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkUpsertDto && + _deepEquality.equals(other.items, items); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (items.hashCode); + + @override + String toString() => 'AssetMetadataBulkUpsertDto[items=$items]'; + + Map toJson() { + final json = {}; + json[r'items'] = this.items; + return json; + } + + /// Returns a new [AssetMetadataBulkUpsertDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkUpsertDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkUpsertDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkUpsertDto( + items: AssetMetadataBulkUpsertItemDto.listFromJson(json[r'items']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataBulkUpsertDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataBulkUpsertDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkUpsertDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataBulkUpsertDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'items', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_upsert_item_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_upsert_item_dto.dart new file mode 100644 index 0000000000000..25a219537eda8 --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_upsert_item_dto.dart @@ -0,0 +1,115 @@ +// +// 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 AssetMetadataBulkUpsertItemDto { + /// Returns a new [AssetMetadataBulkUpsertItemDto] instance. + AssetMetadataBulkUpsertItemDto({ + required this.assetId, + required this.key, + required this.value, + }); + + String assetId; + + String key; + + Object value; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkUpsertItemDto && + other.assetId == assetId && + other.key == key && + other.value == value; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (key.hashCode) + + (value.hashCode); + + @override + String toString() => 'AssetMetadataBulkUpsertItemDto[assetId=$assetId, key=$key, value=$value]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'key'] = this.key; + json[r'value'] = this.value; + return json; + } + + /// Returns a new [AssetMetadataBulkUpsertItemDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkUpsertItemDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkUpsertItemDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkUpsertItemDto( + assetId: mapValueOfType(json, r'assetId')!, + key: mapValueOfType(json, r'key')!, + value: mapValueOfType(json, r'value')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataBulkUpsertItemDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataBulkUpsertItemDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkUpsertItemDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataBulkUpsertItemDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'key', + 'value', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_key.dart b/mobile/openapi/lib/model/asset_metadata_key.dart deleted file mode 100644 index 70186cd41c2aa..0000000000000 --- a/mobile/openapi/lib/model/asset_metadata_key.dart +++ /dev/null @@ -1,82 +0,0 @@ -// -// 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 AssetMetadataKey { - /// Instantiate a new enum with the provided [value]. - const AssetMetadataKey._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const mobileApp = AssetMetadataKey._(r'mobile-app'); - - /// List of all possible values in this [enum][AssetMetadataKey]. - static const values = [ - mobileApp, - ]; - - static AssetMetadataKey? fromJson(dynamic value) => AssetMetadataKeyTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = AssetMetadataKey.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [AssetMetadataKey] to String, -/// and [decode] dynamic data back to [AssetMetadataKey]. -class AssetMetadataKeyTypeTransformer { - factory AssetMetadataKeyTypeTransformer() => _instance ??= const AssetMetadataKeyTypeTransformer._(); - - const AssetMetadataKeyTypeTransformer._(); - - String encode(AssetMetadataKey data) => data.value; - - /// Decodes a [dynamic value][data] to a AssetMetadataKey. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - AssetMetadataKey? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'mobile-app': return AssetMetadataKey.mobileApp; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [AssetMetadataKeyTypeTransformer] instance. - static AssetMetadataKeyTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/asset_metadata_response_dto.dart b/mobile/openapi/lib/model/asset_metadata_response_dto.dart index af5769b9bbacc..cccf42ae87ed0 100644 --- a/mobile/openapi/lib/model/asset_metadata_response_dto.dart +++ b/mobile/openapi/lib/model/asset_metadata_response_dto.dart @@ -18,7 +18,7 @@ class AssetMetadataResponseDto { required this.value, }); - AssetMetadataKey key; + String key; DateTime updatedAt; @@ -57,7 +57,7 @@ class AssetMetadataResponseDto { final json = value.cast(); return AssetMetadataResponseDto( - key: AssetMetadataKey.fromJson(json[r'key'])!, + key: mapValueOfType(json, r'key')!, updatedAt: mapDateTime(json, r'updatedAt', r'')!, value: mapValueOfType(json, r'value')!, ); diff --git a/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart b/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart index 4b7e6579a12cd..3d247f8572762 100644 --- a/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart +++ b/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart @@ -17,7 +17,7 @@ class AssetMetadataUpsertItemDto { required this.value, }); - AssetMetadataKey key; + String key; Object value; @@ -51,7 +51,7 @@ class AssetMetadataUpsertItemDto { final json = value.cast(); return AssetMetadataUpsertItemDto( - key: AssetMetadataKey.fromJson(json[r'key'])!, + key: mapValueOfType(json, r'key')!, value: mapValueOfType(json, r'value')!, ); } diff --git a/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart b/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart index c9a7ef4670daf..cf67b68dd240f 100644 --- a/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart +++ b/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart @@ -19,7 +19,7 @@ class SyncAssetMetadataDeleteV1 { String assetId; - AssetMetadataKey key; + String key; @override bool operator ==(Object other) => identical(this, other) || other is SyncAssetMetadataDeleteV1 && @@ -52,7 +52,7 @@ class SyncAssetMetadataDeleteV1 { return SyncAssetMetadataDeleteV1( assetId: mapValueOfType(json, r'assetId')!, - key: AssetMetadataKey.fromJson(json[r'key'])!, + key: mapValueOfType(json, r'key')!, ); } return null; diff --git a/mobile/openapi/lib/model/sync_asset_metadata_v1.dart b/mobile/openapi/lib/model/sync_asset_metadata_v1.dart index 720fcef947d54..4fa6ed84ed012 100644 --- a/mobile/openapi/lib/model/sync_asset_metadata_v1.dart +++ b/mobile/openapi/lib/model/sync_asset_metadata_v1.dart @@ -20,7 +20,7 @@ class SyncAssetMetadataV1 { String assetId; - AssetMetadataKey key; + String key; Object value; @@ -58,7 +58,7 @@ class SyncAssetMetadataV1 { return SyncAssetMetadataV1( assetId: mapValueOfType(json, r'assetId')!, - key: AssetMetadataKey.fromJson(json[r'key'])!, + key: mapValueOfType(json, r'key')!, value: mapValueOfType(json, r'value')!, ); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7e859ffee05c7..25be8d2f45b69 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2906,6 +2906,112 @@ "x-immich-state": "Stable" } }, + "/assets/metadata": { + "delete": { + "description": "Delete metadata key-value pairs for multiple assets.", + "operationId": "deleteBulkAssetMetadata", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMetadataBulkDeleteDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Delete asset metadata", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Beta" + } + ], + "x-immich-permission": "asset.update", + "x-immich-state": "Beta" + }, + "put": { + "description": "Upsert metadata key-value pairs for multiple assets.", + "operationId": "updateBulkAssetMetadata", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMetadataBulkUpsertDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetMetadataBulkResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Upsert asset metadata", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Beta" + } + ], + "x-immich-permission": "asset.update", + "x-immich-state": "Beta" + } + }, "/assets/random": { "get": { "deprecated": true, @@ -3340,7 +3446,7 @@ "required": true, "in": "path", "schema": { - "$ref": "#/components/schemas/AssetMetadataKey" + "type": "string" } } ], @@ -3399,7 +3505,7 @@ "required": true, "in": "path", "schema": { - "$ref": "#/components/schemas/AssetMetadataKey" + "type": "string" } } ], @@ -15499,8 +15605,7 @@ "deviceAssetId", "deviceId", "fileCreatedAt", - "fileModifiedAt", - "metadata" + "fileModifiedAt" ], "type": "object" }, @@ -15575,20 +15680,98 @@ ], "type": "string" }, - "AssetMetadataKey": { - "enum": [ - "mobile-app" + "AssetMetadataBulkDeleteDto": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AssetMetadataBulkDeleteItemDto" + }, + "type": "array" + } + }, + "required": [ + "items" ], - "type": "string" + "type": "object" + }, + "AssetMetadataBulkDeleteItemDto": { + "properties": { + "assetId": { + "format": "uuid", + "type": "string" + }, + "key": { + "type": "string" + } + }, + "required": [ + "assetId", + "key" + ], + "type": "object" + }, + "AssetMetadataBulkResponseDto": { + "properties": { + "assetId": { + "type": "string" + }, + "key": { + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "value": { + "type": "object" + } + }, + "required": [ + "assetId", + "key", + "updatedAt", + "value" + ], + "type": "object" + }, + "AssetMetadataBulkUpsertDto": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AssetMetadataBulkUpsertItemDto" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "AssetMetadataBulkUpsertItemDto": { + "properties": { + "assetId": { + "format": "uuid", + "type": "string" + }, + "key": { + "type": "string" + }, + "value": { + "type": "object" + } + }, + "required": [ + "assetId", + "key", + "value" + ], + "type": "object" }, "AssetMetadataResponseDto": { "properties": { "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" }, "updatedAt": { "format": "date-time", @@ -15622,11 +15805,7 @@ "AssetMetadataUpsertItemDto": { "properties": { "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" }, "value": { "type": "object" @@ -20651,11 +20830,7 @@ "type": "string" }, "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" } }, "required": [ @@ -20670,11 +20845,7 @@ "type": "string" }, "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" }, "value": { "type": "object" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 5e024b560ccba..ab6b894c60343 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -471,7 +471,7 @@ export type AssetBulkDeleteDto = { ids: string[]; }; export type AssetMetadataUpsertItemDto = { - key: AssetMetadataKey; + key: string; value: object; }; export type AssetMediaCreateDto = { @@ -484,7 +484,7 @@ export type AssetMediaCreateDto = { filename?: string; isFavorite?: boolean; livePhotoVideoId?: string; - metadata: AssetMetadataUpsertItemDto[]; + metadata?: AssetMetadataUpsertItemDto[]; sidecarData?: Blob; visibility?: AssetVisibility; }; @@ -543,6 +543,27 @@ export type AssetJobsDto = { assetIds: string[]; name: AssetJobName; }; +export type AssetMetadataBulkDeleteItemDto = { + assetId: string; + key: string; +}; +export type AssetMetadataBulkDeleteDto = { + items: AssetMetadataBulkDeleteItemDto[]; +}; +export type AssetMetadataBulkUpsertItemDto = { + assetId: string; + key: string; + value: object; +}; +export type AssetMetadataBulkUpsertDto = { + items: AssetMetadataBulkUpsertItemDto[]; +}; +export type AssetMetadataBulkResponseDto = { + assetId: string; + key: string; + updatedAt: string; + value: object; +}; export type UpdateAssetDto = { dateTimeOriginal?: string; description?: string; @@ -554,7 +575,7 @@ export type UpdateAssetDto = { visibility?: AssetVisibility; }; export type AssetMetadataResponseDto = { - key: AssetMetadataKey; + key: string; updatedAt: string; value: object; }; @@ -2462,6 +2483,33 @@ export function runAssetJobs({ assetJobsDto }: { body: assetJobsDto }))); } +/** + * Delete asset metadata + */ +export function deleteBulkAssetMetadata({ assetMetadataBulkDeleteDto }: { + assetMetadataBulkDeleteDto: AssetMetadataBulkDeleteDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/assets/metadata", oazapfts.json({ + ...opts, + method: "DELETE", + body: assetMetadataBulkDeleteDto + }))); +} +/** + * Upsert asset metadata + */ +export function updateBulkAssetMetadata({ assetMetadataBulkUpsertDto }: { + assetMetadataBulkUpsertDto: AssetMetadataBulkUpsertDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetMetadataBulkResponseDto[]; + }>("/assets/metadata", oazapfts.json({ + ...opts, + method: "PUT", + body: assetMetadataBulkUpsertDto + }))); +} /** * Get random assets */ @@ -2564,7 +2612,7 @@ export function updateAssetMetadata({ id, assetMetadataUpsertDto }: { */ export function deleteAssetMetadata({ id, key }: { id: string; - key: AssetMetadataKey; + key: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText(`/assets/${encodeURIComponent(id)}/metadata/${encodeURIComponent(key)}`, { ...opts, @@ -2576,7 +2624,7 @@ export function deleteAssetMetadata({ id, key }: { */ export function getAssetMetadataByKey({ id, key }: { id: string; - key: AssetMetadataKey; + key: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -5363,9 +5411,6 @@ export enum Permission { AdminSessionRead = "adminSession.read", AdminAuthUnlinkAll = "adminAuth.unlinkAll" } -export enum AssetMetadataKey { - MobileApp = "mobile-app" -} export enum AssetMediaStatus { Created = "created", Replaced = "replaced", diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 649c80e8505c7..56c9d18049244 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -79,6 +79,74 @@ describe(AssetController.name, () => { }); }); + describe('PUT /assets/metadata', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/assets/metadata`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid assetId', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put('/assets/metadata') + .send({ items: [{ assetId: '123', key: 'test', value: {} }] }); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID']))); + }); + + it('should require a key', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put('/assets/metadata') + .send({ items: [{ assetId: factory.uuid(), value: {} }] }); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest( + expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']), + ), + ); + }); + + it('should work', async () => { + const { status } = await request(ctx.getHttpServer()) + .put('/assets/metadata') + .send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } }] }); + expect(status).toBe(200); + }); + }); + + describe('DELETE /assets/metadata', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).delete(`/assets/metadata`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid assetId', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .delete('/assets/metadata') + .send({ items: [{ assetId: '123', key: 'test' }] }); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID']))); + }); + + it('should require a key', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .delete('/assets/metadata') + .send({ items: [{ assetId: factory.uuid() }] }); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest( + expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']), + ), + ); + }); + + it('should work', async () => { + const { status } = await request(ctx.getHttpServer()) + .delete('/assets/metadata') + .send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp }] }); + expect(status).toBe(204); + }); + }); + describe('PUT /assets/:id', () => { it('should be an authenticated route', async () => { await request(ctx.getHttpServer()).get(`/assets/123`); @@ -169,12 +237,10 @@ describe(AssetController.name, () => { it('should require each item to have a valid key', async () => { const { status, body } = await request(ctx.getHttpServer()) .put(`/assets/${factory.uuid()}/metadata`) - .send({ items: [{ key: 'someKey' }] }); + .send({ items: [{ value: { some: 'value' } }] }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining([expect.stringContaining('items.0.key must be one of the following values')]), - ), + factory.responses.badRequest(['items.0.key must be a string', 'items.0.key should not be empty']), ); }); @@ -224,16 +290,6 @@ describe(AssetController.name, () => { expect(status).toBe(400); expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID']))); }); - - it('should require a valid key', async () => { - const { status, body } = await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/invalid`); - expect(status).toBe(400); - expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining([expect.stringContaining('key must be one of the following value')]), - ), - ); - }); }); describe('DELETE /assets/:id/metadata/:key', () => { @@ -247,13 +303,5 @@ describe(AssetController.name, () => { expect(status).toBe(400); expect(body).toEqual(factory.responses.badRequest(['id must be a UUID'])); }); - - it('should require a valid key', async () => { - const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/invalid`); - expect(status).toBe(400); - expect(body).toEqual( - factory.responses.badRequest([expect.stringContaining('key must be one of the following values')]), - ); - }); }); }); diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index bcc13fbc06d69..ba9ec865f913a 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -7,6 +7,9 @@ import { AssetBulkUpdateDto, AssetCopyDto, AssetJobsDto, + AssetMetadataBulkDeleteDto, + AssetMetadataBulkResponseDto, + AssetMetadataBulkUpsertDto, AssetMetadataResponseDto, AssetMetadataRouteParams, AssetMetadataUpsertDto, @@ -120,6 +123,32 @@ export class AssetController { return this.service.copy(auth, dto); } + @Put('metadata') + @Authenticated({ permission: Permission.AssetUpdate }) + @Endpoint({ + summary: 'Upsert asset metadata', + description: 'Upsert metadata key-value pairs for multiple assets.', + history: new HistoryBuilder().added('v1').beta('v2.5.0'), + }) + updateBulkAssetMetadata( + @Auth() auth: AuthDto, + @Body() dto: AssetMetadataBulkUpsertDto, + ): Promise { + return this.service.upsertBulkMetadata(auth, dto); + } + + @Delete('metadata') + @Authenticated({ permission: Permission.AssetUpdate }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete asset metadata', + description: 'Delete metadata key-value pairs for multiple assets.', + history: new HistoryBuilder().added('v1').beta('v2.5.0'), + }) + deleteBulkAssetMetadata(@Auth() auth: AuthDto, @Body() dto: AssetMetadataBulkDeleteDto): Promise { + return this.service.deleteBulkMetadata(auth, dto); + } + @Put(':id') @Authenticated({ permission: Permission.AssetUpdate }) @Endpoint({ diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index 755069d827d5e..262e2f9637b05 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -78,7 +78,7 @@ export class AssetMediaCreateDto extends AssetMediaBase { @Optional() @ValidateNested({ each: true }) @IsArray() - metadata!: AssetMetadataUpsertItemDto[]; + metadata?: AssetMetadataUpsertItemDto[]; @ApiProperty({ type: 'string', format: 'binary', required: false }) [UploadFieldName.SIDECAR_DATA]?: any; diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 03d1e31fb99b9..854c244ba9e2e 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -17,9 +17,9 @@ import { ValidateNested, } from 'class-validator'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum'; +import { AssetType, AssetVisibility } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; -import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; +import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation'; export class DeviceIdDto { @IsNotEmpty() @@ -142,8 +142,8 @@ export class AssetMetadataRouteParams { @ValidateUUID() id!: string; - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + @ValidateString() + key!: string; } export class AssetMetadataUpsertDto { @@ -154,26 +154,57 @@ export class AssetMetadataUpsertDto { } export class AssetMetadataUpsertItemDto { - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + @ValidateString() + key!: string; @IsObject() value!: object; } -export class AssetMetadataMobileAppDto { - @IsString() - @Optional() - iCloudId?: string; +export class AssetMetadataBulkUpsertDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssetMetadataBulkUpsertItemDto) + items!: AssetMetadataBulkUpsertItemDto[]; +} + +export class AssetMetadataBulkUpsertItemDto { + @ValidateUUID() + assetId!: string; + + @ValidateString() + key!: string; + + @IsObject() + value!: object; +} + +export class AssetMetadataBulkDeleteDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssetMetadataBulkDeleteItemDto) + items!: AssetMetadataBulkDeleteItemDto[]; +} + +export class AssetMetadataBulkDeleteItemDto { + @ValidateUUID() + assetId!: string; + + @ValidateString() + key!: string; } export class AssetMetadataResponseDto { - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + @ValidateString() + key!: string; value!: object; updatedAt!: Date; } +export class AssetMetadataBulkResponseDto extends AssetMetadataResponseDto { + assetId!: string; +} + export class AssetCopyDto { @ValidateUUID() sourceId!: string; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index d6a557e2c5a03..7f811af371ed9 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -4,7 +4,6 @@ import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AlbumUserRole, - AssetMetadataKey, AssetOrder, AssetType, AssetVisibility, @@ -167,16 +166,14 @@ export class SyncAssetExifV1 { @ExtraModel() export class SyncAssetMetadataV1 { assetId!: string; - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + key!: string; value!: object; } @ExtraModel() export class SyncAssetMetadataDeleteV1 { assetId!: string; - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + key!: string; } @ExtraModel() diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index f25a0798d2faa..27e40139e1d93 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -76,6 +76,14 @@ where "assetId" = $1 and "key" = $2 +-- AssetRepository.deleteBulkMetadata +begin +delete from "asset_metadata" +where + "assetId" = $1 + and "key" = $2 +commit + -- AssetRepository.getByDayOfYear with "res" as ( diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 7db3a76f12572..e1d16b8a6a189 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -5,11 +5,12 @@ import { InjectKysely } from 'nestjs-kysely'; import { LockableProperty, Stack } from 'src/database'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; +import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { anyUuid, @@ -256,7 +257,11 @@ export class AssetRepository { .execute(); } - upsertMetadata(id: string, items: Array<{ key: AssetMetadataKey; value: object }>) { + upsertMetadata(id: string, items: Array<{ key: string; value: object }>) { + if (items.length === 0) { + return []; + } + return this.db .insertInto('asset_metadata') .values(items.map((item) => ({ assetId: id, ...item }))) @@ -269,8 +274,21 @@ export class AssetRepository { .execute(); } + upsertBulkMetadata(items: Insertable[]) { + return this.db + .insertInto('asset_metadata') + .values(items) + .onConflict((oc) => + oc + .columns(['assetId', 'key']) + .doUpdateSet((eb) => ({ key: eb.ref('excluded.key'), value: eb.ref('excluded.value') })), + ) + .returning(['assetId', 'key', 'value', 'updatedAt']) + .execute(); + } + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - getMetadataByKey(assetId: string, key: AssetMetadataKey) { + getMetadataByKey(assetId: string, key: string) { return this.db .selectFrom('asset_metadata') .select(['key', 'value', 'updatedAt']) @@ -280,10 +298,23 @@ export class AssetRepository { } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async deleteMetadataByKey(id: string, key: AssetMetadataKey) { + async deleteMetadataByKey(id: string, key: string) { await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute(); } + @GenerateSql({ params: [[{ assetId: DummyValue.UUID, key: DummyValue.STRING }]] }) + async deleteBulkMetadata(items: Array<{ assetId: string; key: string }>) { + if (items.length === 0) { + return; + } + + await this.db.transaction().execute(async (tx) => { + for (const { assetId, key } of items) { + await tx.deleteFrom('asset_metadata').where('assetId', '=', assetId).where('key', '=', key).execute(); + } + }); + } + create(asset: Insertable) { return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow(); } diff --git a/server/src/schema/tables/asset-metadata-audit.table.ts b/server/src/schema/tables/asset-metadata-audit.table.ts index 3b94ce6d1a2a2..16272eacf7ccc 100644 --- a/server/src/schema/tables/asset-metadata-audit.table.ts +++ b/server/src/schema/tables/asset-metadata-audit.table.ts @@ -1,5 +1,4 @@ import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { AssetMetadataKey } from 'src/enum'; import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('asset_metadata_audit') @@ -11,7 +10,7 @@ export class AssetMetadataAuditTable { assetId!: string; @Column({ index: true }) - key!: AssetMetadataKey; + key!: string; @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) deletedAt!: Generated; diff --git a/server/src/schema/tables/asset-metadata.table.ts b/server/src/schema/tables/asset-metadata.table.ts index d529d6ad7bae8..8a7af1360f633 100644 --- a/server/src/schema/tables/asset-metadata.table.ts +++ b/server/src/schema/tables/asset-metadata.table.ts @@ -32,7 +32,7 @@ export class AssetMetadataTable { assetId!: string; @PrimaryColumn({ type: 'character varying' }) - key!: AssetMetadataKey; + key!: AssetMetadataKey | string; @Column({ type: 'jsonb' }) value!: object; diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 2bb8530c1cf47..5683c6ae1517b 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -433,7 +433,7 @@ export class AssetMediaService extends BaseService { originalFileName: dto.filename || file.originalName, }); - if (dto.metadata) { + if (dto.metadata?.length) { await this.assetRepository.upsertMetadata(asset.id, dto.metadata); } diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index c584cf134f418..1e776bd256832 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -11,6 +11,9 @@ import { AssetCopyDto, AssetJobName, AssetJobsDto, + AssetMetadataBulkDeleteDto, + AssetMetadataBulkResponseDto, + AssetMetadataBulkUpsertDto, AssetMetadataResponseDto, AssetMetadataUpsertDto, AssetStatsDto, @@ -19,16 +22,7 @@ import { } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; -import { - AssetFileType, - AssetMetadataKey, - AssetStatus, - AssetVisibility, - JobName, - JobStatus, - Permission, - QueueName, -} from 'src/enum'; +import { AssetFileType, AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; import { requireElevatedPermission } from 'src/utils/access'; @@ -381,12 +375,17 @@ export class AssetService extends BaseService { return this.ocrRepository.getByAssetId(id); } + async upsertBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkUpsertDto): Promise { + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.items.map((item) => item.assetId) }); + return this.assetRepository.upsertBulkMetadata(dto.items); + } + async upsertMetadata(auth: AuthDto, id: string, dto: AssetMetadataUpsertDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] }); return this.assetRepository.upsertMetadata(id, dto.items); } - async getMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise { + async getMetadataByKey(auth: AuthDto, id: string, key: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); const item = await this.assetRepository.getMetadataByKey(id, key); @@ -396,11 +395,16 @@ export class AssetService extends BaseService { return item; } - async deleteMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise { + async deleteMetadataByKey(auth: AuthDto, id: string, key: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] }); return this.assetRepository.deleteMetadataByKey(id, key); } + async deleteBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkDeleteDto) { + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.items.map((item) => item.assetId) }); + await this.assetRepository.deleteBulkMetadata(dto.items); + } + async run(auth: AuthDto, dto: AssetJobsDto) { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds }); diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 82ea2cd1fcadb..44ca231d8fb3f 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -56,6 +56,7 @@ import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; +import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { MemoryTable } from 'src/schema/tables/memory.table'; @@ -68,6 +69,7 @@ import { UserTable } from 'src/schema/tables/user.table'; import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service'; import { MetadataService } from 'src/services/metadata.service'; import { SyncService } from 'src/services/sync.service'; +import { UploadFile } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory'; @@ -179,6 +181,12 @@ export class MediumTestContext { return { asset, result }; } + async newMetadata(dto: Insertable) { + const { assetId, ...item } = dto; + const result = await this.get(AssetRepository).upsertMetadata(assetId, [item]); + return { metadata: dto, result }; + } + async newAssetFile(dto: Insertable) { const result = await this.get(AssetRepository).upsertFile(dto); return { result }; @@ -739,6 +747,17 @@ const loginResponse = (): LoginResponseDto => { }; }; +const uploadFile = (file: Partial = {}) => { + return { + uuid: newUuid(), + checksum: randomBytes(32), + originalPath: '/path/to/file.jpg', + originalName: 'file.jpg', + size: 123_456, + ...file, + }; +}; + export const mediumFactory = { assetInsert, assetFaceInsert, @@ -753,4 +772,5 @@ export const mediumFactory = { loginDetails, loginResponse, tagInsert, + uploadFile, }; diff --git a/server/test/medium/specs/services/asset-media.service.spec.ts b/server/test/medium/specs/services/asset-media.service.spec.ts new file mode 100644 index 0000000000000..5089850b6f9e6 --- /dev/null +++ b/server/test/medium/specs/services/asset-media.service.spec.ts @@ -0,0 +1,100 @@ +import { Kysely } from 'kysely'; +import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { EventRepository } from 'src/repositories/event.repository'; +import { JobRepository } from 'src/repositories/job.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; +import { UserRepository } from 'src/repositories/user.repository'; +import { DB } from 'src/schema'; +import { AssetMediaService } from 'src/services/asset-media.service'; +import { AssetService } from 'src/services/asset.service'; +import { mediumFactory, newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(AssetMediaService, { + database: db || defaultDatabase, + real: [AccessRepository, AssetRepository, UserRepository], + mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(AssetService.name, () => { + describe('uploadAsset', () => { + it('should work', async () => { + const { sut, ctx } = setup(); + + ctx.getMock(StorageRepository).utimes.mockResolvedValue(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 }); + const auth = factory.auth({ user: { id: user.id } }); + const file = mediumFactory.uploadFile(); + + await expect( + sut.uploadAsset( + auth, + { + deviceId: 'some-id', + deviceAssetId: 'some-id', + fileModifiedAt: new Date(), + fileCreatedAt: new Date(), + assetData: Buffer.from('some data'), + }, + file, + ), + ).resolves.toEqual({ + id: expect.any(String), + status: AssetMediaStatus.CREATED, + }); + + expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AssetCreate', { + asset: expect.objectContaining({ deviceAssetId: 'some-id' }), + }); + }); + + it('should work with an empty metadata list', async () => { + const { sut, ctx } = setup(); + + ctx.getMock(StorageRepository).utimes.mockResolvedValue(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 }); + const auth = factory.auth({ user: { id: user.id } }); + const file = mediumFactory.uploadFile(); + + await expect( + sut.uploadAsset( + auth, + { + deviceId: 'some-id', + deviceAssetId: 'some-id', + fileModifiedAt: new Date(), + fileCreatedAt: new Date(), + assetData: Buffer.from('some data'), + metadata: [], + }, + file, + ), + ).resolves.toEqual({ + id: expect.any(String), + status: AssetMediaStatus.CREATED, + }); + }); + }); +}); diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 661c4f5cdb71e..d0949c153caaf 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -1,5 +1,5 @@ import { Kysely } from 'kysely'; -import { AssetFileType, JobName, SharedLinkType } from 'src/enum'; +import { AssetFileType, AssetMetadataKey, JobName, SharedLinkType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; @@ -430,4 +430,177 @@ describe(AssetService.name, () => { ); }); }); + + describe('upsertBulkMetadata', () => { + it('should work', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const items = [{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }]; + + await sut.upsertBulkMetadata(auth, { items }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata.length).toEqual(1); + expect(metadata[0]).toEqual( + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }), + ); + }); + + it('should work on conflict', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'old-id' } }); + + // verify existing metadata + await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'old-id' } }), + ]); + + const items = [{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'new-id' } }]; + await sut.upsertBulkMetadata(auth, { items }); + + // verify updated metadata + await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'new-id' } }), + ]); + }); + + it('should work with multiple assets', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id }); + const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id }); + + const items = [ + { assetId: asset1.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, + { assetId: asset2.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }, + ]; + + await sut.upsertBulkMetadata(auth, { items }); + + const metadata1 = await ctx.get(AssetRepository).getMetadata(asset1.id); + expect(metadata1).toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }), + ]); + + const metadata2 = await ctx.get(AssetRepository).getMetadata(asset2.id); + expect(metadata2).toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }), + ]); + }); + + it('should work with multiple metadata for the same asset', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + const items = [ + { assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, + { assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } }, + ]; + + await sut.upsertBulkMetadata(auth, { items }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: AssetMetadataKey.MobileApp, + value: { iCloudId: 'id1' }, + }), + expect.objectContaining({ + key: 'some-other-key', + value: { foo: 'bar' }, + }), + ]), + ); + }); + }); + + describe('deleteBulkMetadata', () => { + it('should work', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }); + + await sut.deleteBulkMetadata(auth, { items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }] }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata.length).toEqual(0); + }); + + it('should work even if the item does not exist', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await sut.deleteBulkMetadata(auth, { items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }] }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata.length).toEqual(0); + }); + + it('should work with multiple assets', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset1.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }); + const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset2.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }); + + await sut.deleteBulkMetadata(auth, { + items: [ + { assetId: asset1.id, key: AssetMetadataKey.MobileApp }, + { assetId: asset2.id, key: AssetMetadataKey.MobileApp }, + ], + }); + + await expect(ctx.get(AssetRepository).getMetadata(asset1.id)).resolves.toEqual([]); + await expect(ctx.get(AssetRepository).getMetadata(asset2.id)).resolves.toEqual([]); + }); + + it('should work with multiple metadata for the same asset', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }); + await ctx.newMetadata({ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } }); + + await sut.deleteBulkMetadata(auth, { + items: [ + { assetId: asset.id, key: AssetMetadataKey.MobileApp }, + { assetId: asset.id, key: 'some-other-key' }, + ], + }); + + await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([]); + }); + + it('should not delete unspecified keys', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }); + await ctx.newMetadata({ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } }); + + await sut.deleteBulkMetadata(auth, { + items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }], + }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata).toEqual([expect.objectContaining({ key: 'some-other-key', value: { foo: 'bar' } })]); + }); + }); }); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 5ba77ddc2f437..4847c84a3508b 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -44,8 +44,10 @@ export const newAssetRepositoryMock = (): Mocked { - try { - const updatedAlbum = await addUsersToAlbum({ - id: album.id, - addUsersDto: { - albumUsers, - }, - }); - onUpdate(updatedAlbum); - } catch (error) { - handleError(error, $t('errors.unable_to_add_album_users')); - } - }; - const onAlbumUpdate = (album: AlbumResponseDto) => { onUpdate(album); userInteraction.recentAlbums = findAndUpdate(userInteraction.recentAlbums || [], album); diff --git a/web/src/lib/components/asset-viewer/actions/delete-action.spec.ts b/web/src/lib/components/asset-viewer/actions/delete-action.spec.ts index 126beead9cd43..8ba3432464a0d 100644 --- a/web/src/lib/components/asset-viewer/actions/delete-action.spec.ts +++ b/web/src/lib/components/asset-viewer/actions/delete-action.spec.ts @@ -7,6 +7,13 @@ import DeleteAction from './delete-action.svelte'; let asset: AssetResponseDto; describe('DeleteAction component', () => { + beforeEach(() => { + vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any }; + }); + }); + describe('given an asset which is not trashed yet', () => { beforeEach(() => { asset = assetFactory.build({ isTrashed: false }); diff --git a/web/src/lib/components/asset-viewer/actions/delete-action.svelte b/web/src/lib/components/asset-viewer/actions/delete-action.svelte index be9c9ccf9c74b..24ef7d941f409 100644 --- a/web/src/lib/components/asset-viewer/actions/delete-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/delete-action.svelte @@ -1,15 +1,14 @@ trashOrDelete(asset.isTrashed) }, + { shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete() }, { shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) }, ]} /> @@ -75,13 +69,7 @@ color="secondary" shape="round" variant="ghost" - icon={asset.isTrashed ? mdiDeleteForeverOutline : mdiDeleteOutline} - aria-label={asset.isTrashed ? $t('permanently_delete') : $t('delete')} - onclick={() => trashOrDelete(asset.isTrashed)} + icon={forceDefault ? mdiDeleteForeverOutline : mdiDeleteOutline} + aria-label={forceDefault ? $t('permanently_delete') : $t('delete')} + onclick={() => trashOrDelete()} /> - -{#if showConfirmModal} - - (showConfirmModal = false)} onConfirm={deleteAsset} /> - -{/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts index 957c9a17d2865..1c802b0dce645 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts @@ -32,8 +32,14 @@ describe('AssetViewerNavBar component', () => { vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() })), ); vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { smartSearch: true } } as any }; + return { + featureFlagsManager: { + init: vi.fn(), + loadFeatureFlags: vi.fn(), + value: { trash: true, smartSearch: true }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }; }); }); diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 27fe0f8c7470c..9344867a7b718 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -105,7 +105,7 @@ const stackThumbnailSize = 60; const stackSelectedThumbnailSize = 65; - let asset = $derived(cursor.current); + const asset = $derived(cursor.current); let appearsInAlbums: AlbumResponseDto[] = $state([]); let sharedLink = getSharedLink(); let previewStackedAsset: AssetResponseDto | undefined = $state(); @@ -312,7 +312,7 @@ case AssetAction.REMOVE_ASSET_FROM_STACK: { stack = action.stack; if (stack) { - asset = stack.assets[0]; + cursor.current = stack.assets[0]; } break; } @@ -323,11 +323,11 @@ } case AssetAction.SET_PERSON_FEATURED_PHOTO: { const assetInfo = await getAssetInfo({ id: asset.id }); - asset = { ...asset, people: assetInfo.people }; + cursor.current = { ...asset, people: assetInfo.people }; break; } case AssetAction.RATING: { - asset = { + cursor.current = { ...asset, exifInfo: { ...asset.exifInfo, @@ -394,7 +394,7 @@ const onAssetUpdate = (update: AssetResponseDto) => { if (asset.id === update.id) { - asset = update; + cursor.current = update; } }; @@ -590,7 +590,7 @@ dimmed={stackedAsset.id !== asset.id} asset={toTimelineAsset(stackedAsset)} onClick={() => { - asset = stackedAsset; + cursor.current = stackedAsset; previewStackedAsset = undefined; }} onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} diff --git a/web/src/lib/components/asset-viewer/detail-panel-description.svelte b/web/src/lib/components/asset-viewer/detail-panel-description.svelte index b41891de82c47..a1ffd8441bf24 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-description.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-description.svelte @@ -13,17 +13,16 @@ let { asset, isOwner }: Props = $props(); - let currentDescription = asset.exifInfo?.description ?? ''; - let draftDescription = $state(currentDescription); + let currentDescription = $derived(asset.exifInfo?.description ?? ''); + let description = $derived(currentDescription); const handleFocusOut = async () => { - if (draftDescription === currentDescription) { + if (description === currentDescription) { return; } try { - await updateAsset({ id: asset.id, updateAssetDto: { description: draftDescription } }); + await updateAsset({ id: asset.id, updateAssetDto: { description } }); toastManager.success($t('asset_description_updated')); - currentDescription = draftDescription; } catch (error) { handleError(error, $t('cannot_update_the_description')); } @@ -33,7 +32,7 @@ {#if isOwner}