فهرست منبع

feat(server): trash asset (#4015)

* refactor(server): delete assets endpoint

* fix: formatting

* chore: cleanup

* chore: open api

* chore(mobile): replace DeleteAssetDTO with BulkIdsDTOs

* feat: trash an asset

* chore(server): formatting

* chore: open api

* chore: wording

* chore: open-api

* feat(server): add withDeleted to getAssets queries

* WIP: mobile-recycle-bin

* feat(server): recycle-bin to system config

* feat(web): use recycle-bin system config

* chore(server): domain assetcore removed

* chore(server): rename recycle-bin to trash

* chore(web): rename recycle-bin to trash

* chore(server): always send soft deleted assets for getAllByUserId

* chore(web): formatting

* feat(server): permanent delete assets older than trashed period

* feat(web): trash empty placeholder image

* feat(server): empty trash

* feat(web): empty trash

* WIP: mobile-recycle-bin

* refactor(server): empty / restore trash to separate endpoint

* test(server): handle failures

* test(server): fix e2e server-info test

* test(server): deletion test refactor

* feat(mobile): use map settings from server-config to enable / disable map

* feat(mobile): trash asset

* fix(server): operations on assets in trash

* feat(web): show trash statistics

* fix(web): handle trash enabled

* fix(mobile): restore updates from trash

* fix(server): ignore trashed assets for person

* fix(server): add / remove search index when trashed / restored

* chore(web): format

* fix(server): asset service test

* fix(server): include trashed assts for duplicates from uploads

* feat(mobile): no dialog for trash, always dialog for permanent delete

* refactor(mobile): use isar where instead of dart filter

* refactor(mobile): asset provide - handle deletes in single db txn

* chore(mobile): review changes

* feat(web): confirmation before empty trash

* server: review changes

* fix(server): handle library changes

* fix: filter external assets from getting trashed / deleted

* fix(server): empty-bin

* feat: broadcast config update events through ws

* change order of trash button on mobile

* styling

* fix(mobile): do not show trashed toast for local only assets

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
shenlong 1 سال پیش
والد
کامیت
4a8887f37b
100فایلهای تغییر یافته به همراه2758 افزوده شده و 884 حذف شده
  1. 354 83
      cli/src/api/open-api/api.ts
  2. 13 1
      mobile/assets/i18n/en-US.json
  3. 1 1
      mobile/ios/Podfile.lock
  4. 1 0
      mobile/lib/modules/archive/providers/archive_asset_provider.dart
  5. 48 16
      mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
  6. 3 1
      mobile/lib/modules/backup/views/backup_controller_page.dart
  7. 1 0
      mobile/lib/modules/favorite/providers/favorite_provider.dart
  8. 17 8
      mobile/lib/modules/home/ui/control_bottom_app_bar.dart
  9. 28 0
      mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart
  10. 17 1
      mobile/lib/modules/home/views/home_page.dart
  11. 144 0
      mobile/lib/modules/trash/providers/trashed_asset.provider.dart
  12. 48 0
      mobile/lib/modules/trash/services/trash.service.dart
  13. 276 0
      mobile/lib/modules/trash/views/trash_page.dart
  14. 2 0
      mobile/lib/routing/router.dart
  15. 26 0
      mobile/lib/routing/router.gr.dart
  16. 19 6
      mobile/lib/shared/models/asset.dart
  17. 81 26
      mobile/lib/shared/models/asset.g.dart
  18. 32 10
      mobile/lib/shared/providers/asset.provider.dart
  19. 2 0
      mobile/lib/shared/providers/server_info.provider.dart
  20. 7 0
      mobile/lib/shared/providers/websocket.provider.dart
  21. 15 7
      mobile/lib/shared/services/asset.service.dart
  22. 6 9
      mobile/openapi/.openapi-generator/FILES
  23. 6 4
      mobile/openapi/README.md
  24. 178 16
      mobile/openapi/doc/AssetApi.md
  25. 2 1
      mobile/openapi/doc/AssetBulkDeleteDto.md
  26. 1 0
      mobile/openapi/doc/AssetResponseDto.md
  27. 0 16
      mobile/openapi/doc/DeleteAssetResponseDto.md
  28. 1 0
      mobile/openapi/doc/ServerConfigDto.md
  29. 1 0
      mobile/openapi/doc/ServerFeaturesDto.md
  30. 1 0
      mobile/openapi/doc/SystemConfigDto.md
  31. 3 1
      mobile/openapi/doc/SystemConfigTrashDto.md
  32. 2 3
      mobile/openapi/lib/api.dart
  33. 141 26
      mobile/openapi/lib/api/asset_api.dart
  34. 4 6
      mobile/openapi/lib/api_client.dart
  35. 0 3
      mobile/openapi/lib/api_helper.dart
  36. 35 18
      mobile/openapi/lib/model/asset_bulk_delete_dto.dart
  37. 9 1
      mobile/openapi/lib/model/asset_response_dto.dart
  38. 0 85
      mobile/openapi/lib/model/delete_asset_status.dart
  39. 11 3
      mobile/openapi/lib/model/server_config_dto.dart
  40. 11 3
      mobile/openapi/lib/model/server_features_dto.dart
  41. 11 3
      mobile/openapi/lib/model/system_config_dto.dart
  42. 32 32
      mobile/openapi/lib/model/system_config_trash_dto.dart
  43. 20 5
      mobile/openapi/test/asset_api_test.dart
  44. 8 3
      mobile/openapi/test/asset_bulk_delete_dto_test.dart
  45. 5 0
      mobile/openapi/test/asset_response_dto_test.dart
  46. 0 21
      mobile/openapi/test/delete_asset_status_test.dart
  47. 5 0
      mobile/openapi/test/server_config_dto_test.dart
  48. 5 0
      mobile/openapi/test/server_features_dto_test.dart
  49. 5 0
      mobile/openapi/test/system_config_dto_test.dart
  50. 7 7
      mobile/openapi/test/system_config_trash_dto_test.dart
  51. 1 0
      mobile/test/asset_grid_data_structure_test.dart
  52. 1 0
      mobile/test/sync_service_test.dart
  53. 162 56
      server/immich-openapi-specs.json
  54. 4 0
      server/src/domain/access/access.core.ts
  55. 8 3
      server/src/domain/asset/asset.repository.ts
  56. 215 4
      server/src/domain/asset/asset.service.spec.ts
  57. 120 4
      server/src/domain/asset/asset.service.ts
  58. 5 0
      server/src/domain/asset/dto/asset-statistics.dto.ts
  59. 11 0
      server/src/domain/asset/dto/asset.dto.ts
  60. 5 0
      server/src/domain/asset/dto/time-bucket.dto.ts
  61. 2 0
      server/src/domain/asset/response-dto/asset-response.dto.ts
  62. 2 0
      server/src/domain/communication/communication.repository.ts
  63. 6 0
      server/src/domain/job/job.constants.ts
  64. 4 0
      server/src/domain/job/job.interface.ts
  65. 3 0
      server/src/domain/job/job.repository.ts
  66. 1 0
      server/src/domain/job/job.service.spec.ts
  67. 1 0
      server/src/domain/job/job.service.ts
  68. 2 17
      server/src/domain/library/library.service.spec.ts
  69. 5 19
      server/src/domain/library/library.service.ts
  70. 3 0
      server/src/domain/server-info/server-info.dto.ts
  71. 2 0
      server/src/domain/server-info/server-info.service.spec.ts
  72. 1 0
      server/src/domain/server-info/server-info.service.ts
  73. 1 0
      server/src/domain/system-config/dto/index.ts
  74. 14 0
      server/src/domain/system-config/dto/system-config-trash.dto.ts
  75. 6 1
      server/src/domain/system-config/dto/system-config.dto.ts
  76. 6 2
      server/src/domain/system-config/system-config.core.ts
  77. 12 3
      server/src/domain/system-config/system-config.service.spec.ts
  78. 3 0
      server/src/domain/system-config/system-config.service.ts
  79. 5 5
      server/src/immich/api-v1/asset/asset-repository.ts
  80. 0 11
      server/src/immich/api-v1/asset/asset.controller.ts
  81. 1 0
      server/src/immich/api-v1/asset/asset.core.ts
  82. 0 128
      server/src/immich/api-v1/asset/asset.service.spec.ts
  83. 0 62
      server/src/immich/api-v1/asset/asset.service.ts
  84. 0 17
      server/src/immich/api-v1/asset/dto/delete-asset.dto.ts
  85. 0 13
      server/src/immich/api-v1/asset/response-dto/delete-asset-response.dto.ts
  86. 40 1
      server/src/immich/controllers/asset.controller.ts
  87. 4 0
      server/src/infra/entities/asset.entity.ts
  88. 7 0
      server/src/infra/entities/system-config.entity.ts
  89. 14 0
      server/src/infra/migrations/1694204416744-AddAssetDeletedAtColumn.ts
  90. 1 0
      server/src/infra/repositories/access.repository.ts
  91. 45 26
      server/src/infra/repositories/asset.repository.ts
  92. 4 0
      server/src/infra/repositories/communication.repository.ts
  93. 1 0
      server/src/infra/repositories/person.repository.ts
  94. 4 0
      server/src/microservices/app.service.ts
  95. 2 0
      server/test/e2e/server-info.e2e-spec.ts
  96. 18 1
      server/test/fixtures/asset.stub.ts
  97. 2 0
      server/test/fixtures/shared-link.stub.ts
  98. 4 2
      server/test/repositories/asset.repository.mock.ts
  99. 1 0
      server/test/repositories/communication.repository.mock.ts
  100. 354 83
      web/src/api/open-api/api.ts

+ 354 - 83
cli/src/api/open-api/api.ts

@@ -356,6 +356,25 @@ export interface AllJobStatusResponseDto {
      */
      */
     'videoConversion': JobStatusDto;
     'videoConversion': JobStatusDto;
 }
 }
+/**
+ * 
+ * @export
+ * @interface AssetBulkDeleteDto
+ */
+export interface AssetBulkDeleteDto {
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetBulkDeleteDto
+     */
+    'force'?: boolean;
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof AssetBulkDeleteDto
+     */
+    'ids': Array<string>;
+}
 /**
 /**
  * 
  * 
  * @export
  * @export
@@ -657,6 +676,12 @@ export interface AssetResponseDto {
      * @memberof AssetResponseDto
      * @memberof AssetResponseDto
      */
      */
     'isReadOnly': boolean;
     'isReadOnly': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetResponseDto
+     */
+    'isTrashed': boolean;
     /**
     /**
      * 
      * 
      * @type {string}
      * @type {string}
@@ -1357,54 +1382,6 @@ export interface CuratedObjectsResponseDto {
      */
      */
     'resizePath': string;
     'resizePath': string;
 }
 }
-/**
- * 
- * @export
- * @interface DeleteAssetDto
- */
-export interface DeleteAssetDto {
-    /**
-     * 
-     * @type {Array<string>}
-     * @memberof DeleteAssetDto
-     */
-    'ids': Array<string>;
-}
-/**
- * 
- * @export
- * @interface DeleteAssetResponseDto
- */
-export interface DeleteAssetResponseDto {
-    /**
-     * 
-     * @type {string}
-     * @memberof DeleteAssetResponseDto
-     */
-    'id': string;
-    /**
-     * 
-     * @type {DeleteAssetStatus}
-     * @memberof DeleteAssetResponseDto
-     */
-    'status': DeleteAssetStatus;
-}
-
-
-/**
- * 
- * @export
- * @enum {string}
- */
-
-export const DeleteAssetStatus = {
-    Success: 'SUCCESS',
-    Failed: 'FAILED'
-} as const;
-
-export type DeleteAssetStatus = typeof DeleteAssetStatus[keyof typeof DeleteAssetStatus];
-
-
 /**
 /**
  * 
  * 
  * @export
  * @export
@@ -2623,6 +2600,12 @@ export interface ServerConfigDto {
      * @memberof ServerConfigDto
      * @memberof ServerConfigDto
      */
      */
     'oauthButtonText': string;
     'oauthButtonText': string;
+    /**
+     * 
+     * @type {number}
+     * @memberof ServerConfigDto
+     */
+    'trashDays': number;
 }
 }
 /**
 /**
  * 
  * 
@@ -2696,6 +2679,12 @@ export interface ServerFeaturesDto {
      * @memberof ServerFeaturesDto
      * @memberof ServerFeaturesDto
      */
      */
     'tagImage': boolean;
     'tagImage': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof ServerFeaturesDto
+     */
+    'trash': boolean;
 }
 }
 /**
 /**
  * 
  * 
@@ -3139,6 +3128,12 @@ export interface SystemConfigDto {
      * @memberof SystemConfigDto
      * @memberof SystemConfigDto
      */
      */
     'thumbnail': SystemConfigThumbnailDto;
     'thumbnail': SystemConfigThumbnailDto;
+    /**
+     * 
+     * @type {SystemConfigTrashDto}
+     * @memberof SystemConfigDto
+     */
+    'trash': SystemConfigTrashDto;
 }
 }
 /**
 /**
  * 
  * 
@@ -3594,6 +3589,25 @@ export interface SystemConfigThumbnailDto {
 }
 }
 
 
 
 
+/**
+ * 
+ * @export
+ * @interface SystemConfigTrashDto
+ */
+export interface SystemConfigTrashDto {
+    /**
+     * 
+     * @type {number}
+     * @memberof SystemConfigTrashDto
+     */
+    'days': number;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof SystemConfigTrashDto
+     */
+    'enabled': boolean;
+}
 /**
 /**
  * 
  * 
  * @export
  * @export
@@ -5682,13 +5696,13 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
         },
         },
         /**
         /**
          * 
          * 
-         * @param {DeleteAssetDto} deleteAssetDto 
+         * @param {AssetBulkDeleteDto} assetBulkDeleteDto 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        deleteAsset: async (deleteAssetDto: DeleteAssetDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
-            // verify required parameter 'deleteAssetDto' is not null or undefined
-            assertParamExists('deleteAsset', 'deleteAssetDto', deleteAssetDto)
+        deleteAssets: async (assetBulkDeleteDto: AssetBulkDeleteDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'assetBulkDeleteDto' is not null or undefined
+            assertParamExists('deleteAssets', 'assetBulkDeleteDto', assetBulkDeleteDto)
             const localVarPath = `/asset`;
             const localVarPath = `/asset`;
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -5717,7 +5731,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
-            localVarRequestOptions.data = serializeDataIfNeeded(deleteAssetDto, localVarRequestOptions, configuration)
+            localVarRequestOptions.data = serializeDataIfNeeded(assetBulkDeleteDto, localVarRequestOptions, configuration)
 
 
             return {
             return {
                 url: toPathString(localVarUrlObj),
                 url: toPathString(localVarUrlObj),
@@ -5811,6 +5825,44 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
 
 
 
 
     
     
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        emptyTrash: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/asset/trash/empty`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -5979,10 +6031,11 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
          * 
          * 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {boolean} [isFavorite] 
+         * @param {boolean} [isTrashed] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        getAssetStats: async (isArchived?: boolean, isFavorite?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+        getAssetStats: async (isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             const localVarPath = `/asset/statistics`;
             const localVarPath = `/asset/statistics`;
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -6012,6 +6065,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 localVarQueryParameter['isFavorite'] = isFavorite;
                 localVarQueryParameter['isFavorite'] = isFavorite;
             }
             }
 
 
+            if (isTrashed !== undefined) {
+                localVarQueryParameter['isTrashed'] = isTrashed;
+            }
+
 
 
     
     
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -6084,11 +6141,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
          * @param {string} [personId] 
          * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {boolean} [isFavorite] 
+         * @param {boolean} [isTrashed] 
          * @param {string} [key] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+        getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             // verify required parameter 'size' is not null or undefined
             // verify required parameter 'size' is not null or undefined
             assertParamExists('getByTimeBucket', 'size', size)
             assertParamExists('getByTimeBucket', 'size', size)
             // verify required parameter 'timeBucket' is not null or undefined
             // verify required parameter 'timeBucket' is not null or undefined
@@ -6138,6 +6196,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 localVarQueryParameter['isFavorite'] = isFavorite;
                 localVarQueryParameter['isFavorite'] = isFavorite;
             }
             }
 
 
+            if (isTrashed !== undefined) {
+                localVarQueryParameter['isTrashed'] = isTrashed;
+            }
+
             if (timeBucket !== undefined) {
             if (timeBucket !== undefined) {
                 localVarQueryParameter['timeBucket'] = timeBucket;
                 localVarQueryParameter['timeBucket'] = timeBucket;
             }
             }
@@ -6447,11 +6509,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
          * @param {string} [personId] 
          * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {boolean} [isFavorite] 
+         * @param {boolean} [isTrashed] 
          * @param {string} [key] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+        getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             // verify required parameter 'size' is not null or undefined
             // verify required parameter 'size' is not null or undefined
             assertParamExists('getTimeBuckets', 'size', size)
             assertParamExists('getTimeBuckets', 'size', size)
             const localVarPath = `/asset/time-buckets`;
             const localVarPath = `/asset/time-buckets`;
@@ -6499,6 +6562,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 localVarQueryParameter['isFavorite'] = isFavorite;
                 localVarQueryParameter['isFavorite'] = isFavorite;
             }
             }
 
 
+            if (isTrashed !== undefined) {
+                localVarQueryParameter['isTrashed'] = isTrashed;
+            }
+
             if (key !== undefined) {
             if (key !== undefined) {
                 localVarQueryParameter['key'] = key;
                 localVarQueryParameter['key'] = key;
             }
             }
@@ -6600,6 +6667,88 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 options: localVarRequestOptions,
                 options: localVarRequestOptions,
             };
             };
         },
         },
+        /**
+         * 
+         * @param {BulkIdsDto} bulkIdsDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        restoreAssets: async (bulkIdsDto: BulkIdsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'bulkIdsDto' is not null or undefined
+            assertParamExists('restoreAssets', 'bulkIdsDto', bulkIdsDto)
+            const localVarPath = `/asset/restore`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        restoreTrash: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/asset/trash/restore`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
         /**
         /**
          * 
          * 
          * @param {AssetJobsDto} assetJobsDto 
          * @param {AssetJobsDto} assetJobsDto 
@@ -7014,12 +7163,12 @@ export const AssetApiFp = function(configuration?: Configuration) {
         },
         },
         /**
         /**
          * 
          * 
-         * @param {DeleteAssetDto} deleteAssetDto 
+         * @param {AssetBulkDeleteDto} assetBulkDeleteDto 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        async deleteAsset(deleteAssetDto: DeleteAssetDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<DeleteAssetResponseDto>>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAsset(deleteAssetDto, options);
+        async deleteAssets(assetBulkDeleteDto: AssetBulkDeleteDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAssets(assetBulkDeleteDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
         /**
         /**
@@ -7044,6 +7193,15 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(id, key, options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(id, key, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async emptyTrash(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.emptyTrash(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
         /**
          * Get all AssetEntity belong to the user
          * Get all AssetEntity belong to the user
          * @param {string} [userId] 
          * @param {string} [userId] 
@@ -7083,11 +7241,12 @@ export const AssetApiFp = function(configuration?: Configuration) {
          * 
          * 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {boolean} [isFavorite] 
+         * @param {boolean} [isTrashed] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        async getAssetStats(isArchived?: boolean, isFavorite?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetStatsResponseDto>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetStats(isArchived, isFavorite, options);
+        async getAssetStats(isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetStatsResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetStats(isArchived, isFavorite, isTrashed, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
         /**
         /**
@@ -7111,12 +7270,13 @@ export const AssetApiFp = function(configuration?: Configuration) {
          * @param {string} [personId] 
          * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {boolean} [isFavorite] 
+         * @param {boolean} [isTrashed] 
          * @param {string} [key] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, key, options);
+        async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, key, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
         /**
         /**
@@ -7190,12 +7350,13 @@ export const AssetApiFp = function(configuration?: Configuration) {
          * @param {string} [personId] 
          * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {boolean} [isFavorite] 
+         * @param {boolean} [isTrashed] 
          * @param {string} [key] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<TimeBucketResponseDto>>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, key, options);
+        async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<TimeBucketResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, key, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
         /**
         /**
@@ -7218,6 +7379,25 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
+        /**
+         * 
+         * @param {BulkIdsDto} bulkIdsDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async restoreAssets(bulkIdsDto: BulkIdsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.restoreAssets(bulkIdsDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async restoreTrash(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.restoreTrash(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
         /**
          * 
          * 
          * @param {AssetJobsDto} assetJobsDto 
          * @param {AssetJobsDto} assetJobsDto 
@@ -7336,12 +7516,12 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         },
         },
         /**
         /**
          * 
          * 
-         * @param {AssetApiDeleteAssetRequest} requestParameters Request parameters.
+         * @param {AssetApiDeleteAssetsRequest} requestParameters Request parameters.
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        deleteAsset(requestParameters: AssetApiDeleteAssetRequest, options?: AxiosRequestConfig): AxiosPromise<Array<DeleteAssetResponseDto>> {
-            return localVarFp.deleteAsset(requestParameters.deleteAssetDto, options).then((request) => request(axios, basePath));
+        deleteAssets(requestParameters: AssetApiDeleteAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
+            return localVarFp.deleteAssets(requestParameters.assetBulkDeleteDto, options).then((request) => request(axios, basePath));
         },
         },
         /**
         /**
          * 
          * 
@@ -7361,6 +7541,14 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         downloadFile(requestParameters: AssetApiDownloadFileRequest, options?: AxiosRequestConfig): AxiosPromise<File> {
         downloadFile(requestParameters: AssetApiDownloadFileRequest, options?: AxiosRequestConfig): AxiosPromise<File> {
             return localVarFp.downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(axios, basePath));
             return localVarFp.downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(axios, basePath));
         },
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        emptyTrash(options?: AxiosRequestConfig): AxiosPromise<void> {
+            return localVarFp.emptyTrash(options).then((request) => request(axios, basePath));
+        },
         /**
         /**
          * Get all AssetEntity belong to the user
          * Get all AssetEntity belong to the user
          * @param {AssetApiGetAllAssetsRequest} requestParameters Request parameters.
          * @param {AssetApiGetAllAssetsRequest} requestParameters Request parameters.
@@ -7394,7 +7582,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
         getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig): AxiosPromise<AssetStatsResponseDto> {
         getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig): AxiosPromise<AssetStatsResponseDto> {
-            return localVarFp.getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, options).then((request) => request(axios, basePath));
+            return localVarFp.getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, options).then((request) => request(axios, basePath));
         },
         },
         /**
         /**
          * 
          * 
@@ -7412,7 +7600,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
         getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> {
         getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> {
-            return localVarFp.getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(axios, basePath));
+            return localVarFp.getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(axios, basePath));
         },
         },
         /**
         /**
          * 
          * 
@@ -7473,7 +7661,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
         getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig): AxiosPromise<Array<TimeBucketResponseDto>> {
         getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig): AxiosPromise<Array<TimeBucketResponseDto>> {
-            return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(axios, basePath));
+            return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(axios, basePath));
         },
         },
         /**
         /**
          * Get all asset of a device that are in the database, ID only.
          * Get all asset of a device that are in the database, ID only.
@@ -7493,6 +7681,23 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig): AxiosPromise<AssetFileUploadResponseDto> {
         importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig): AxiosPromise<AssetFileUploadResponseDto> {
             return localVarFp.importFile(requestParameters.importAssetDto, options).then((request) => request(axios, basePath));
             return localVarFp.importFile(requestParameters.importAssetDto, options).then((request) => request(axios, basePath));
         },
         },
+        /**
+         * 
+         * @param {AssetApiRestoreAssetsRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        restoreAssets(requestParameters: AssetApiRestoreAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
+            return localVarFp.restoreAssets(requestParameters.bulkIdsDto, options).then((request) => request(axios, basePath));
+        },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        restoreTrash(options?: AxiosRequestConfig): AxiosPromise<void> {
+            return localVarFp.restoreTrash(options).then((request) => request(axios, basePath));
+        },
         /**
         /**
          * 
          * 
          * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters.
          * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters.
@@ -7600,17 +7805,17 @@ export interface AssetApiCheckExistingAssetsRequest {
 }
 }
 
 
 /**
 /**
- * Request parameters for deleteAsset operation in AssetApi.
+ * Request parameters for deleteAssets operation in AssetApi.
  * @export
  * @export
- * @interface AssetApiDeleteAssetRequest
+ * @interface AssetApiDeleteAssetsRequest
  */
  */
-export interface AssetApiDeleteAssetRequest {
+export interface AssetApiDeleteAssetsRequest {
     /**
     /**
      * 
      * 
-     * @type {DeleteAssetDto}
-     * @memberof AssetApiDeleteAsset
+     * @type {AssetBulkDeleteDto}
+     * @memberof AssetApiDeleteAssets
      */
      */
-    readonly deleteAssetDto: DeleteAssetDto
+    readonly assetBulkDeleteDto: AssetBulkDeleteDto
 }
 }
 
 
 /**
 /**
@@ -7744,6 +7949,13 @@ export interface AssetApiGetAssetStatsRequest {
      * @memberof AssetApiGetAssetStats
      * @memberof AssetApiGetAssetStats
      */
      */
     readonly isFavorite?: boolean
     readonly isFavorite?: boolean
+
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetApiGetAssetStats
+     */
+    readonly isTrashed?: boolean
 }
 }
 
 
 /**
 /**
@@ -7829,6 +8041,13 @@ export interface AssetApiGetByTimeBucketRequest {
      */
      */
     readonly isFavorite?: boolean
     readonly isFavorite?: boolean
 
 
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetApiGetByTimeBucket
+     */
+    readonly isTrashed?: boolean
+
     /**
     /**
      * 
      * 
      * @type {string}
      * @type {string}
@@ -7976,6 +8195,13 @@ export interface AssetApiGetTimeBucketsRequest {
      */
      */
     readonly isFavorite?: boolean
     readonly isFavorite?: boolean
 
 
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetApiGetTimeBuckets
+     */
+    readonly isTrashed?: boolean
+
     /**
     /**
      * 
      * 
      * @type {string}
      * @type {string}
@@ -8012,6 +8238,20 @@ export interface AssetApiImportFileRequest {
     readonly importAssetDto: ImportAssetDto
     readonly importAssetDto: ImportAssetDto
 }
 }
 
 
+/**
+ * Request parameters for restoreAssets operation in AssetApi.
+ * @export
+ * @interface AssetApiRestoreAssetsRequest
+ */
+export interface AssetApiRestoreAssetsRequest {
+    /**
+     * 
+     * @type {BulkIdsDto}
+     * @memberof AssetApiRestoreAssets
+     */
+    readonly bulkIdsDto: BulkIdsDto
+}
+
 /**
 /**
  * Request parameters for runAssetJobs operation in AssetApi.
  * Request parameters for runAssetJobs operation in AssetApi.
  * @export
  * @export
@@ -8271,13 +8511,13 @@ export class AssetApi extends BaseAPI {
 
 
     /**
     /**
      * 
      * 
-     * @param {AssetApiDeleteAssetRequest} requestParameters Request parameters.
+     * @param {AssetApiDeleteAssetsRequest} requestParameters Request parameters.
      * @param {*} [options] Override http request option.
      * @param {*} [options] Override http request option.
      * @throws {RequiredError}
      * @throws {RequiredError}
      * @memberof AssetApi
      * @memberof AssetApi
      */
      */
-    public deleteAsset(requestParameters: AssetApiDeleteAssetRequest, options?: AxiosRequestConfig) {
-        return AssetApiFp(this.configuration).deleteAsset(requestParameters.deleteAssetDto, options).then((request) => request(this.axios, this.basePath));
+    public deleteAssets(requestParameters: AssetApiDeleteAssetsRequest, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).deleteAssets(requestParameters.assetBulkDeleteDto, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
     /**
     /**
@@ -8302,6 +8542,16 @@ export class AssetApi extends BaseAPI {
         return AssetApiFp(this.configuration).downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
         return AssetApiFp(this.configuration).downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public emptyTrash(options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).emptyTrash(options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
     /**
      * Get all AssetEntity belong to the user
      * Get all AssetEntity belong to the user
      * @param {AssetApiGetAllAssetsRequest} requestParameters Request parameters.
      * @param {AssetApiGetAllAssetsRequest} requestParameters Request parameters.
@@ -8342,7 +8592,7 @@ export class AssetApi extends BaseAPI {
      * @memberof AssetApi
      * @memberof AssetApi
      */
      */
     public getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig) {
     public getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig) {
-        return AssetApiFp(this.configuration).getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, options).then((request) => request(this.axios, this.basePath));
+        return AssetApiFp(this.configuration).getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
     /**
     /**
@@ -8364,7 +8614,7 @@ export class AssetApi extends BaseAPI {
      * @memberof AssetApi
      * @memberof AssetApi
      */
      */
     public getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig) {
     public getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig) {
-        return AssetApiFp(this.configuration).getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
+        return AssetApiFp(this.configuration).getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
     /**
     /**
@@ -8439,7 +8689,7 @@ export class AssetApi extends BaseAPI {
      * @memberof AssetApi
      * @memberof AssetApi
      */
      */
     public getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig) {
     public getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig) {
-        return AssetApiFp(this.configuration).getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
+        return AssetApiFp(this.configuration).getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
     /**
     /**
@@ -8464,6 +8714,27 @@ export class AssetApi extends BaseAPI {
         return AssetApiFp(this.configuration).importFile(requestParameters.importAssetDto, options).then((request) => request(this.axios, this.basePath));
         return AssetApiFp(this.configuration).importFile(requestParameters.importAssetDto, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
+    /**
+     * 
+     * @param {AssetApiRestoreAssetsRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public restoreAssets(requestParameters: AssetApiRestoreAssetsRequest, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).restoreAssets(requestParameters.bulkIdsDto, options).then((request) => request(this.axios, this.basePath));
+    }
+
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public restoreTrash(options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).restoreTrash(options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
     /**
      * 
      * 
      * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters.
      * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters.

+ 13 - 1
mobile/assets/i18n/en-US.json

@@ -322,5 +322,17 @@
   "map_no_location_permission_title": "Location Permission denied",
   "map_no_location_permission_title": "Location Permission denied",
   "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
   "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
   "map_location_dialog_cancel": "Cancel",
   "map_location_dialog_cancel": "Cancel",
-  "map_location_dialog_yes": "Yes"
+  "map_location_dialog_yes": "Yes",
+  "trash_page_title": "Trash ({})",
+  "trash_page_info": "Backed up items will be permanently deleted after {} days",
+  "trash_page_no_assets": "No trashed assets",
+  "trash_page_delete": "Delete",
+  "trash_page_delete_all": "Delete All",
+  "trash_page_restore": "Restore",
+  "trash_page_restore_all": "Restore All",
+  "trash_page_select_btn": "Select",
+  "trash_page_select_assets_btn": "Select assets",
+  "trash_page_empty_trash_btn": "Empty trash",
+  "trash_page_empty_trash_dialog_ok": "Ok",
+  "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich"
 }
 }

+ 1 - 1
mobile/ios/Podfile.lock

@@ -169,4 +169,4 @@ SPEC CHECKSUMS:
 
 
 PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
 PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
 
 
-COCOAPODS: 1.12.1
+COCOAPODS: 1.11.3

+ 1 - 0
mobile/lib/modules/archive/providers/archive_asset_provider.dart

@@ -17,6 +17,7 @@ final archiveProvider = StreamProvider<RenderList>((ref) async* {
       .ownerIdEqualToAnyChecksum(user.isarId)
       .ownerIdEqualToAnyChecksum(user.isarId)
       .filter()
       .filter()
       .isArchivedEqualTo(true)
       .isArchivedEqualTo(true)
+      .isTrashedEqualTo(false)
       .sortByFileCreatedAt();
       .sortByFileCreatedAt();
   final settings = ref.watch(appSettingsServiceProvider);
   final settings = ref.watch(appSettingsServiceProvider);
   final groupBy =
   final groupBy =

+ 48 - 16
mobile/lib/modules/asset_viewer/views/gallery_viewer.dart

@@ -6,6 +6,7 @@ import 'package:cached_network_image/cached_network_image.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
 import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
+import 'package:fluttertoast/fluttertoast.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
@@ -19,11 +20,14 @@ import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'
 import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
 import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
 import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
 import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
 import 'package:immich_mobile/shared/cache/original_image_provider.dart';
 import 'package:immich_mobile/shared/cache/original_image_provider.dart';
+import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
 import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
+import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:immich_mobile/shared/ui/immich_image.dart';
 import 'package:immich_mobile/shared/ui/immich_image.dart';
+import 'package:immich_mobile/shared/ui/immich_toast.dart';
 import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
 import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
 import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
 import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
 import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
 import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
@@ -67,6 +71,12 @@ class GalleryViewerPage extends HookConsumerWidget {
     final header = {"Authorization": authToken};
     final header = {"Authorization": authToken};
     final currentIndex = useState(initialIndex);
     final currentIndex = useState(initialIndex);
     final currentAsset = loadAsset(currentIndex.value);
     final currentAsset = loadAsset(currentIndex.value);
+    final isTrashEnabled =
+        ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
+    final navStack = AutoRouter.of(context).stackData;
+    final isFromTrash = isTrashEnabled &&
+        navStack.length > 2 &&
+        navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
 
 
     Asset asset() => currentAsset;
     Asset asset() => currentAsset;
 
 
@@ -161,25 +171,47 @@ class GalleryViewerPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    void handleDelete(Asset deleteAsset) {
+    void handleDelete(Asset deleteAsset) async {
+      Future<bool> onDelete(bool force) async {
+        final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
+          {deleteAsset},
+          force: force,
+        );
+        if (isDeleted) {
+          if (totalAssets == 1) {
+            // Handle only one asset
+            AutoRouter.of(context).pop();
+          } else {
+            // Go to next page otherwise
+            controller.nextPage(
+              duration: const Duration(milliseconds: 100),
+              curve: Curves.fastLinearToSlowEaseIn,
+            );
+          }
+        }
+        return isDeleted;
+      }
+
+      // Asset is trashed
+      if (isTrashEnabled && !isFromTrash) {
+        final isDeleted = await onDelete(false);
+        // Can only trash assets stored in server. Local assets are always permanently removed for now
+        if (context.mounted && isDeleted && deleteAsset.isRemote) {
+          ImmichToast.show(
+            durationInSecond: 1,
+            context: context,
+            msg: 'Asset trashed',
+            gravity: ToastGravity.BOTTOM,
+          );
+        }
+        return;
+      }
+
+      // Asset is permanently removed
       showDialog(
       showDialog(
         context: context,
         context: context,
         builder: (BuildContext _) {
         builder: (BuildContext _) {
-          return DeleteDialog(
-            onDelete: () {
-              if (totalAssets == 1) {
-                // Handle only one asset
-                AutoRouter.of(context).pop();
-              } else {
-                // Go to next page otherwise
-                controller.nextPage(
-                  duration: const Duration(milliseconds: 100),
-                  curve: Curves.fastLinearToSlowEaseIn,
-                );
-              }
-              ref.watch(assetProvider.notifier).deleteAssets({deleteAsset});
-            },
-          );
+          return DeleteDialog(onDelete: () => onDelete(true));
         },
         },
       );
       );
     }
     }

+ 3 - 1
mobile/lib/modules/backup/views/backup_controller_page.dart

@@ -81,7 +81,9 @@ class BackupControllerPage extends HookConsumerWidget {
           context: context,
           context: context,
           msg: "Deleting ${assets.length} assets on the server...",
           msg: "Deleting ${assets.length} assets on the server...",
         );
         );
-        await ref.read(assetProvider.notifier).deleteAssets(assets);
+        await ref
+            .read(assetProvider.notifier)
+            .deleteAssets(assets, force: true);
         ImmichToast.show(
         ImmichToast.show(
           context: context,
           context: context,
           msg: "Deleted ${assets.length} assets on the server. "
           msg: "Deleted ${assets.length} assets on the server. "

+ 1 - 0
mobile/lib/modules/favorite/providers/favorite_provider.dart

@@ -17,6 +17,7 @@ final favoriteAssetsProvider = StreamProvider<RenderList>((ref) async* {
       .ownerIdEqualToAnyChecksum(user.isarId)
       .ownerIdEqualToAnyChecksum(user.isarId)
       .filter()
       .filter()
       .isFavoriteEqualTo(true)
       .isFavoriteEqualTo(true)
+      .isTrashedEqualTo(false)
       .sortByFileCreatedAt();
       .sortByFileCreatedAt();
   final settings = ref.watch(appSettingsServiceProvider);
   final settings = ref.watch(appSettingsServiceProvider);
   final groupBy =
   final groupBy =

+ 17 - 8
mobile/lib/modules/home/ui/control_bottom_app_bar.dart

@@ -7,6 +7,7 @@ import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
 import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
 import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
 import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
 import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:immich_mobile/shared/ui/drag_sheet.dart';
 import 'package:immich_mobile/shared/ui/drag_sheet.dart';
 import 'package:immich_mobile/shared/models/album.dart';
 import 'package:immich_mobile/shared/models/album.dart';
 
 
@@ -43,6 +44,8 @@ class ControlBottomAppBar extends ConsumerWidget {
   Widget build(BuildContext context, WidgetRef ref) {
   Widget build(BuildContext context, WidgetRef ref) {
     var isDarkMode = Theme.of(context).brightness == Brightness.dark;
     var isDarkMode = Theme.of(context).brightness == Brightness.dark;
     var hasRemote = selectionAssetState == AssetState.remote;
     var hasRemote = selectionAssetState == AssetState.remote;
+    final trashEnabled =
+        ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
 
 
     Widget renderActionButtons() {
     Widget renderActionButtons() {
       return Row(
       return Row(
@@ -70,14 +73,20 @@ class ControlBottomAppBar extends ConsumerWidget {
             iconData: Icons.delete_outline_rounded,
             iconData: Icons.delete_outline_rounded,
             label: "control_bottom_app_bar_delete".tr(),
             label: "control_bottom_app_bar_delete".tr(),
             onPressed: enabled
             onPressed: enabled
-                ? () => showDialog(
-                      context: context,
-                      builder: (BuildContext context) {
-                        return DeleteDialog(
-                          onDelete: onDelete,
-                        );
-                      },
-                    )
+                ? () {
+                    if (!trashEnabled) {
+                      showDialog(
+                        context: context,
+                        builder: (BuildContext context) {
+                          return DeleteDialog(
+                            onDelete: onDelete,
+                          );
+                        },
+                      );
+                    } else {
+                      onDelete();
+                    }
+                  }
                 : null,
                 : null,
           ),
           ),
           if (!hasRemote)
           if (!hasRemote)

+ 28 - 0
mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart

@@ -9,6 +9,7 @@ import 'package:immich_mobile/modules/home/ui/profile_drawer/server_info_box.dar
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
+import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 
 
 class ProfileDrawer extends HookConsumerWidget {
 class ProfileDrawer extends HookConsumerWidget {
@@ -16,6 +17,9 @@ class ProfileDrawer extends HookConsumerWidget {
 
 
   @override
   @override
   Widget build(BuildContext context, WidgetRef ref) {
   Widget build(BuildContext context, WidgetRef ref) {
+    final trashEnabled =
+        ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
+
     buildSignOutButton() {
     buildSignOutButton() {
       return ListTile(
       return ListTile(
         leading: SizedBox(
         leading: SizedBox(
@@ -91,6 +95,29 @@ class ProfileDrawer extends HookConsumerWidget {
       );
       );
     }
     }
 
 
+    buildTrashButton() {
+      return ListTile(
+        leading: SizedBox(
+          height: double.infinity,
+          child: Icon(
+            Icons.delete_rounded,
+            color: Theme.of(context).textTheme.labelMedium?.color,
+            size: 20,
+          ),
+        ),
+        title: Text(
+          "Trash",
+          style: Theme.of(context)
+              .textTheme
+              .labelLarge
+              ?.copyWith(fontWeight: FontWeight.bold),
+        ).tr(),
+        onTap: () {
+          AutoRouter.of(context).push(const TrashRoute());
+        },
+      );
+    }
+
     return Drawer(
     return Drawer(
       shape: const RoundedRectangleBorder(
       shape: const RoundedRectangleBorder(
         borderRadius: BorderRadius.zero,
         borderRadius: BorderRadius.zero,
@@ -105,6 +132,7 @@ class ProfileDrawer extends HookConsumerWidget {
               const ProfileDrawerHeader(),
               const ProfileDrawerHeader(),
               buildSettingButton(),
               buildSettingButton(),
               buildAppLogButton(),
               buildAppLogButton(),
+              if (trashEnabled) buildTrashButton(),
               buildSignOutButton(),
               buildSignOutButton(),
             ],
             ],
           ),
           ),

+ 17 - 1
mobile/lib/modules/home/views/home_page.dart

@@ -43,6 +43,8 @@ class HomePage extends HookConsumerWidget {
     final sharedAlbums = ref.watch(sharedAlbumProvider);
     final sharedAlbums = ref.watch(sharedAlbumProvider);
     final albumService = ref.watch(albumServiceProvider);
     final albumService = ref.watch(albumServiceProvider);
     final currentUser = ref.watch(currentUserProvider);
     final currentUser = ref.watch(currentUserProvider);
+    final trashEnabled =
+        ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
 
 
     final tipOneOpacity = useState(0.0);
     final tipOneOpacity = useState(0.0);
     final refreshCount = useState(0);
     final refreshCount = useState(0);
@@ -139,7 +141,21 @@ class HomePage extends HookConsumerWidget {
       void onDelete() async {
       void onDelete() async {
         processing.value = true;
         processing.value = true;
         try {
         try {
-          await ref.read(assetProvider.notifier).deleteAssets(selection.value);
+          await ref
+              .read(assetProvider.notifier)
+              .deleteAssets(selection.value, force: !trashEnabled);
+
+          final hasRemote = selection.value.any((a) => a.isRemote);
+          final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
+          final trashOrRemoved =
+              !trashEnabled ? 'deleted permanently' : 'trashed';
+          if (hasRemote) {
+            ImmichToast.show(
+              context: context,
+              msg: '${selection.value.length} $assetOrAssets $trashOrRemoved',
+              gravity: ToastGravity.BOTTOM,
+            );
+          }
           selectionEnabledHook.value = false;
           selectionEnabledHook.value = false;
         } finally {
         } finally {
           processing.value = false;
           processing.value = false;

+ 144 - 0
mobile/lib/modules/trash/providers/trashed_asset.provider.dart

@@ -0,0 +1,144 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
+import 'package:immich_mobile/modules/trash/services/trash.service.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/models/exif_info.dart';
+import 'package:immich_mobile/shared/providers/asset.provider.dart';
+import 'package:immich_mobile/shared/providers/db.provider.dart';
+import 'package:immich_mobile/shared/providers/user.provider.dart';
+import 'package:isar/isar.dart';
+import 'package:logging/logging.dart';
+
+class TrashNotifier extends StateNotifier<bool> {
+  final Isar _db;
+  final Ref _ref;
+  final TrashService _trashService;
+  final _log = Logger('TrashNotifier');
+
+  TrashNotifier(
+    this._trashService,
+    this._db,
+    this._ref,
+  ) : super(false);
+
+  Future<void> emptyTrash() async {
+    try {
+      final user = _ref.read(currentUserProvider);
+      if (user == null) {
+        return;
+      }
+      await _trashService.emptyTrash();
+
+      final dbIds = await _db.assets
+          .where()
+          .remoteIdIsNotNull()
+          .filter()
+          .ownerIdEqualTo(user.isarId)
+          .isTrashedEqualTo(true)
+          .idProperty()
+          .findAll();
+
+      await _db.writeTxn(() async {
+        await _db.exifInfos.deleteAll(dbIds);
+        await _db.assets.deleteAll(dbIds);
+      });
+
+      // Refresh assets in background
+      Future.delayed(
+        const Duration(seconds: 4),
+        () async => await _ref.read(assetProvider.notifier).getAllAsset(),
+      );
+    } catch (error, stack) {
+      _log.severe("Cannot empty trash ${error.toString()}", error, stack);
+    }
+  }
+
+  Future<bool> restoreAssets(Iterable<Asset> assetList) async {
+    try {
+      final result = await _trashService.restoreAssets(assetList);
+
+      if (result) {
+        final remoteAssets = assetList.where((a) => a.isRemote).toList();
+
+        final updatedAssets = remoteAssets.map((e) {
+          e.isTrashed = false;
+          return e;
+        }).toList();
+
+        await _db.writeTxn(() async {
+          await _db.assets.putAll(updatedAssets);
+        });
+
+        // Refresh assets in background
+        Future.delayed(
+          const Duration(seconds: 4),
+          () async => await _ref.read(assetProvider.notifier).getAllAsset(),
+        );
+        return true;
+      }
+    } catch (error, stack) {
+      _log.severe("Cannot restore trash ${error.toString()}", error, stack);
+    }
+    return false;
+  }
+
+  Future<void> restoreTrash() async {
+    try {
+      final user = _ref.read(currentUserProvider);
+      if (user == null) {
+        return;
+      }
+      await _trashService.restoreTrash();
+
+      final assets = await _db.assets
+          .where()
+          .remoteIdIsNotNull()
+          .filter()
+          .ownerIdEqualTo(user.isarId)
+          .isTrashedEqualTo(true)
+          .findAll();
+
+      final updatedAssets = assets.map((e) {
+        e.isTrashed = false;
+        return e;
+      }).toList();
+
+      await _db.writeTxn(() async {
+        await _db.assets.putAll(updatedAssets);
+      });
+
+      // Refresh assets in background
+      Future.delayed(
+        const Duration(seconds: 4),
+        () async => await _ref.read(assetProvider.notifier).getAllAsset(),
+      );
+    } catch (error, stack) {
+      _log.severe("Cannot restore trash ${error.toString()}", error, stack);
+    }
+  }
+}
+
+final trashProvider = StateNotifierProvider<TrashNotifier, bool>((ref) {
+  return TrashNotifier(
+    ref.watch(trashServiceProvider),
+    ref.watch(dbProvider),
+    ref,
+  );
+});
+
+final trashedAssetsProvider = StreamProvider<RenderList>((ref) async* {
+  final user = ref.read(currentUserProvider);
+  if (user == null) return;
+  final query = ref
+      .watch(dbProvider)
+      .assets
+      .filter()
+      .ownerIdEqualTo(user.isarId)
+      .isTrashedEqualTo(true)
+      .sortByFileCreatedAt();
+  const groupBy = GroupAssetsBy.none;
+  yield await RenderList.fromQuery(query, groupBy);
+  await for (final _ in query.watchLazy()) {
+    yield await RenderList.fromQuery(query, groupBy);
+  }
+});

+ 48 - 0
mobile/lib/modules/trash/services/trash.service.dart

@@ -0,0 +1,48 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/providers/api.provider.dart';
+import 'package:immich_mobile/shared/services/api.service.dart';
+import 'package:logging/logging.dart';
+import 'package:openapi/api.dart';
+
+final trashServiceProvider = Provider<TrashService>((ref) {
+  return TrashService(
+    ref.watch(apiServiceProvider),
+  );
+});
+
+class TrashService {
+  final _log = Logger("TrashService");
+
+  final ApiService _apiService;
+
+  TrashService(this._apiService);
+
+  Future<bool> restoreAssets(Iterable<Asset> assetList) async {
+    try {
+      List<String> remoteIds =
+          assetList.where((a) => a.isRemote).map((e) => e.remoteId!).toList();
+      await _apiService.assetApi.restoreAssets(BulkIdsDto(ids: remoteIds));
+      return true;
+    } catch (error, stack) {
+      _log.severe("Cannot restore assets ${error.toString()}", error, stack);
+      return false;
+    }
+  }
+
+  Future<void> emptyTrash() async {
+    try {
+      await _apiService.assetApi.emptyTrash();
+    } catch (error, stack) {
+      _log.severe("Cannot empty trash ${error.toString()}", error, stack);
+    }
+  }
+
+  Future<void> restoreTrash() async {
+    try {
+      await _apiService.assetApi.restoreTrash();
+    } catch (error, stack) {
+      _log.severe("Cannot restore trash ${error.toString()}", error, stack);
+    }
+  }
+}

+ 276 - 0
mobile/lib/modules/trash/views/trash_page.dart

@@ -0,0 +1,276 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:fluttertoast/fluttertoast.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
+import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
+import 'package:immich_mobile/modules/trash/providers/trashed_asset.provider.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/providers/asset.provider.dart';
+import 'package:immich_mobile/shared/providers/server_info.provider.dart';
+import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+import 'package:immich_mobile/shared/ui/immich_toast.dart';
+
+class TrashPage extends HookConsumerWidget {
+  const TrashPage({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final trashedAssets = ref.watch(trashedAssetsProvider);
+    final trashDays =
+        ref.watch(serverInfoProvider.select((v) => v.serverConfig.trashDays));
+    final selectionEnabledHook = useState(false);
+    final selection = useState(<Asset>{});
+    final processing = useState(false);
+
+    void selectionListener(
+      bool multiselect,
+      Set<Asset> selectedAssets,
+    ) {
+      selectionEnabledHook.value = multiselect;
+      selection.value = selectedAssets;
+    }
+
+    onEmptyTrash() async {
+      processing.value = true;
+      await ref.read(trashProvider.notifier).emptyTrash();
+      processing.value = false;
+      selectionEnabledHook.value = false;
+      if (context.mounted) {
+        ImmichToast.show(
+          context: context,
+          msg: 'Emptied trash',
+          gravity: ToastGravity.BOTTOM,
+        );
+      }
+    }
+
+    handleEmptyTrash() async {
+      await showDialog(
+        context: context,
+        builder: (context) => ConfirmDialog(
+          onOk: () => onEmptyTrash(),
+          title: "trash_page_empty_trash_btn".tr(),
+          ok: "trash_page_empty_trash_dialog_ok".tr(),
+          content: "trash_page_empty_trash_dialog_content".tr(),
+        ),
+      );
+    }
+
+    Future<void> onPermanentlyDelete() async {
+      processing.value = true;
+      try {
+        if (selection.value.isNotEmpty) {
+          await ref
+              .read(assetProvider.notifier)
+              .deleteAssets(selection.value, force: true);
+
+          final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
+          if (context.mounted) {
+            ImmichToast.show(
+              context: context,
+              msg:
+                  '${selection.value.length} $assetOrAssets deleted permanently',
+              gravity: ToastGravity.BOTTOM,
+            );
+          }
+        }
+      } finally {
+        processing.value = false;
+        selectionEnabledHook.value = false;
+      }
+    }
+
+    handlePermanentDelete() async {
+      await showDialog(
+        context: context,
+        builder: (context) => DeleteDialog(
+          onDelete: () => onPermanentlyDelete(),
+        ),
+      );
+    }
+
+    Future<void> handleRestoreAll() async {
+      processing.value = true;
+      await ref.read(trashProvider.notifier).restoreTrash();
+      processing.value = false;
+      selectionEnabledHook.value = false;
+    }
+
+    Future<void> handleRestore() async {
+      processing.value = true;
+      try {
+        if (selection.value.isNotEmpty) {
+          final result = await ref
+              .read(trashProvider.notifier)
+              .restoreAssets(selection.value);
+
+          final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
+          if (result && context.mounted) {
+            ImmichToast.show(
+              context: context,
+              msg:
+                  '${selection.value.length} $assetOrAssets restored successfully',
+              gravity: ToastGravity.BOTTOM,
+            );
+          }
+        }
+      } finally {
+        processing.value = false;
+        selectionEnabledHook.value = false;
+      }
+    }
+
+    String getAppBarTitle(String count) {
+      if (selectionEnabledHook.value) {
+        return selection.value.isNotEmpty
+            ? "${selection.value.length}"
+            : "trash_page_select_assets_btn".tr();
+      }
+      return 'trash_page_title'.tr(args: [count]);
+    }
+
+    AppBar buildAppBar(String count) {
+      return AppBar(
+        leading: IconButton(
+          onPressed: !selectionEnabledHook.value
+              ? () => AutoRouter.of(context).pop()
+              : () {
+                  selectionEnabledHook.value = false;
+                  selection.value = {};
+                },
+          icon: !selectionEnabledHook.value
+              ? const Icon(Icons.arrow_back_ios_rounded)
+              : const Icon(Icons.close_rounded),
+        ),
+        centerTitle: !selectionEnabledHook.value,
+        automaticallyImplyLeading: false,
+        title: Text(getAppBarTitle(count)),
+        actions: <Widget>[
+          if (!selectionEnabledHook.value)
+            PopupMenuButton<void Function()>(
+              itemBuilder: (context) {
+                return [
+                  PopupMenuItem(
+                    value: () => selectionEnabledHook.value = true,
+                    child: const Text('trash_page_select_btn').tr(),
+                  ),
+                  PopupMenuItem(
+                    value: handleEmptyTrash,
+                    child: const Text('trash_page_empty_trash_btn').tr(),
+                  ),
+                ];
+              },
+              onSelected: (fn) => fn(),
+            ),
+        ],
+      );
+    }
+
+    Widget buildBottomBar() {
+      return SafeArea(
+        child: Align(
+          alignment: Alignment.bottomCenter,
+          child: SizedBox(
+            height: 64,
+            child: Container(
+              color: Theme.of(context).canvasColor,
+              child: Row(
+                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+                children: [
+                  TextButton.icon(
+                    icon: Icon(
+                      Icons.delete_forever,
+                      color: Colors.red[400],
+                    ),
+                    label: Text(
+                      selection.value.isEmpty
+                          ? 'trash_page_delete_all'.tr()
+                          : 'trash_page_delete'.tr(),
+                      style: TextStyle(
+                        fontSize: 14,
+                        color: Colors.red[400],
+                        fontWeight: FontWeight.bold,
+                      ),
+                    ),
+                    onPressed: processing.value
+                        ? null
+                        : selection.value.isEmpty
+                            ? handleEmptyTrash
+                            : handlePermanentDelete,
+                  ),
+                  TextButton.icon(
+                    icon: const Icon(
+                      Icons.history_rounded,
+                    ),
+                    label: Text(
+                      selection.value.isEmpty
+                          ? 'trash_page_restore_all'.tr()
+                          : 'trash_page_restore'.tr(),
+                      style: const TextStyle(
+                        fontSize: 14,
+                        fontWeight: FontWeight.bold,
+                      ),
+                    ),
+                    onPressed: processing.value
+                        ? null
+                        : selection.value.isEmpty
+                            ? handleRestoreAll
+                            : handleRestore,
+                  ),
+                ],
+              ),
+            ),
+          ),
+        ),
+      );
+    }
+
+    return trashedAssets.when(
+      loading: () => Scaffold(
+        appBar: buildAppBar("?"),
+        body: const Center(child: CircularProgressIndicator()),
+      ),
+      error: (error, stackTrace) => Scaffold(
+        appBar: buildAppBar("!"),
+        body: Center(child: Text(error.toString())),
+      ),
+      data: (data) => Scaffold(
+        appBar: buildAppBar(data.totalAssets.toString()),
+        body: data.isEmpty
+            ? Center(
+                child: Text('trash_page_no_assets'.tr()),
+              )
+            : Stack(
+                children: [
+                  SafeArea(
+                    child: ImmichAssetGrid(
+                      renderList: data,
+                      listener: selectionListener,
+                      selectionActive: selectionEnabledHook.value,
+                      showMultiSelectIndicator: false,
+                      topWidget: Padding(
+                        padding: const EdgeInsets.only(
+                          top: 24,
+                          bottom: 24,
+                          left: 12,
+                          right: 12,
+                        ),
+                        child: const Text(
+                          "trash_page_info",
+                        ).tr(args: ["$trashDays"]),
+                      ),
+                    ),
+                  ),
+                  if (selectionEnabledHook.value) buildBottomBar(),
+                  if (processing.value)
+                    const Center(child: ImmichLoadingIndicator()),
+                ],
+              ),
+      ),
+    );
+  }
+}

+ 2 - 0
mobile/lib/routing/router.dart

@@ -28,6 +28,7 @@ import 'package:immich_mobile/modules/login/views/change_password_page.dart';
 import 'package:immich_mobile/modules/login/views/login_page.dart';
 import 'package:immich_mobile/modules/login/views/login_page.dart';
 import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
 import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
 import 'package:immich_mobile/modules/onboarding/views/permission_onboarding_page.dart';
 import 'package:immich_mobile/modules/onboarding/views/permission_onboarding_page.dart';
+import 'package:immich_mobile/modules/trash/views/trash_page.dart';
 import 'package:immich_mobile/modules/search/views/all_motion_videos_page.dart';
 import 'package:immich_mobile/modules/search/views/all_motion_videos_page.dart';
 import 'package:immich_mobile/modules/search/views/all_people_page.dart';
 import 'package:immich_mobile/modules/search/views/all_people_page.dart';
 import 'package:immich_mobile/modules/search/views/all_videos_page.dart';
 import 'package:immich_mobile/modules/search/views/all_videos_page.dart';
@@ -155,6 +156,7 @@ part 'router.gr.dart';
     AutoRoute(page: MemoryPage, guards: [AuthGuard, DuplicateGuard]),
     AutoRoute(page: MemoryPage, guards: [AuthGuard, DuplicateGuard]),
     AutoRoute(page: MapPage, guards: [AuthGuard, DuplicateGuard]),
     AutoRoute(page: MapPage, guards: [AuthGuard, DuplicateGuard]),
     AutoRoute(page: AlbumOptionsPage, guards: [AuthGuard, DuplicateGuard]),
     AutoRoute(page: AlbumOptionsPage, guards: [AuthGuard, DuplicateGuard]),
+    AutoRoute(page: TrashPage, guards: [AuthGuard, DuplicateGuard]),
   ],
   ],
 )
 )
 class AppRouter extends _$AppRouter {
 class AppRouter extends _$AppRouter {

+ 26 - 0
mobile/lib/routing/router.gr.dart

@@ -312,6 +312,12 @@ class _$AppRouter extends RootStackRouter {
         ),
         ),
       );
       );
     },
     },
+    TrashRoute.name: (routeData) {
+      return MaterialPageX<dynamic>(
+        routeData: routeData,
+        child: const TrashPage(),
+      );
+    },
     HomeRoute.name: (routeData) {
     HomeRoute.name: (routeData) {
       return MaterialPageX<dynamic>(
       return MaterialPageX<dynamic>(
         routeData: routeData,
         routeData: routeData,
@@ -624,6 +630,14 @@ class _$AppRouter extends RootStackRouter {
             duplicateGuard,
             duplicateGuard,
           ],
           ],
         ),
         ),
+        RouteConfig(
+          TrashRoute.name,
+          path: '/trash-page',
+          guards: [
+            authGuard,
+            duplicateGuard,
+          ],
+        ),
       ];
       ];
 }
 }
 
 
@@ -1394,6 +1408,18 @@ class AlbumOptionsRouteArgs {
   }
   }
 }
 }
 
 
+/// generated route for
+/// [TrashPage]
+class TrashRoute extends PageRouteInfo<void> {
+  const TrashRoute()
+      : super(
+          TrashRoute.name,
+          path: '/trash-page',
+        );
+
+  static const String name = 'TrashRoute';
+}
+
 /// generated route for
 /// generated route for
 /// [HomePage]
 /// [HomePage]
 class HomeRoute extends PageRouteInfo<void> {
 class HomeRoute extends PageRouteInfo<void> {

+ 19 - 6
mobile/lib/shared/models/asset.dart

@@ -30,7 +30,8 @@ class Asset {
         exifInfo =
         exifInfo =
             remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
             remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
         isFavorite = remote.isFavorite,
         isFavorite = remote.isFavorite,
-        isArchived = remote.isArchived;
+        isArchived = remote.isArchived,
+        isTrashed = remote.isTrashed;
 
 
   Asset.local(AssetEntity local, List<int> hash)
   Asset.local(AssetEntity local, List<int> hash)
       : localId = local.id,
       : localId = local.id,
@@ -45,6 +46,7 @@ class Asset {
         updatedAt = local.modifiedDateTime,
         updatedAt = local.modifiedDateTime,
         isFavorite = local.isFavorite,
         isFavorite = local.isFavorite,
         isArchived = false,
         isArchived = false,
+        isTrashed = false,
         fileCreatedAt = local.createDateTime {
         fileCreatedAt = local.createDateTime {
     if (fileCreatedAt.year == 1970) {
     if (fileCreatedAt.year == 1970) {
       fileCreatedAt = fileModifiedAt;
       fileCreatedAt = fileModifiedAt;
@@ -74,6 +76,7 @@ class Asset {
     this.exifInfo,
     this.exifInfo,
     required this.isFavorite,
     required this.isFavorite,
     required this.isArchived,
     required this.isArchived,
+    required this.isTrashed,
   });
   });
 
 
   @ignore
   @ignore
@@ -138,6 +141,8 @@ class Asset {
 
 
   bool isArchived;
   bool isArchived;
 
 
+  bool isTrashed;
+
   @ignore
   @ignore
   ExifInfo? exifInfo;
   ExifInfo? exifInfo;
 
 
@@ -194,7 +199,8 @@ class Asset {
         livePhotoVideoId == other.livePhotoVideoId &&
         livePhotoVideoId == other.livePhotoVideoId &&
         isFavorite == other.isFavorite &&
         isFavorite == other.isFavorite &&
         isLocal == other.isLocal &&
         isLocal == other.isLocal &&
-        isArchived == other.isArchived;
+        isArchived == other.isArchived &&
+        isTrashed == other.isTrashed;
   }
   }
 
 
   @override
   @override
@@ -216,7 +222,8 @@ class Asset {
       livePhotoVideoId.hashCode ^
       livePhotoVideoId.hashCode ^
       isFavorite.hashCode ^
       isFavorite.hashCode ^
       isLocal.hashCode ^
       isLocal.hashCode ^
-      isArchived.hashCode;
+      isArchived.hashCode ^
+      isTrashed.hashCode;
 
 
   /// Returns `true` if this [Asset] can updated with values from parameter [a]
   /// Returns `true` if this [Asset] can updated with values from parameter [a]
   bool canUpdate(Asset a) {
   bool canUpdate(Asset a) {
@@ -229,8 +236,9 @@ class Asset {
         width == null && a.width != null ||
         width == null && a.width != null ||
         height == null && a.height != null ||
         height == null && a.height != null ||
         livePhotoVideoId == null && a.livePhotoVideoId != null ||
         livePhotoVideoId == null && a.livePhotoVideoId != null ||
-        !isRemote && a.isRemote && isFavorite != a.isFavorite ||
-        !isRemote && a.isRemote && isArchived != a.isArchived;
+        isFavorite != a.isFavorite ||
+        isArchived != a.isArchived ||
+        isTrashed != a.isTrashed;
   }
   }
 
 
   /// Returns a new [Asset] with values from this and merged & updated with [a]
   /// Returns a new [Asset] with values from this and merged & updated with [a]
@@ -261,6 +269,7 @@ class Asset {
           livePhotoVideoId: livePhotoVideoId,
           livePhotoVideoId: livePhotoVideoId,
           isFavorite: isFavorite,
           isFavorite: isFavorite,
           isArchived: isArchived,
           isArchived: isArchived,
+          isTrashed: isTrashed,
         );
         );
       }
       }
     } else {
     } else {
@@ -275,6 +284,7 @@ class Asset {
           // isFavorite + isArchived are not set by device-only assets
           // isFavorite + isArchived are not set by device-only assets
           isFavorite: a.isFavorite,
           isFavorite: a.isFavorite,
           isArchived: a.isArchived,
           isArchived: a.isArchived,
+          isTrashed: a.isTrashed,
           exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo,
           exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo,
         );
         );
       } else {
       } else {
@@ -306,6 +316,7 @@ class Asset {
     String? livePhotoVideoId,
     String? livePhotoVideoId,
     bool? isFavorite,
     bool? isFavorite,
     bool? isArchived,
     bool? isArchived,
+    bool? isTrashed,
     ExifInfo? exifInfo,
     ExifInfo? exifInfo,
   }) =>
   }) =>
       Asset(
       Asset(
@@ -325,6 +336,7 @@ class Asset {
         livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
         livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
         isFavorite: isFavorite ?? this.isFavorite,
         isFavorite: isFavorite ?? this.isFavorite,
         isArchived: isArchived ?? this.isArchived,
         isArchived: isArchived ?? this.isArchived,
+        isTrashed: isTrashed ?? this.isTrashed,
         exifInfo: exifInfo ?? this.exifInfo,
         exifInfo: exifInfo ?? this.exifInfo,
       );
       );
 
 
@@ -378,7 +390,8 @@ class Asset {
   "storage": "$storage",
   "storage": "$storage",
   "width": ${width ?? "N/A"},
   "width": ${width ?? "N/A"},
   "height": ${height ?? "N/A"},
   "height": ${height ?? "N/A"},
-  "isArchived": $isArchived
+  "isArchived": $isArchived,
+  "isTrashed": $isTrashed,
 }""";
 }""";
   }
   }
 }
 }

+ 81 - 26
mobile/lib/shared/models/asset.g.dart

@@ -57,39 +57,44 @@ const AssetSchema = CollectionSchema(
       name: r'isFavorite',
       name: r'isFavorite',
       type: IsarType.bool,
       type: IsarType.bool,
     ),
     ),
-    r'livePhotoVideoId': PropertySchema(
+    r'isTrashed': PropertySchema(
       id: 8,
       id: 8,
+      name: r'isTrashed',
+      type: IsarType.bool,
+    ),
+    r'livePhotoVideoId': PropertySchema(
+      id: 9,
       name: r'livePhotoVideoId',
       name: r'livePhotoVideoId',
       type: IsarType.string,
       type: IsarType.string,
     ),
     ),
     r'localId': PropertySchema(
     r'localId': PropertySchema(
-      id: 9,
+      id: 10,
       name: r'localId',
       name: r'localId',
       type: IsarType.string,
       type: IsarType.string,
     ),
     ),
     r'ownerId': PropertySchema(
     r'ownerId': PropertySchema(
-      id: 10,
+      id: 11,
       name: r'ownerId',
       name: r'ownerId',
       type: IsarType.long,
       type: IsarType.long,
     ),
     ),
     r'remoteId': PropertySchema(
     r'remoteId': PropertySchema(
-      id: 11,
+      id: 12,
       name: r'remoteId',
       name: r'remoteId',
       type: IsarType.string,
       type: IsarType.string,
     ),
     ),
     r'type': PropertySchema(
     r'type': PropertySchema(
-      id: 12,
+      id: 13,
       name: r'type',
       name: r'type',
       type: IsarType.byte,
       type: IsarType.byte,
       enumMap: _AssettypeEnumValueMap,
       enumMap: _AssettypeEnumValueMap,
     ),
     ),
     r'updatedAt': PropertySchema(
     r'updatedAt': PropertySchema(
-      id: 13,
+      id: 14,
       name: r'updatedAt',
       name: r'updatedAt',
       type: IsarType.dateTime,
       type: IsarType.dateTime,
     ),
     ),
     r'width': PropertySchema(
     r'width': PropertySchema(
-      id: 14,
+      id: 15,
       name: r'width',
       name: r'width',
       type: IsarType.int,
       type: IsarType.int,
     )
     )
@@ -196,13 +201,14 @@ void _assetSerialize(
   writer.writeInt(offsets[5], object.height);
   writer.writeInt(offsets[5], object.height);
   writer.writeBool(offsets[6], object.isArchived);
   writer.writeBool(offsets[6], object.isArchived);
   writer.writeBool(offsets[7], object.isFavorite);
   writer.writeBool(offsets[7], object.isFavorite);
-  writer.writeString(offsets[8], object.livePhotoVideoId);
-  writer.writeString(offsets[9], object.localId);
-  writer.writeLong(offsets[10], object.ownerId);
-  writer.writeString(offsets[11], object.remoteId);
-  writer.writeByte(offsets[12], object.type.index);
-  writer.writeDateTime(offsets[13], object.updatedAt);
-  writer.writeInt(offsets[14], object.width);
+  writer.writeBool(offsets[8], object.isTrashed);
+  writer.writeString(offsets[9], object.livePhotoVideoId);
+  writer.writeString(offsets[10], object.localId);
+  writer.writeLong(offsets[11], object.ownerId);
+  writer.writeString(offsets[12], object.remoteId);
+  writer.writeByte(offsets[13], object.type.index);
+  writer.writeDateTime(offsets[14], object.updatedAt);
+  writer.writeInt(offsets[15], object.width);
 }
 }
 
 
 Asset _assetDeserialize(
 Asset _assetDeserialize(
@@ -221,14 +227,15 @@ Asset _assetDeserialize(
     id: id,
     id: id,
     isArchived: reader.readBool(offsets[6]),
     isArchived: reader.readBool(offsets[6]),
     isFavorite: reader.readBool(offsets[7]),
     isFavorite: reader.readBool(offsets[7]),
-    livePhotoVideoId: reader.readStringOrNull(offsets[8]),
-    localId: reader.readStringOrNull(offsets[9]),
-    ownerId: reader.readLong(offsets[10]),
-    remoteId: reader.readStringOrNull(offsets[11]),
-    type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[12])] ??
+    isTrashed: reader.readBool(offsets[8]),
+    livePhotoVideoId: reader.readStringOrNull(offsets[9]),
+    localId: reader.readStringOrNull(offsets[10]),
+    ownerId: reader.readLong(offsets[11]),
+    remoteId: reader.readStringOrNull(offsets[12]),
+    type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[13])] ??
         AssetType.other,
         AssetType.other,
-    updatedAt: reader.readDateTime(offsets[13]),
-    width: reader.readIntOrNull(offsets[14]),
+    updatedAt: reader.readDateTime(offsets[14]),
+    width: reader.readIntOrNull(offsets[15]),
   );
   );
   return object;
   return object;
 }
 }
@@ -257,19 +264,21 @@ P _assetDeserializeProp<P>(
     case 7:
     case 7:
       return (reader.readBool(offset)) as P;
       return (reader.readBool(offset)) as P;
     case 8:
     case 8:
-      return (reader.readStringOrNull(offset)) as P;
+      return (reader.readBool(offset)) as P;
     case 9:
     case 9:
       return (reader.readStringOrNull(offset)) as P;
       return (reader.readStringOrNull(offset)) as P;
     case 10:
     case 10:
-      return (reader.readLong(offset)) as P;
-    case 11:
       return (reader.readStringOrNull(offset)) as P;
       return (reader.readStringOrNull(offset)) as P;
+    case 11:
+      return (reader.readLong(offset)) as P;
     case 12:
     case 12:
+      return (reader.readStringOrNull(offset)) as P;
+    case 13:
       return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
       return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
           AssetType.other) as P;
           AssetType.other) as P;
-    case 13:
-      return (reader.readDateTime(offset)) as P;
     case 14:
     case 14:
+      return (reader.readDateTime(offset)) as P;
+    case 15:
       return (reader.readIntOrNull(offset)) as P;
       return (reader.readIntOrNull(offset)) as P;
     default:
     default:
       throw IsarError('Unknown property with id $propertyId');
       throw IsarError('Unknown property with id $propertyId');
@@ -1290,6 +1299,16 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
     });
     });
   }
   }
 
 
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> isTrashedEqualTo(
+      bool value) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'isTrashed',
+        value: value,
+      ));
+    });
+  }
+
   QueryBuilder<Asset, Asset, QAfterFilterCondition> livePhotoVideoIdIsNull() {
   QueryBuilder<Asset, Asset, QAfterFilterCondition> livePhotoVideoIdIsNull() {
     return QueryBuilder.apply(this, (query) {
     return QueryBuilder.apply(this, (query) {
       return query.addFilterCondition(const FilterCondition.isNull(
       return query.addFilterCondition(const FilterCondition.isNull(
@@ -2058,6 +2077,18 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
     });
     });
   }
   }
 
 
+  QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsTrashed() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isTrashed', Sort.asc);
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsTrashedDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isTrashed', Sort.desc);
+    });
+  }
+
   QueryBuilder<Asset, Asset, QAfterSortBy> sortByLivePhotoVideoId() {
   QueryBuilder<Asset, Asset, QAfterSortBy> sortByLivePhotoVideoId() {
     return QueryBuilder.apply(this, (query) {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'livePhotoVideoId', Sort.asc);
       return query.addSortBy(r'livePhotoVideoId', Sort.asc);
@@ -2252,6 +2283,18 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
     });
     });
   }
   }
 
 
+  QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsTrashed() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isTrashed', Sort.asc);
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsTrashedDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isTrashed', Sort.desc);
+    });
+  }
+
   QueryBuilder<Asset, Asset, QAfterSortBy> thenByLivePhotoVideoId() {
   QueryBuilder<Asset, Asset, QAfterSortBy> thenByLivePhotoVideoId() {
     return QueryBuilder.apply(this, (query) {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'livePhotoVideoId', Sort.asc);
       return query.addSortBy(r'livePhotoVideoId', Sort.asc);
@@ -2388,6 +2431,12 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
     });
     });
   }
   }
 
 
+  QueryBuilder<Asset, Asset, QDistinct> distinctByIsTrashed() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'isTrashed');
+    });
+  }
+
   QueryBuilder<Asset, Asset, QDistinct> distinctByLivePhotoVideoId(
   QueryBuilder<Asset, Asset, QDistinct> distinctByLivePhotoVideoId(
       {bool caseSensitive = true}) {
       {bool caseSensitive = true}) {
     return QueryBuilder.apply(this, (query) {
     return QueryBuilder.apply(this, (query) {
@@ -2490,6 +2539,12 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
     });
     });
   }
   }
 
 
+  QueryBuilder<Asset, bool, QQueryOperations> isTrashedProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'isTrashed');
+    });
+  }
+
   QueryBuilder<Asset, String?, QQueryOperations> livePhotoVideoIdProperty() {
   QueryBuilder<Asset, String?, QQueryOperations> livePhotoVideoIdProperty() {
     return QueryBuilder.apply(this, (query) {
     return QueryBuilder.apply(this, (query) {
       return query.addPropertyName(r'livePhotoVideoId');
       return query.addPropertyName(r'livePhotoVideoId');

+ 32 - 10
mobile/lib/shared/providers/asset.provider.dart

@@ -15,7 +15,6 @@ import 'package:immich_mobile/shared/services/user.service.dart';
 import 'package:immich_mobile/utils/db.dart';
 import 'package:immich_mobile/utils/db.dart';
 import 'package:isar/isar.dart';
 import 'package:isar/isar.dart';
 import 'package:logging/logging.dart';
 import 'package:logging/logging.dart';
-import 'package:openapi/api.dart';
 import 'package:photo_manager/photo_manager.dart';
 import 'package:photo_manager/photo_manager.dart';
 
 
 class AssetNotifier extends StateNotifier<bool> {
 class AssetNotifier extends StateNotifier<bool> {
@@ -92,23 +91,45 @@ class AssetNotifier extends StateNotifier<bool> {
     await _syncService.syncNewAssetToDb(newAsset);
     await _syncService.syncNewAssetToDb(newAsset);
   }
   }
 
 
-  Future<void> deleteAssets(Iterable<Asset> deleteAssets) async {
+  Future<bool> deleteAssets(
+    Iterable<Asset> deleteAssets, {
+    bool? force = false,
+  }) async {
     _deleteInProgress = true;
     _deleteInProgress = true;
     state = true;
     state = true;
     try {
     try {
       final localDeleted = await _deleteLocalAssets(deleteAssets);
       final localDeleted = await _deleteLocalAssets(deleteAssets);
-      final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
+      final remoteDeleted = await _deleteRemoteAssets(deleteAssets, force);
       if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) {
       if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) {
-        final dbIds = deleteAssets.map((e) => e.id).toList();
+        List<Asset>? assetsToUpdate;
+        // Local only assets are permanently deleted for now. So always remove them from db
+        final dbIds = deleteAssets
+            .where((a) => a.isLocal && !a.isRemote)
+            .map((e) => e.id)
+            .toList();
+        if (force == null || !force) {
+          assetsToUpdate = remoteDeleted.map((e) {
+            e.isTrashed = true;
+            return e;
+          }).toList();
+        } else {
+          // Add all remote assets to be deleted from isar as since they are permanently deleted
+          dbIds.addAll(remoteDeleted.map((e) => e.id));
+        }
         await _db.writeTxn(() async {
         await _db.writeTxn(() async {
+          if (assetsToUpdate != null) {
+            await _db.assets.putAll(assetsToUpdate);
+          }
           await _db.exifInfos.deleteAll(dbIds);
           await _db.exifInfos.deleteAll(dbIds);
           await _db.assets.deleteAll(dbIds);
           await _db.assets.deleteAll(dbIds);
         });
         });
+        return true;
       }
       }
     } finally {
     } finally {
       _deleteInProgress = false;
       _deleteInProgress = false;
       state = false;
       state = false;
     }
     }
+    return false;
   }
   }
 
 
   Future<List<String>> _deleteLocalAssets(
   Future<List<String>> _deleteLocalAssets(
@@ -127,15 +148,14 @@ class AssetNotifier extends StateNotifier<bool> {
     return [];
     return [];
   }
   }
 
 
-  Future<Iterable<String>> _deleteRemoteAssets(
+  Future<Iterable<Asset>> _deleteRemoteAssets(
     Iterable<Asset> assetsToDelete,
     Iterable<Asset> assetsToDelete,
+    bool? force,
   ) async {
   ) async {
     final Iterable<Asset> remote = assetsToDelete.where((e) => e.isRemote);
     final Iterable<Asset> remote = assetsToDelete.where((e) => e.isRemote);
-    final List<DeleteAssetResponseDto> deleteAssetResult =
-        await _assetService.deleteAssets(remote) ?? [];
-    return deleteAssetResult
-        .where((a) => a.status == DeleteAssetStatus.SUCCESS)
-        .map((a) => a.id);
+
+    final isSuccess = await _assetService.deleteAssets(remote, force: force);
+    return isSuccess ? remote : [];
   }
   }
 
 
   Future<void> toggleFavorite(List<Asset> assets, bool status) async {
   Future<void> toggleFavorite(List<Asset> assets, bool status) async {
@@ -190,6 +210,7 @@ final assetsProvider =
       .ownerIdEqualToAnyChecksum(userId)
       .ownerIdEqualToAnyChecksum(userId)
       .filter()
       .filter()
       .isArchivedEqualTo(false)
       .isArchivedEqualTo(false)
+      .isTrashedEqualTo(false)
       .sortByFileCreatedAtDesc();
       .sortByFileCreatedAtDesc();
   final settings = ref.watch(appSettingsServiceProvider);
   final settings = ref.watch(appSettingsServiceProvider);
   final groupBy =
   final groupBy =
@@ -210,6 +231,7 @@ final remoteAssetsProvider =
       .remoteIdIsNotNull()
       .remoteIdIsNotNull()
       .filter()
       .filter()
       .ownerIdEqualTo(userId)
       .ownerIdEqualTo(userId)
+      .isTrashedEqualTo(false)
       .sortByFileCreatedAtDesc();
       .sortByFileCreatedAtDesc();
   final settings = ref.watch(appSettingsServiceProvider);
   final settings = ref.watch(appSettingsServiceProvider);
   final groupBy =
   final groupBy =

+ 2 - 0
mobile/lib/shared/providers/server_info.provider.dart

@@ -26,12 +26,14 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
               search: true,
               search: true,
               sidecar: true,
               sidecar: true,
               tagImage: true,
               tagImage: true,
+              trash: true,
               reverseGeocoding: true,
               reverseGeocoding: true,
             ),
             ),
             serverConfig: ServerConfigDto(
             serverConfig: ServerConfigDto(
               loginPageMessage: "",
               loginPageMessage: "",
               mapTileUrl: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
               mapTileUrl: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
               oauthButtonText: "",
               oauthButtonText: "",
+              trashDays: 30,
             ),
             ),
             isVersionMismatch: false,
             isVersionMismatch: false,
             versionMismatchErrorMessage: "",
             versionMismatchErrorMessage: "",

+ 7 - 0
mobile/lib/shared/providers/websocket.provider.dart

@@ -6,6 +6,7 @@ import 'package:immich_mobile/modules/login/providers/authentication.provider.da
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
+import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:logging/logging.dart';
 import 'package:logging/logging.dart';
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
 import 'package:socket_io_client/socket_io_client.dart';
 import 'package:socket_io_client/socket_io_client.dart';
@@ -92,6 +93,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
         });
         });
 
 
         socket.on('on_upload_success', _handleOnUploadSuccess);
         socket.on('on_upload_success', _handleOnUploadSuccess);
+        socket.on('on_config_update', _handleOnConfigUpdate);
       } catch (e) {
       } catch (e) {
         debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
         debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
       }
       }
@@ -126,6 +128,11 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
       ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
       ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
     }
     }
   }
   }
+
+  _handleOnConfigUpdate(dynamic data) {
+    ref.read(serverInfoProvider.notifier).getServerFeatures();
+    ref.read(serverInfoProvider.notifier).getServerConfig();
+  }
 }
 }
 
 
 final websocketProvider =
 final websocketProvider =

+ 15 - 7
mobile/lib/shared/services/asset.service.dart

@@ -64,7 +64,9 @@ class AssetService {
   Future<List<Asset>?> _getRemoteAssets(User user) async {
   Future<List<Asset>?> _getRemoteAssets(User user) async {
     try {
     try {
       final List<AssetResponseDto>? assets =
       final List<AssetResponseDto>? assets =
-          await _apiService.assetApi.getAllAssets(userId: user.id);
+          await _apiService.assetApi.getAllAssets(
+        userId: user.id,
+      );
       if (assets == null) {
       if (assets == null) {
         return null;
         return null;
       } else if (assets.isNotEmpty && assets.first.ownerId != user.id) {
       } else if (assets.isNotEmpty && assets.first.ownerId != user.id) {
@@ -84,9 +86,10 @@ class AssetService {
     }
     }
   }
   }
 
 
-  Future<List<DeleteAssetResponseDto>?> deleteAssets(
-    Iterable<Asset> deleteAssets,
-  ) async {
+  Future<bool> deleteAssets(
+    Iterable<Asset> deleteAssets, {
+    bool? force = false,
+  }) async {
     try {
     try {
       final List<String> payload = [];
       final List<String> payload = [];
 
 
@@ -94,12 +97,17 @@ class AssetService {
         payload.add(asset.remoteId!);
         payload.add(asset.remoteId!);
       }
       }
 
 
-      return await _apiService.assetApi
-          .deleteAsset(DeleteAssetDto(ids: payload));
+      await _apiService.assetApi.deleteAssets(
+        AssetBulkDeleteDto(
+          ids: payload,
+          force: force,
+        ),
+      );
+      return true;
     } catch (error, stack) {
     } catch (error, stack) {
       log.severe("Error deleteAssets  ${error.toString()}", error, stack);
       log.severe("Error deleteAssets  ${error.toString()}", error, stack);
-      return null;
     }
     }
+    return false;
   }
   }
 
 
   /// Loads the exif information from the database. If there is none, loads
   /// Loads the exif information from the database. If there is none, loads

+ 6 - 9
mobile/openapi/.openapi-generator/FILES

@@ -15,6 +15,7 @@ doc/AlbumCountResponseDto.md
 doc/AlbumResponseDto.md
 doc/AlbumResponseDto.md
 doc/AllJobStatusResponseDto.md
 doc/AllJobStatusResponseDto.md
 doc/AssetApi.md
 doc/AssetApi.md
+doc/AssetBulkDeleteDto.md
 doc/AssetBulkUpdateDto.md
 doc/AssetBulkUpdateDto.md
 doc/AssetBulkUploadCheckDto.md
 doc/AssetBulkUploadCheckDto.md
 doc/AssetBulkUploadCheckItem.md
 doc/AssetBulkUploadCheckItem.md
@@ -53,9 +54,6 @@ doc/CreateTagDto.md
 doc/CreateUserDto.md
 doc/CreateUserDto.md
 doc/CuratedLocationsResponseDto.md
 doc/CuratedLocationsResponseDto.md
 doc/CuratedObjectsResponseDto.md
 doc/CuratedObjectsResponseDto.md
-doc/DeleteAssetDto.md
-doc/DeleteAssetResponseDto.md
-doc/DeleteAssetStatus.md
 doc/DownloadArchiveInfo.md
 doc/DownloadArchiveInfo.md
 doc/DownloadInfoDto.md
 doc/DownloadInfoDto.md
 doc/DownloadResponseDto.md
 doc/DownloadResponseDto.md
@@ -131,6 +129,7 @@ doc/SystemConfigReverseGeocodingDto.md
 doc/SystemConfigStorageTemplateDto.md
 doc/SystemConfigStorageTemplateDto.md
 doc/SystemConfigTemplateStorageOptionDto.md
 doc/SystemConfigTemplateStorageOptionDto.md
 doc/SystemConfigThumbnailDto.md
 doc/SystemConfigThumbnailDto.md
+doc/SystemConfigTrashDto.md
 doc/TagApi.md
 doc/TagApi.md
 doc/TagResponseDto.md
 doc/TagResponseDto.md
 doc/TagTypeEnum.md
 doc/TagTypeEnum.md
@@ -186,6 +185,7 @@ lib/model/api_key_create_dto.dart
 lib/model/api_key_create_response_dto.dart
 lib/model/api_key_create_response_dto.dart
 lib/model/api_key_response_dto.dart
 lib/model/api_key_response_dto.dart
 lib/model/api_key_update_dto.dart
 lib/model/api_key_update_dto.dart
+lib/model/asset_bulk_delete_dto.dart
 lib/model/asset_bulk_update_dto.dart
 lib/model/asset_bulk_update_dto.dart
 lib/model/asset_bulk_upload_check_dto.dart
 lib/model/asset_bulk_upload_check_dto.dart
 lib/model/asset_bulk_upload_check_item.dart
 lib/model/asset_bulk_upload_check_item.dart
@@ -222,9 +222,6 @@ lib/model/create_tag_dto.dart
 lib/model/create_user_dto.dart
 lib/model/create_user_dto.dart
 lib/model/curated_locations_response_dto.dart
 lib/model/curated_locations_response_dto.dart
 lib/model/curated_objects_response_dto.dart
 lib/model/curated_objects_response_dto.dart
-lib/model/delete_asset_dto.dart
-lib/model/delete_asset_response_dto.dart
-lib/model/delete_asset_status.dart
 lib/model/download_archive_info.dart
 lib/model/download_archive_info.dart
 lib/model/download_info_dto.dart
 lib/model/download_info_dto.dart
 lib/model/download_response_dto.dart
 lib/model/download_response_dto.dart
@@ -291,6 +288,7 @@ lib/model/system_config_reverse_geocoding_dto.dart
 lib/model/system_config_storage_template_dto.dart
 lib/model/system_config_storage_template_dto.dart
 lib/model/system_config_template_storage_option_dto.dart
 lib/model/system_config_template_storage_option_dto.dart
 lib/model/system_config_thumbnail_dto.dart
 lib/model/system_config_thumbnail_dto.dart
+lib/model/system_config_trash_dto.dart
 lib/model/tag_response_dto.dart
 lib/model/tag_response_dto.dart
 lib/model/tag_type_enum.dart
 lib/model/tag_type_enum.dart
 lib/model/thumbnail_format.dart
 lib/model/thumbnail_format.dart
@@ -322,6 +320,7 @@ test/api_key_create_response_dto_test.dart
 test/api_key_response_dto_test.dart
 test/api_key_response_dto_test.dart
 test/api_key_update_dto_test.dart
 test/api_key_update_dto_test.dart
 test/asset_api_test.dart
 test/asset_api_test.dart
+test/asset_bulk_delete_dto_test.dart
 test/asset_bulk_update_dto_test.dart
 test/asset_bulk_update_dto_test.dart
 test/asset_bulk_upload_check_dto_test.dart
 test/asset_bulk_upload_check_dto_test.dart
 test/asset_bulk_upload_check_item_test.dart
 test/asset_bulk_upload_check_item_test.dart
@@ -360,9 +359,6 @@ test/create_tag_dto_test.dart
 test/create_user_dto_test.dart
 test/create_user_dto_test.dart
 test/curated_locations_response_dto_test.dart
 test/curated_locations_response_dto_test.dart
 test/curated_objects_response_dto_test.dart
 test/curated_objects_response_dto_test.dart
-test/delete_asset_dto_test.dart
-test/delete_asset_response_dto_test.dart
-test/delete_asset_status_test.dart
 test/download_archive_info_test.dart
 test/download_archive_info_test.dart
 test/download_info_dto_test.dart
 test/download_info_dto_test.dart
 test/download_response_dto_test.dart
 test/download_response_dto_test.dart
@@ -438,6 +434,7 @@ test/system_config_reverse_geocoding_dto_test.dart
 test/system_config_storage_template_dto_test.dart
 test/system_config_storage_template_dto_test.dart
 test/system_config_template_storage_option_dto_test.dart
 test/system_config_template_storage_option_dto_test.dart
 test/system_config_thumbnail_dto_test.dart
 test/system_config_thumbnail_dto_test.dart
+test/system_config_trash_dto_test.dart
 test/tag_api_test.dart
 test/tag_api_test.dart
 test/tag_response_dto_test.dart
 test/tag_response_dto_test.dart
 test/tag_type_enum_test.dart
 test/tag_type_enum_test.dart

+ 6 - 4
mobile/openapi/README.md

@@ -90,9 +90,10 @@ Class | Method | HTTP request | Description
 *AssetApi* | [**bulkUploadCheck**](doc//AssetApi.md#bulkuploadcheck) | **POST** /asset/bulk-upload-check | 
 *AssetApi* | [**bulkUploadCheck**](doc//AssetApi.md#bulkuploadcheck) | **POST** /asset/bulk-upload-check | 
 *AssetApi* | [**checkDuplicateAsset**](doc//AssetApi.md#checkduplicateasset) | **POST** /asset/check | 
 *AssetApi* | [**checkDuplicateAsset**](doc//AssetApi.md#checkduplicateasset) | **POST** /asset/check | 
 *AssetApi* | [**checkExistingAssets**](doc//AssetApi.md#checkexistingassets) | **POST** /asset/exist | 
 *AssetApi* | [**checkExistingAssets**](doc//AssetApi.md#checkexistingassets) | **POST** /asset/exist | 
-*AssetApi* | [**deleteAsset**](doc//AssetApi.md#deleteasset) | **DELETE** /asset | 
+*AssetApi* | [**deleteAssets**](doc//AssetApi.md#deleteassets) | **DELETE** /asset | 
 *AssetApi* | [**downloadArchive**](doc//AssetApi.md#downloadarchive) | **POST** /asset/download/archive | 
 *AssetApi* | [**downloadArchive**](doc//AssetApi.md#downloadarchive) | **POST** /asset/download/archive | 
 *AssetApi* | [**downloadFile**](doc//AssetApi.md#downloadfile) | **POST** /asset/download/{id} | 
 *AssetApi* | [**downloadFile**](doc//AssetApi.md#downloadfile) | **POST** /asset/download/{id} | 
+*AssetApi* | [**emptyTrash**](doc//AssetApi.md#emptytrash) | **POST** /asset/trash/empty | 
 *AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset | 
 *AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset | 
 *AssetApi* | [**getAssetById**](doc//AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | 
 *AssetApi* | [**getAssetById**](doc//AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | 
 *AssetApi* | [**getAssetSearchTerms**](doc//AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms | 
 *AssetApi* | [**getAssetSearchTerms**](doc//AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms | 
@@ -108,6 +109,8 @@ Class | Method | HTTP request | Description
 *AssetApi* | [**getTimeBuckets**](doc//AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | 
 *AssetApi* | [**getTimeBuckets**](doc//AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | 
 *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 *AssetApi* | [**importFile**](doc//AssetApi.md#importfile) | **POST** /asset/import | 
 *AssetApi* | [**importFile**](doc//AssetApi.md#importfile) | **POST** /asset/import | 
+*AssetApi* | [**restoreAssets**](doc//AssetApi.md#restoreassets) | **POST** /asset/restore | 
+*AssetApi* | [**restoreTrash**](doc//AssetApi.md#restoretrash) | **POST** /asset/trash/restore | 
 *AssetApi* | [**runAssetJobs**](doc//AssetApi.md#runassetjobs) | **POST** /asset/jobs | 
 *AssetApi* | [**runAssetJobs**](doc//AssetApi.md#runassetjobs) | **POST** /asset/jobs | 
 *AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search | 
 *AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search | 
 *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} | 
@@ -201,6 +204,7 @@ Class | Method | HTTP request | Description
  - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
  - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
  - [AlbumResponseDto](doc//AlbumResponseDto.md)
  - [AlbumResponseDto](doc//AlbumResponseDto.md)
  - [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
  - [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
+ - [AssetBulkDeleteDto](doc//AssetBulkDeleteDto.md)
  - [AssetBulkUpdateDto](doc//AssetBulkUpdateDto.md)
  - [AssetBulkUpdateDto](doc//AssetBulkUpdateDto.md)
  - [AssetBulkUploadCheckDto](doc//AssetBulkUploadCheckDto.md)
  - [AssetBulkUploadCheckDto](doc//AssetBulkUploadCheckDto.md)
  - [AssetBulkUploadCheckItem](doc//AssetBulkUploadCheckItem.md)
  - [AssetBulkUploadCheckItem](doc//AssetBulkUploadCheckItem.md)
@@ -237,9 +241,6 @@ Class | Method | HTTP request | Description
  - [CreateUserDto](doc//CreateUserDto.md)
  - [CreateUserDto](doc//CreateUserDto.md)
  - [CuratedLocationsResponseDto](doc//CuratedLocationsResponseDto.md)
  - [CuratedLocationsResponseDto](doc//CuratedLocationsResponseDto.md)
  - [CuratedObjectsResponseDto](doc//CuratedObjectsResponseDto.md)
  - [CuratedObjectsResponseDto](doc//CuratedObjectsResponseDto.md)
- - [DeleteAssetDto](doc//DeleteAssetDto.md)
- - [DeleteAssetResponseDto](doc//DeleteAssetResponseDto.md)
- - [DeleteAssetStatus](doc//DeleteAssetStatus.md)
  - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
  - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
  - [DownloadInfoDto](doc//DownloadInfoDto.md)
  - [DownloadInfoDto](doc//DownloadInfoDto.md)
  - [DownloadResponseDto](doc//DownloadResponseDto.md)
  - [DownloadResponseDto](doc//DownloadResponseDto.md)
@@ -306,6 +307,7 @@ Class | Method | HTTP request | Description
  - [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md)
  - [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md)
  - [SystemConfigTemplateStorageOptionDto](doc//SystemConfigTemplateStorageOptionDto.md)
  - [SystemConfigTemplateStorageOptionDto](doc//SystemConfigTemplateStorageOptionDto.md)
  - [SystemConfigThumbnailDto](doc//SystemConfigThumbnailDto.md)
  - [SystemConfigThumbnailDto](doc//SystemConfigThumbnailDto.md)
+ - [SystemConfigTrashDto](doc//SystemConfigTrashDto.md)
  - [TagResponseDto](doc//TagResponseDto.md)
  - [TagResponseDto](doc//TagResponseDto.md)
  - [TagTypeEnum](doc//TagTypeEnum.md)
  - [TagTypeEnum](doc//TagTypeEnum.md)
  - [ThumbnailFormat](doc//ThumbnailFormat.md)
  - [ThumbnailFormat](doc//ThumbnailFormat.md)

+ 178 - 16
mobile/openapi/doc/AssetApi.md

@@ -12,9 +12,10 @@ Method | HTTP request | Description
 [**bulkUploadCheck**](AssetApi.md#bulkuploadcheck) | **POST** /asset/bulk-upload-check | 
 [**bulkUploadCheck**](AssetApi.md#bulkuploadcheck) | **POST** /asset/bulk-upload-check | 
 [**checkDuplicateAsset**](AssetApi.md#checkduplicateasset) | **POST** /asset/check | 
 [**checkDuplicateAsset**](AssetApi.md#checkduplicateasset) | **POST** /asset/check | 
 [**checkExistingAssets**](AssetApi.md#checkexistingassets) | **POST** /asset/exist | 
 [**checkExistingAssets**](AssetApi.md#checkexistingassets) | **POST** /asset/exist | 
-[**deleteAsset**](AssetApi.md#deleteasset) | **DELETE** /asset | 
+[**deleteAssets**](AssetApi.md#deleteassets) | **DELETE** /asset | 
 [**downloadArchive**](AssetApi.md#downloadarchive) | **POST** /asset/download/archive | 
 [**downloadArchive**](AssetApi.md#downloadarchive) | **POST** /asset/download/archive | 
 [**downloadFile**](AssetApi.md#downloadfile) | **POST** /asset/download/{id} | 
 [**downloadFile**](AssetApi.md#downloadfile) | **POST** /asset/download/{id} | 
+[**emptyTrash**](AssetApi.md#emptytrash) | **POST** /asset/trash/empty | 
 [**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset | 
 [**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset | 
 [**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | 
 [**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | 
 [**getAssetSearchTerms**](AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms | 
 [**getAssetSearchTerms**](AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms | 
@@ -30,6 +31,8 @@ Method | HTTP request | Description
 [**getTimeBuckets**](AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | 
 [**getTimeBuckets**](AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | 
 [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 [**importFile**](AssetApi.md#importfile) | **POST** /asset/import | 
 [**importFile**](AssetApi.md#importfile) | **POST** /asset/import | 
+[**restoreAssets**](AssetApi.md#restoreassets) | **POST** /asset/restore | 
+[**restoreTrash**](AssetApi.md#restoretrash) | **POST** /asset/trash/restore | 
 [**runAssetJobs**](AssetApi.md#runassetjobs) | **POST** /asset/jobs | 
 [**runAssetJobs**](AssetApi.md#runassetjobs) | **POST** /asset/jobs | 
 [**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search | 
 [**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search | 
 [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} | 
@@ -211,8 +214,8 @@ Name | Type | Description  | Notes
 
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
-# **deleteAsset**
-> List<DeleteAssetResponseDto> deleteAsset(deleteAssetDto)
+# **deleteAssets**
+> deleteAssets(assetBulkDeleteDto)
 
 
 
 
 
 
@@ -235,13 +238,12 @@ import 'package:openapi/api.dart';
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 
 final api_instance = AssetApi();
 final api_instance = AssetApi();
-final deleteAssetDto = DeleteAssetDto(); // DeleteAssetDto | 
+final assetBulkDeleteDto = AssetBulkDeleteDto(); // AssetBulkDeleteDto | 
 
 
 try {
 try {
-    final result = api_instance.deleteAsset(deleteAssetDto);
-    print(result);
+    api_instance.deleteAssets(assetBulkDeleteDto);
 } catch (e) {
 } catch (e) {
-    print('Exception when calling AssetApi->deleteAsset: $e\n');
+    print('Exception when calling AssetApi->deleteAssets: $e\n');
 }
 }
 ```
 ```
 
 
@@ -249,11 +251,11 @@ try {
 
 
 Name | Type | Description  | Notes
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
 ------------- | ------------- | ------------- | -------------
- **deleteAssetDto** | [**DeleteAssetDto**](DeleteAssetDto.md)|  | 
+ **assetBulkDeleteDto** | [**AssetBulkDeleteDto**](AssetBulkDeleteDto.md)|  | 
 
 
 ### Return type
 ### Return type
 
 
-[**List<DeleteAssetResponseDto>**](DeleteAssetResponseDto.md)
+void (empty response body)
 
 
 ### Authorization
 ### Authorization
 
 
@@ -262,7 +264,7 @@ Name | Type | Description  | Notes
 ### HTTP request headers
 ### HTTP request headers
 
 
  - **Content-Type**: application/json
  - **Content-Type**: application/json
- - **Accept**: application/json
+ - **Accept**: Not defined
 
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
@@ -380,6 +382,56 @@ Name | Type | Description  | Notes
 
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
+# **emptyTrash**
+> emptyTrash()
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = AssetApi();
+
+try {
+    api_instance.emptyTrash();
+} catch (e) {
+    print('Exception when calling AssetApi->emptyTrash: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+void (empty response body)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: Not defined
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **getAllAssets**
 # **getAllAssets**
 > List<AssetResponseDto> getAllAssets(userId, isFavorite, isArchived, skip, updatedAfter, ifNoneMatch)
 > List<AssetResponseDto> getAllAssets(userId, isFavorite, isArchived, skip, updatedAfter, ifNoneMatch)
 
 
@@ -558,7 +610,7 @@ This endpoint does not need any parameter.
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
 # **getAssetStats**
 # **getAssetStats**
-> AssetStatsResponseDto getAssetStats(isArchived, isFavorite)
+> AssetStatsResponseDto getAssetStats(isArchived, isFavorite, isTrashed)
 
 
 
 
 
 
@@ -583,9 +635,10 @@ import 'package:openapi/api.dart';
 final api_instance = AssetApi();
 final api_instance = AssetApi();
 final isArchived = true; // bool | 
 final isArchived = true; // bool | 
 final isFavorite = true; // bool | 
 final isFavorite = true; // bool | 
+final isTrashed = true; // bool | 
 
 
 try {
 try {
-    final result = api_instance.getAssetStats(isArchived, isFavorite);
+    final result = api_instance.getAssetStats(isArchived, isFavorite, isTrashed);
     print(result);
     print(result);
 } catch (e) {
 } catch (e) {
     print('Exception when calling AssetApi->getAssetStats: $e\n');
     print('Exception when calling AssetApi->getAssetStats: $e\n');
@@ -598,6 +651,7 @@ Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
 ------------- | ------------- | ------------- | -------------
  **isArchived** | **bool**|  | [optional] 
  **isArchived** | **bool**|  | [optional] 
  **isFavorite** | **bool**|  | [optional] 
  **isFavorite** | **bool**|  | [optional] 
+ **isTrashed** | **bool**|  | [optional] 
 
 
 ### Return type
 ### Return type
 
 
@@ -674,7 +728,7 @@ Name | Type | Description  | Notes
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
 # **getByTimeBucket**
 # **getByTimeBucket**
-> List<AssetResponseDto> getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, key)
+> List<AssetResponseDto> getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, key)
 
 
 
 
 
 
@@ -704,10 +758,11 @@ final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
 final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final isArchived = true; // bool | 
 final isArchived = true; // bool | 
 final isFavorite = true; // bool | 
 final isFavorite = true; // bool | 
+final isTrashed = true; // bool | 
 final key = key_example; // String | 
 final key = key_example; // String | 
 
 
 try {
 try {
-    final result = api_instance.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, key);
+    final result = api_instance.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, key);
     print(result);
     print(result);
 } catch (e) {
 } catch (e) {
     print('Exception when calling AssetApi->getByTimeBucket: $e\n');
     print('Exception when calling AssetApi->getByTimeBucket: $e\n');
@@ -725,6 +780,7 @@ Name | Type | Description  | Notes
  **personId** | **String**|  | [optional] 
  **personId** | **String**|  | [optional] 
  **isArchived** | **bool**|  | [optional] 
  **isArchived** | **bool**|  | [optional] 
  **isFavorite** | **bool**|  | [optional] 
  **isFavorite** | **bool**|  | [optional] 
+ **isTrashed** | **bool**|  | [optional] 
  **key** | **String**|  | [optional] 
  **key** | **String**|  | [optional] 
 
 
 ### Return type
 ### Return type
@@ -1075,7 +1131,7 @@ Name | Type | Description  | Notes
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
 # **getTimeBuckets**
 # **getTimeBuckets**
-> List<TimeBucketResponseDto> getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, key)
+> List<TimeBucketResponseDto> getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, key)
 
 
 
 
 
 
@@ -1104,10 +1160,11 @@ final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
 final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final isArchived = true; // bool | 
 final isArchived = true; // bool | 
 final isFavorite = true; // bool | 
 final isFavorite = true; // bool | 
+final isTrashed = true; // bool | 
 final key = key_example; // String | 
 final key = key_example; // String | 
 
 
 try {
 try {
-    final result = api_instance.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, key);
+    final result = api_instance.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, key);
     print(result);
     print(result);
 } catch (e) {
 } catch (e) {
     print('Exception when calling AssetApi->getTimeBuckets: $e\n');
     print('Exception when calling AssetApi->getTimeBuckets: $e\n');
@@ -1124,6 +1181,7 @@ Name | Type | Description  | Notes
  **personId** | **String**|  | [optional] 
  **personId** | **String**|  | [optional] 
  **isArchived** | **bool**|  | [optional] 
  **isArchived** | **bool**|  | [optional] 
  **isFavorite** | **bool**|  | [optional] 
  **isFavorite** | **bool**|  | [optional] 
+ **isTrashed** | **bool**|  | [optional] 
  **key** | **String**|  | [optional] 
  **key** | **String**|  | [optional] 
 
 
 ### Return type
 ### Return type
@@ -1253,6 +1311,110 @@ Name | Type | Description  | Notes
 
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
+# **restoreAssets**
+> restoreAssets(bulkIdsDto)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = AssetApi();
+final bulkIdsDto = BulkIdsDto(); // BulkIdsDto | 
+
+try {
+    api_instance.restoreAssets(bulkIdsDto);
+} catch (e) {
+    print('Exception when calling AssetApi->restoreAssets: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **bulkIdsDto** | [**BulkIdsDto**](BulkIdsDto.md)|  | 
+
+### Return type
+
+void (empty response body)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **Accept**: Not defined
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
+# **restoreTrash**
+> restoreTrash()
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = AssetApi();
+
+try {
+    api_instance.restoreTrash();
+} catch (e) {
+    print('Exception when calling AssetApi->restoreTrash: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+void (empty response body)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: Not defined
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **runAssetJobs**
 # **runAssetJobs**
 > runAssetJobs(assetJobsDto)
 > runAssetJobs(assetJobsDto)
 
 

+ 2 - 1
mobile/openapi/doc/DeleteAssetDto.md → mobile/openapi/doc/AssetBulkDeleteDto.md

@@ -1,4 +1,4 @@
-# openapi.model.DeleteAssetDto
+# openapi.model.AssetBulkDeleteDto
 
 
 ## Load the model package
 ## Load the model package
 ```dart
 ```dart
@@ -8,6 +8,7 @@ import 'package:openapi/api.dart';
 ## Properties
 ## Properties
 Name | Type | Description | Notes
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 ------------ | ------------- | ------------- | -------------
+**force** | **bool** |  | [optional] 
 **ids** | **List<String>** |  | [default to const []]
 **ids** | **List<String>** |  | [default to const []]
 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

+ 1 - 0
mobile/openapi/doc/AssetResponseDto.md

@@ -21,6 +21,7 @@ Name | Type | Description | Notes
 **isFavorite** | **bool** |  | 
 **isFavorite** | **bool** |  | 
 **isOffline** | **bool** |  | 
 **isOffline** | **bool** |  | 
 **isReadOnly** | **bool** |  | 
 **isReadOnly** | **bool** |  | 
+**isTrashed** | **bool** |  | 
 **libraryId** | **String** |  | 
 **libraryId** | **String** |  | 
 **livePhotoVideoId** | **String** |  | [optional] 
 **livePhotoVideoId** | **String** |  | [optional] 
 **localDateTime** | [**DateTime**](DateTime.md) |  | 
 **localDateTime** | [**DateTime**](DateTime.md) |  | 

+ 0 - 16
mobile/openapi/doc/DeleteAssetResponseDto.md

@@ -1,16 +0,0 @@
-# openapi.model.DeleteAssetResponseDto
-
-## Load the model package
-```dart
-import 'package:openapi/api.dart';
-```
-
-## Properties
-Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
-**id** | **String** |  | 
-**status** | [**DeleteAssetStatus**](DeleteAssetStatus.md) |  | 
-
-[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
-
-

+ 1 - 0
mobile/openapi/doc/ServerConfigDto.md

@@ -11,6 +11,7 @@ Name | Type | Description | Notes
 **loginPageMessage** | **String** |  | 
 **loginPageMessage** | **String** |  | 
 **mapTileUrl** | **String** |  | 
 **mapTileUrl** | **String** |  | 
 **oauthButtonText** | **String** |  | 
 **oauthButtonText** | **String** |  | 
+**trashDays** | **int** |  | 
 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 
 

+ 1 - 0
mobile/openapi/doc/ServerFeaturesDto.md

@@ -19,6 +19,7 @@ Name | Type | Description | Notes
 **search** | **bool** |  | 
 **search** | **bool** |  | 
 **sidecar** | **bool** |  | 
 **sidecar** | **bool** |  | 
 **tagImage** | **bool** |  | 
 **tagImage** | **bool** |  | 
+**trash** | **bool** |  | 
 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 
 

+ 1 - 0
mobile/openapi/doc/SystemConfigDto.md

@@ -17,6 +17,7 @@ Name | Type | Description | Notes
 **reverseGeocoding** | [**SystemConfigReverseGeocodingDto**](SystemConfigReverseGeocodingDto.md) |  | 
 **reverseGeocoding** | [**SystemConfigReverseGeocodingDto**](SystemConfigReverseGeocodingDto.md) |  | 
 **storageTemplate** | [**SystemConfigStorageTemplateDto**](SystemConfigStorageTemplateDto.md) |  | 
 **storageTemplate** | [**SystemConfigStorageTemplateDto**](SystemConfigStorageTemplateDto.md) |  | 
 **thumbnail** | [**SystemConfigThumbnailDto**](SystemConfigThumbnailDto.md) |  | 
 **thumbnail** | [**SystemConfigThumbnailDto**](SystemConfigThumbnailDto.md) |  | 
+**trash** | [**SystemConfigTrashDto**](SystemConfigTrashDto.md) |  | 
 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 
 

+ 3 - 1
mobile/openapi/doc/DeleteAssetStatus.md → mobile/openapi/doc/SystemConfigTrashDto.md

@@ -1,4 +1,4 @@
-# openapi.model.DeleteAssetStatus
+# openapi.model.SystemConfigTrashDto
 
 
 ## Load the model package
 ## Load the model package
 ```dart
 ```dart
@@ -8,6 +8,8 @@ import 'package:openapi/api.dart';
 ## Properties
 ## Properties
 Name | Type | Description | Notes
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 ------------ | ------------- | ------------- | -------------
+**days** | **int** |  | 
+**enabled** | **bool** |  | 
 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 
 

+ 2 - 3
mobile/openapi/lib/api.dart

@@ -54,6 +54,7 @@ part 'model/admin_signup_response_dto.dart';
 part 'model/album_count_response_dto.dart';
 part 'model/album_count_response_dto.dart';
 part 'model/album_response_dto.dart';
 part 'model/album_response_dto.dart';
 part 'model/all_job_status_response_dto.dart';
 part 'model/all_job_status_response_dto.dart';
+part 'model/asset_bulk_delete_dto.dart';
 part 'model/asset_bulk_update_dto.dart';
 part 'model/asset_bulk_update_dto.dart';
 part 'model/asset_bulk_upload_check_dto.dart';
 part 'model/asset_bulk_upload_check_dto.dart';
 part 'model/asset_bulk_upload_check_item.dart';
 part 'model/asset_bulk_upload_check_item.dart';
@@ -90,9 +91,6 @@ part 'model/create_tag_dto.dart';
 part 'model/create_user_dto.dart';
 part 'model/create_user_dto.dart';
 part 'model/curated_locations_response_dto.dart';
 part 'model/curated_locations_response_dto.dart';
 part 'model/curated_objects_response_dto.dart';
 part 'model/curated_objects_response_dto.dart';
-part 'model/delete_asset_dto.dart';
-part 'model/delete_asset_response_dto.dart';
-part 'model/delete_asset_status.dart';
 part 'model/download_archive_info.dart';
 part 'model/download_archive_info.dart';
 part 'model/download_info_dto.dart';
 part 'model/download_info_dto.dart';
 part 'model/download_response_dto.dart';
 part 'model/download_response_dto.dart';
@@ -159,6 +157,7 @@ part 'model/system_config_reverse_geocoding_dto.dart';
 part 'model/system_config_storage_template_dto.dart';
 part 'model/system_config_storage_template_dto.dart';
 part 'model/system_config_template_storage_option_dto.dart';
 part 'model/system_config_template_storage_option_dto.dart';
 part 'model/system_config_thumbnail_dto.dart';
 part 'model/system_config_thumbnail_dto.dart';
+part 'model/system_config_trash_dto.dart';
 part 'model/tag_response_dto.dart';
 part 'model/tag_response_dto.dart';
 part 'model/tag_type_enum.dart';
 part 'model/tag_type_enum.dart';
 part 'model/thumbnail_format.dart';
 part 'model/thumbnail_format.dart';

+ 141 - 26
mobile/openapi/lib/api/asset_api.dart

@@ -183,13 +183,13 @@ class AssetApi {
   /// Performs an HTTP 'DELETE /asset' operation and returns the [Response].
   /// Performs an HTTP 'DELETE /asset' operation and returns the [Response].
   /// Parameters:
   /// Parameters:
   ///
   ///
-  /// * [DeleteAssetDto] deleteAssetDto (required):
-  Future<Response> deleteAssetWithHttpInfo(DeleteAssetDto deleteAssetDto,) async {
+  /// * [AssetBulkDeleteDto] assetBulkDeleteDto (required):
+  Future<Response> deleteAssetsWithHttpInfo(AssetBulkDeleteDto assetBulkDeleteDto,) async {
     // ignore: prefer_const_declarations
     // ignore: prefer_const_declarations
     final path = r'/asset';
     final path = r'/asset';
 
 
     // ignore: prefer_final_locals
     // ignore: prefer_final_locals
-    Object? postBody = deleteAssetDto;
+    Object? postBody = assetBulkDeleteDto;
 
 
     final queryParams = <QueryParam>[];
     final queryParams = <QueryParam>[];
     final headerParams = <String, String>{};
     final headerParams = <String, String>{};
@@ -211,23 +211,12 @@ class AssetApi {
 
 
   /// Parameters:
   /// Parameters:
   ///
   ///
-  /// * [DeleteAssetDto] deleteAssetDto (required):
-  Future<List<DeleteAssetResponseDto>?> deleteAsset(DeleteAssetDto deleteAssetDto,) async {
-    final response = await deleteAssetWithHttpInfo(deleteAssetDto,);
+  /// * [AssetBulkDeleteDto] assetBulkDeleteDto (required):
+  Future<void> deleteAssets(AssetBulkDeleteDto assetBulkDeleteDto,) async {
+    final response = await deleteAssetsWithHttpInfo(assetBulkDeleteDto,);
     if (response.statusCode >= HttpStatus.badRequest) {
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
       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<DeleteAssetResponseDto>') as List)
-        .cast<DeleteAssetResponseDto>()
-        .toList();
-
-    }
-    return null;
   }
   }
 
 
   /// Performs an HTTP 'POST /asset/download/archive' operation and returns the [Response].
   /// Performs an HTTP 'POST /asset/download/archive' operation and returns the [Response].
@@ -341,6 +330,39 @@ class AssetApi {
     return null;
     return null;
   }
   }
 
 
+  /// Performs an HTTP 'POST /asset/trash/empty' operation and returns the [Response].
+  Future<Response> emptyTrashWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/asset/trash/empty';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'POST',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<void> emptyTrash() async {
+    final response = await emptyTrashWithHttpInfo();
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+
   /// Get all AssetEntity belong to the user
   /// Get all AssetEntity belong to the user
   ///
   ///
   /// Note: This method returns the HTTP [Response].
   /// Note: This method returns the HTTP [Response].
@@ -549,7 +571,9 @@ class AssetApi {
   /// * [bool] isArchived:
   /// * [bool] isArchived:
   ///
   ///
   /// * [bool] isFavorite:
   /// * [bool] isFavorite:
-  Future<Response> getAssetStatsWithHttpInfo({ bool? isArchived, bool? isFavorite, }) async {
+  ///
+  /// * [bool] isTrashed:
+  Future<Response> getAssetStatsWithHttpInfo({ bool? isArchived, bool? isFavorite, bool? isTrashed, }) async {
     // ignore: prefer_const_declarations
     // ignore: prefer_const_declarations
     final path = r'/asset/statistics';
     final path = r'/asset/statistics';
 
 
@@ -566,6 +590,9 @@ class AssetApi {
     if (isFavorite != null) {
     if (isFavorite != null) {
       queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
       queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
     }
     }
+    if (isTrashed != null) {
+      queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
+    }
 
 
     const contentTypes = <String>[];
     const contentTypes = <String>[];
 
 
@@ -586,8 +613,10 @@ class AssetApi {
   /// * [bool] isArchived:
   /// * [bool] isArchived:
   ///
   ///
   /// * [bool] isFavorite:
   /// * [bool] isFavorite:
-  Future<AssetStatsResponseDto?> getAssetStats({ bool? isArchived, bool? isFavorite, }) async {
-    final response = await getAssetStatsWithHttpInfo( isArchived: isArchived, isFavorite: isFavorite, );
+  ///
+  /// * [bool] isTrashed:
+  Future<AssetStatsResponseDto?> getAssetStats({ bool? isArchived, bool? isFavorite, bool? isTrashed, }) async {
+    final response = await getAssetStatsWithHttpInfo( isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, );
     if (response.statusCode >= HttpStatus.badRequest) {
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
     }
@@ -681,8 +710,10 @@ class AssetApi {
   ///
   ///
   /// * [bool] isFavorite:
   /// * [bool] isFavorite:
   ///
   ///
+  /// * [bool] isTrashed:
+  ///
   /// * [String] key:
   /// * [String] key:
-  Future<Response> getByTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, String? key, }) async {
+  Future<Response> getByTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, }) async {
     // ignore: prefer_const_declarations
     // ignore: prefer_const_declarations
     final path = r'/asset/time-bucket';
     final path = r'/asset/time-bucket';
 
 
@@ -708,6 +739,9 @@ class AssetApi {
     }
     }
     if (isFavorite != null) {
     if (isFavorite != null) {
       queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
       queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
+    }
+    if (isTrashed != null) {
+      queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
     }
     }
       queryParams.addAll(_queryParams('', 'timeBucket', timeBucket));
       queryParams.addAll(_queryParams('', 'timeBucket', timeBucket));
     if (key != null) {
     if (key != null) {
@@ -744,9 +778,11 @@ class AssetApi {
   ///
   ///
   /// * [bool] isFavorite:
   /// * [bool] isFavorite:
   ///
   ///
+  /// * [bool] isTrashed:
+  ///
   /// * [String] key:
   /// * [String] key:
-  Future<List<AssetResponseDto>?> getByTimeBucket(TimeBucketSize size, String timeBucket, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, String? key, }) async {
-    final response = await getByTimeBucketWithHttpInfo(size, timeBucket,  userId: userId, albumId: albumId, personId: personId, isArchived: isArchived, isFavorite: isFavorite, key: key, );
+  Future<List<AssetResponseDto>?> getByTimeBucket(TimeBucketSize size, String timeBucket, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, }) async {
+    final response = await getByTimeBucketWithHttpInfo(size, timeBucket,  userId: userId, albumId: albumId, personId: personId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, );
     if (response.statusCode >= HttpStatus.badRequest) {
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
     }
@@ -1107,8 +1143,10 @@ class AssetApi {
   ///
   ///
   /// * [bool] isFavorite:
   /// * [bool] isFavorite:
   ///
   ///
+  /// * [bool] isTrashed:
+  ///
   /// * [String] key:
   /// * [String] key:
-  Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, String? key, }) async {
+  Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, }) async {
     // ignore: prefer_const_declarations
     // ignore: prefer_const_declarations
     final path = r'/asset/time-buckets';
     final path = r'/asset/time-buckets';
 
 
@@ -1135,6 +1173,9 @@ class AssetApi {
     if (isFavorite != null) {
     if (isFavorite != null) {
       queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
       queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
     }
     }
+    if (isTrashed != null) {
+      queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
+    }
     if (key != null) {
     if (key != null) {
       queryParams.addAll(_queryParams('', 'key', key));
       queryParams.addAll(_queryParams('', 'key', key));
     }
     }
@@ -1167,9 +1208,11 @@ class AssetApi {
   ///
   ///
   /// * [bool] isFavorite:
   /// * [bool] isFavorite:
   ///
   ///
+  /// * [bool] isTrashed:
+  ///
   /// * [String] key:
   /// * [String] key:
-  Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, String? key, }) async {
-    final response = await getTimeBucketsWithHttpInfo(size,  userId: userId, albumId: albumId, personId: personId, isArchived: isArchived, isFavorite: isFavorite, key: key, );
+  Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, }) async {
+    final response = await getTimeBucketsWithHttpInfo(size,  userId: userId, albumId: albumId, personId: personId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, );
     if (response.statusCode >= HttpStatus.badRequest) {
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
     }
@@ -1289,6 +1332,78 @@ class AssetApi {
     return null;
     return null;
   }
   }
 
 
+  /// Performs an HTTP 'POST /asset/restore' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [BulkIdsDto] bulkIdsDto (required):
+  Future<Response> restoreAssetsWithHttpInfo(BulkIdsDto bulkIdsDto,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/asset/restore';
+
+    // ignore: prefer_final_locals
+    Object? postBody = bulkIdsDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'POST',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [BulkIdsDto] bulkIdsDto (required):
+  Future<void> restoreAssets(BulkIdsDto bulkIdsDto,) async {
+    final response = await restoreAssetsWithHttpInfo(bulkIdsDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+
+  /// Performs an HTTP 'POST /asset/trash/restore' operation and returns the [Response].
+  Future<Response> restoreTrashWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/asset/trash/restore';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'POST',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<void> restoreTrash() async {
+    final response = await restoreTrashWithHttpInfo();
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+
   /// Performs an HTTP 'POST /asset/jobs' operation and returns the [Response].
   /// Performs an HTTP 'POST /asset/jobs' operation and returns the [Response].
   /// Parameters:
   /// Parameters:
   ///
   ///

+ 4 - 6
mobile/openapi/lib/api_client.dart

@@ -199,6 +199,8 @@ class ApiClient {
           return AlbumResponseDto.fromJson(value);
           return AlbumResponseDto.fromJson(value);
         case 'AllJobStatusResponseDto':
         case 'AllJobStatusResponseDto':
           return AllJobStatusResponseDto.fromJson(value);
           return AllJobStatusResponseDto.fromJson(value);
+        case 'AssetBulkDeleteDto':
+          return AssetBulkDeleteDto.fromJson(value);
         case 'AssetBulkUpdateDto':
         case 'AssetBulkUpdateDto':
           return AssetBulkUpdateDto.fromJson(value);
           return AssetBulkUpdateDto.fromJson(value);
         case 'AssetBulkUploadCheckDto':
         case 'AssetBulkUploadCheckDto':
@@ -271,12 +273,6 @@ class ApiClient {
           return CuratedLocationsResponseDto.fromJson(value);
           return CuratedLocationsResponseDto.fromJson(value);
         case 'CuratedObjectsResponseDto':
         case 'CuratedObjectsResponseDto':
           return CuratedObjectsResponseDto.fromJson(value);
           return CuratedObjectsResponseDto.fromJson(value);
-        case 'DeleteAssetDto':
-          return DeleteAssetDto.fromJson(value);
-        case 'DeleteAssetResponseDto':
-          return DeleteAssetResponseDto.fromJson(value);
-        case 'DeleteAssetStatus':
-          return DeleteAssetStatusTypeTransformer().decode(value);
         case 'DownloadArchiveInfo':
         case 'DownloadArchiveInfo':
           return DownloadArchiveInfo.fromJson(value);
           return DownloadArchiveInfo.fromJson(value);
         case 'DownloadInfoDto':
         case 'DownloadInfoDto':
@@ -409,6 +405,8 @@ class ApiClient {
           return SystemConfigTemplateStorageOptionDto.fromJson(value);
           return SystemConfigTemplateStorageOptionDto.fromJson(value);
         case 'SystemConfigThumbnailDto':
         case 'SystemConfigThumbnailDto':
           return SystemConfigThumbnailDto.fromJson(value);
           return SystemConfigThumbnailDto.fromJson(value);
+        case 'SystemConfigTrashDto':
+          return SystemConfigTrashDto.fromJson(value);
         case 'TagResponseDto':
         case 'TagResponseDto':
           return TagResponseDto.fromJson(value);
           return TagResponseDto.fromJson(value);
         case 'TagTypeEnum':
         case 'TagTypeEnum':

+ 0 - 3
mobile/openapi/lib/api_helper.dart

@@ -76,9 +76,6 @@ String parameterToString(dynamic value) {
   if (value is Colorspace) {
   if (value is Colorspace) {
     return ColorspaceTypeTransformer().encode(value).toString();
     return ColorspaceTypeTransformer().encode(value).toString();
   }
   }
-  if (value is DeleteAssetStatus) {
-    return DeleteAssetStatusTypeTransformer().encode(value).toString();
-  }
   if (value is EntityType) {
   if (value is EntityType) {
     return EntityTypeTypeTransformer().encode(value).toString();
     return EntityTypeTypeTransformer().encode(value).toString();
   }
   }

+ 35 - 18
mobile/openapi/lib/model/delete_asset_dto.dart → mobile/openapi/lib/model/asset_bulk_delete_dto.dart

@@ -10,40 +10,57 @@
 
 
 part of openapi.api;
 part of openapi.api;
 
 
-class DeleteAssetDto {
-  /// Returns a new [DeleteAssetDto] instance.
-  DeleteAssetDto({
+class AssetBulkDeleteDto {
+  /// Returns a new [AssetBulkDeleteDto] instance.
+  AssetBulkDeleteDto({
+    this.force,
     this.ids = const [],
     this.ids = const [],
   });
   });
 
 
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  bool? force;
+
   List<String> ids;
   List<String> ids;
 
 
   @override
   @override
-  bool operator ==(Object other) => identical(this, other) || other is DeleteAssetDto &&
+  bool operator ==(Object other) => identical(this, other) || other is AssetBulkDeleteDto &&
+     other.force == force &&
      other.ids == ids;
      other.ids == ids;
 
 
   @override
   @override
   int get hashCode =>
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     // ignore: unnecessary_parenthesis
+    (force == null ? 0 : force!.hashCode) +
     (ids.hashCode);
     (ids.hashCode);
 
 
   @override
   @override
-  String toString() => 'DeleteAssetDto[ids=$ids]';
+  String toString() => 'AssetBulkDeleteDto[force=$force, ids=$ids]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
+    if (this.force != null) {
+      json[r'force'] = this.force;
+    } else {
+    //  json[r'force'] = null;
+    }
       json[r'ids'] = this.ids;
       json[r'ids'] = this.ids;
     return json;
     return json;
   }
   }
 
 
-  /// Returns a new [DeleteAssetDto] instance and imports its values from
+  /// Returns a new [AssetBulkDeleteDto] instance and imports its values from
   /// [value] if it's a [Map], null otherwise.
   /// [value] if it's a [Map], null otherwise.
   // ignore: prefer_constructors_over_static_methods
   // ignore: prefer_constructors_over_static_methods
-  static DeleteAssetDto? fromJson(dynamic value) {
+  static AssetBulkDeleteDto? fromJson(dynamic value) {
     if (value is Map) {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
       final json = value.cast<String, dynamic>();
 
 
-      return DeleteAssetDto(
+      return AssetBulkDeleteDto(
+        force: mapValueOfType<bool>(json, r'force'),
         ids: json[r'ids'] is List
         ids: json[r'ids'] is List
             ? (json[r'ids'] as List).cast<String>()
             ? (json[r'ids'] as List).cast<String>()
             : const [],
             : const [],
@@ -52,11 +69,11 @@ class DeleteAssetDto {
     return null;
     return null;
   }
   }
 
 
-  static List<DeleteAssetDto> listFromJson(dynamic json, {bool growable = false,}) {
-    final result = <DeleteAssetDto>[];
+  static List<AssetBulkDeleteDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <AssetBulkDeleteDto>[];
     if (json is List && json.isNotEmpty) {
     if (json is List && json.isNotEmpty) {
       for (final row in json) {
       for (final row in json) {
-        final value = DeleteAssetDto.fromJson(row);
+        final value = AssetBulkDeleteDto.fromJson(row);
         if (value != null) {
         if (value != null) {
           result.add(value);
           result.add(value);
         }
         }
@@ -65,12 +82,12 @@ class DeleteAssetDto {
     return result.toList(growable: growable);
     return result.toList(growable: growable);
   }
   }
 
 
-  static Map<String, DeleteAssetDto> mapFromJson(dynamic json) {
-    final map = <String, DeleteAssetDto>{};
+  static Map<String, AssetBulkDeleteDto> mapFromJson(dynamic json) {
+    final map = <String, AssetBulkDeleteDto>{};
     if (json is Map && json.isNotEmpty) {
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       for (final entry in json.entries) {
       for (final entry in json.entries) {
-        final value = DeleteAssetDto.fromJson(entry.value);
+        final value = AssetBulkDeleteDto.fromJson(entry.value);
         if (value != null) {
         if (value != null) {
           map[entry.key] = value;
           map[entry.key] = value;
         }
         }
@@ -79,14 +96,14 @@ class DeleteAssetDto {
     return map;
     return map;
   }
   }
 
 
-  // maps a json object with a list of DeleteAssetDto-objects as value to a dart map
-  static Map<String, List<DeleteAssetDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
-    final map = <String, List<DeleteAssetDto>>{};
+  // maps a json object with a list of AssetBulkDeleteDto-objects as value to a dart map
+  static Map<String, List<AssetBulkDeleteDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<AssetBulkDeleteDto>>{};
     if (json is Map && json.isNotEmpty) {
     if (json is Map && json.isNotEmpty) {
       // ignore: parameter_assignments
       // ignore: parameter_assignments
       json = json.cast<String, dynamic>();
       json = json.cast<String, dynamic>();
       for (final entry in json.entries) {
       for (final entry in json.entries) {
-        map[entry.key] = DeleteAssetDto.listFromJson(entry.value, growable: growable,);
+        map[entry.key] = AssetBulkDeleteDto.listFromJson(entry.value, growable: growable,);
       }
       }
     }
     }
     return map;
     return map;

+ 9 - 1
mobile/openapi/lib/model/asset_response_dto.dart

@@ -26,6 +26,7 @@ class AssetResponseDto {
     required this.isFavorite,
     required this.isFavorite,
     required this.isOffline,
     required this.isOffline,
     required this.isReadOnly,
     required this.isReadOnly,
+    required this.isTrashed,
     required this.libraryId,
     required this.libraryId,
     this.livePhotoVideoId,
     this.livePhotoVideoId,
     required this.localDateTime,
     required this.localDateTime,
@@ -75,6 +76,8 @@ class AssetResponseDto {
 
 
   bool isReadOnly;
   bool isReadOnly;
 
 
+  bool isTrashed;
+
   String libraryId;
   String libraryId;
 
 
   String? livePhotoVideoId;
   String? livePhotoVideoId;
@@ -131,6 +134,7 @@ class AssetResponseDto {
      other.isFavorite == isFavorite &&
      other.isFavorite == isFavorite &&
      other.isOffline == isOffline &&
      other.isOffline == isOffline &&
      other.isReadOnly == isReadOnly &&
      other.isReadOnly == isReadOnly &&
+     other.isTrashed == isTrashed &&
      other.libraryId == libraryId &&
      other.libraryId == libraryId &&
      other.livePhotoVideoId == livePhotoVideoId &&
      other.livePhotoVideoId == livePhotoVideoId &&
      other.localDateTime == localDateTime &&
      other.localDateTime == localDateTime &&
@@ -162,6 +166,7 @@ class AssetResponseDto {
     (isFavorite.hashCode) +
     (isFavorite.hashCode) +
     (isOffline.hashCode) +
     (isOffline.hashCode) +
     (isReadOnly.hashCode) +
     (isReadOnly.hashCode) +
+    (isTrashed.hashCode) +
     (libraryId.hashCode) +
     (libraryId.hashCode) +
     (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
     (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
     (localDateTime.hashCode) +
     (localDateTime.hashCode) +
@@ -178,7 +183,7 @@ class AssetResponseDto {
     (updatedAt.hashCode);
     (updatedAt.hashCode);
 
 
   @override
   @override
-  String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
+  String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
@@ -199,6 +204,7 @@ class AssetResponseDto {
       json[r'isFavorite'] = this.isFavorite;
       json[r'isFavorite'] = this.isFavorite;
       json[r'isOffline'] = this.isOffline;
       json[r'isOffline'] = this.isOffline;
       json[r'isReadOnly'] = this.isReadOnly;
       json[r'isReadOnly'] = this.isReadOnly;
+      json[r'isTrashed'] = this.isTrashed;
       json[r'libraryId'] = this.libraryId;
       json[r'libraryId'] = this.libraryId;
     if (this.livePhotoVideoId != null) {
     if (this.livePhotoVideoId != null) {
       json[r'livePhotoVideoId'] = this.livePhotoVideoId;
       json[r'livePhotoVideoId'] = this.livePhotoVideoId;
@@ -253,6 +259,7 @@ class AssetResponseDto {
         isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
         isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
         isOffline: mapValueOfType<bool>(json, r'isOffline')!,
         isOffline: mapValueOfType<bool>(json, r'isOffline')!,
         isReadOnly: mapValueOfType<bool>(json, r'isReadOnly')!,
         isReadOnly: mapValueOfType<bool>(json, r'isReadOnly')!,
+        isTrashed: mapValueOfType<bool>(json, r'isTrashed')!,
         libraryId: mapValueOfType<String>(json, r'libraryId')!,
         libraryId: mapValueOfType<String>(json, r'libraryId')!,
         livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
         livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
         localDateTime: mapDateTime(json, r'localDateTime', '')!,
         localDateTime: mapDateTime(json, r'localDateTime', '')!,
@@ -326,6 +333,7 @@ class AssetResponseDto {
     'isFavorite',
     'isFavorite',
     'isOffline',
     'isOffline',
     'isReadOnly',
     'isReadOnly',
+    'isTrashed',
     'libraryId',
     'libraryId',
     'localDateTime',
     'localDateTime',
     'originalFileName',
     'originalFileName',

+ 0 - 85
mobile/openapi/lib/model/delete_asset_status.dart

@@ -1,85 +0,0 @@
-//
-// AUTO-GENERATED FILE, DO NOT MODIFY!
-//
-// @dart=2.12
-
-// 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 DeleteAssetStatus {
-  /// Instantiate a new enum with the provided [value].
-  const DeleteAssetStatus._(this.value);
-
-  /// The underlying value of this enum member.
-  final String value;
-
-  @override
-  String toString() => value;
-
-  String toJson() => value;
-
-  static const SUCCESS = DeleteAssetStatus._(r'SUCCESS');
-  static const FAILED = DeleteAssetStatus._(r'FAILED');
-
-  /// List of all possible values in this [enum][DeleteAssetStatus].
-  static const values = <DeleteAssetStatus>[
-    SUCCESS,
-    FAILED,
-  ];
-
-  static DeleteAssetStatus? fromJson(dynamic value) => DeleteAssetStatusTypeTransformer().decode(value);
-
-  static List<DeleteAssetStatus>? listFromJson(dynamic json, {bool growable = false,}) {
-    final result = <DeleteAssetStatus>[];
-    if (json is List && json.isNotEmpty) {
-      for (final row in json) {
-        final value = DeleteAssetStatus.fromJson(row);
-        if (value != null) {
-          result.add(value);
-        }
-      }
-    }
-    return result.toList(growable: growable);
-  }
-}
-
-/// Transformation class that can [encode] an instance of [DeleteAssetStatus] to String,
-/// and [decode] dynamic data back to [DeleteAssetStatus].
-class DeleteAssetStatusTypeTransformer {
-  factory DeleteAssetStatusTypeTransformer() => _instance ??= const DeleteAssetStatusTypeTransformer._();
-
-  const DeleteAssetStatusTypeTransformer._();
-
-  String encode(DeleteAssetStatus data) => data.value;
-
-  /// Decodes a [dynamic value][data] to a DeleteAssetStatus.
-  ///
-  /// 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.
-  DeleteAssetStatus? decode(dynamic data, {bool allowNull = true}) {
-    if (data != null) {
-      switch (data) {
-        case r'SUCCESS': return DeleteAssetStatus.SUCCESS;
-        case r'FAILED': return DeleteAssetStatus.FAILED;
-        default:
-          if (!allowNull) {
-            throw ArgumentError('Unknown enum value to decode: $data');
-          }
-      }
-    }
-    return null;
-  }
-
-  /// Singleton [DeleteAssetStatusTypeTransformer] instance.
-  static DeleteAssetStatusTypeTransformer? _instance;
-}
-

+ 11 - 3
mobile/openapi/lib/model/server_config_dto.dart

@@ -16,6 +16,7 @@ class ServerConfigDto {
     required this.loginPageMessage,
     required this.loginPageMessage,
     required this.mapTileUrl,
     required this.mapTileUrl,
     required this.oauthButtonText,
     required this.oauthButtonText,
+    required this.trashDays,
   });
   });
 
 
   String loginPageMessage;
   String loginPageMessage;
@@ -24,27 +25,32 @@ class ServerConfigDto {
 
 
   String oauthButtonText;
   String oauthButtonText;
 
 
+  int trashDays;
+
   @override
   @override
   bool operator ==(Object other) => identical(this, other) || other is ServerConfigDto &&
   bool operator ==(Object other) => identical(this, other) || other is ServerConfigDto &&
      other.loginPageMessage == loginPageMessage &&
      other.loginPageMessage == loginPageMessage &&
      other.mapTileUrl == mapTileUrl &&
      other.mapTileUrl == mapTileUrl &&
-     other.oauthButtonText == oauthButtonText;
+     other.oauthButtonText == oauthButtonText &&
+     other.trashDays == trashDays;
 
 
   @override
   @override
   int get hashCode =>
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     // ignore: unnecessary_parenthesis
     (loginPageMessage.hashCode) +
     (loginPageMessage.hashCode) +
     (mapTileUrl.hashCode) +
     (mapTileUrl.hashCode) +
-    (oauthButtonText.hashCode);
+    (oauthButtonText.hashCode) +
+    (trashDays.hashCode);
 
 
   @override
   @override
-  String toString() => 'ServerConfigDto[loginPageMessage=$loginPageMessage, mapTileUrl=$mapTileUrl, oauthButtonText=$oauthButtonText]';
+  String toString() => 'ServerConfigDto[loginPageMessage=$loginPageMessage, mapTileUrl=$mapTileUrl, oauthButtonText=$oauthButtonText, trashDays=$trashDays]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
       json[r'loginPageMessage'] = this.loginPageMessage;
       json[r'loginPageMessage'] = this.loginPageMessage;
       json[r'mapTileUrl'] = this.mapTileUrl;
       json[r'mapTileUrl'] = this.mapTileUrl;
       json[r'oauthButtonText'] = this.oauthButtonText;
       json[r'oauthButtonText'] = this.oauthButtonText;
+      json[r'trashDays'] = this.trashDays;
     return json;
     return json;
   }
   }
 
 
@@ -59,6 +65,7 @@ class ServerConfigDto {
         loginPageMessage: mapValueOfType<String>(json, r'loginPageMessage')!,
         loginPageMessage: mapValueOfType<String>(json, r'loginPageMessage')!,
         mapTileUrl: mapValueOfType<String>(json, r'mapTileUrl')!,
         mapTileUrl: mapValueOfType<String>(json, r'mapTileUrl')!,
         oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
         oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
+        trashDays: mapValueOfType<int>(json, r'trashDays')!,
       );
       );
     }
     }
     return null;
     return null;
@@ -109,6 +116,7 @@ class ServerConfigDto {
     'loginPageMessage',
     'loginPageMessage',
     'mapTileUrl',
     'mapTileUrl',
     'oauthButtonText',
     'oauthButtonText',
+    'trashDays',
   };
   };
 }
 }
 
 

+ 11 - 3
mobile/openapi/lib/model/server_features_dto.dart

@@ -24,6 +24,7 @@ class ServerFeaturesDto {
     required this.search,
     required this.search,
     required this.sidecar,
     required this.sidecar,
     required this.tagImage,
     required this.tagImage,
+    required this.trash,
   });
   });
 
 
   bool clipEncode;
   bool clipEncode;
@@ -48,6 +49,8 @@ class ServerFeaturesDto {
 
 
   bool tagImage;
   bool tagImage;
 
 
+  bool trash;
+
   @override
   @override
   bool operator ==(Object other) => identical(this, other) || other is ServerFeaturesDto &&
   bool operator ==(Object other) => identical(this, other) || other is ServerFeaturesDto &&
      other.clipEncode == clipEncode &&
      other.clipEncode == clipEncode &&
@@ -60,7 +63,8 @@ class ServerFeaturesDto {
      other.reverseGeocoding == reverseGeocoding &&
      other.reverseGeocoding == reverseGeocoding &&
      other.search == search &&
      other.search == search &&
      other.sidecar == sidecar &&
      other.sidecar == sidecar &&
-     other.tagImage == tagImage;
+     other.tagImage == tagImage &&
+     other.trash == trash;
 
 
   @override
   @override
   int get hashCode =>
   int get hashCode =>
@@ -75,10 +79,11 @@ class ServerFeaturesDto {
     (reverseGeocoding.hashCode) +
     (reverseGeocoding.hashCode) +
     (search.hashCode) +
     (search.hashCode) +
     (sidecar.hashCode) +
     (sidecar.hashCode) +
-    (tagImage.hashCode);
+    (tagImage.hashCode) +
+    (trash.hashCode);
 
 
   @override
   @override
-  String toString() => 'ServerFeaturesDto[clipEncode=$clipEncode, configFile=$configFile, facialRecognition=$facialRecognition, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, tagImage=$tagImage]';
+  String toString() => 'ServerFeaturesDto[clipEncode=$clipEncode, configFile=$configFile, facialRecognition=$facialRecognition, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, tagImage=$tagImage, trash=$trash]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
@@ -93,6 +98,7 @@ class ServerFeaturesDto {
       json[r'search'] = this.search;
       json[r'search'] = this.search;
       json[r'sidecar'] = this.sidecar;
       json[r'sidecar'] = this.sidecar;
       json[r'tagImage'] = this.tagImage;
       json[r'tagImage'] = this.tagImage;
+      json[r'trash'] = this.trash;
     return json;
     return json;
   }
   }
 
 
@@ -115,6 +121,7 @@ class ServerFeaturesDto {
         search: mapValueOfType<bool>(json, r'search')!,
         search: mapValueOfType<bool>(json, r'search')!,
         sidecar: mapValueOfType<bool>(json, r'sidecar')!,
         sidecar: mapValueOfType<bool>(json, r'sidecar')!,
         tagImage: mapValueOfType<bool>(json, r'tagImage')!,
         tagImage: mapValueOfType<bool>(json, r'tagImage')!,
+        trash: mapValueOfType<bool>(json, r'trash')!,
       );
       );
     }
     }
     return null;
     return null;
@@ -173,6 +180,7 @@ class ServerFeaturesDto {
     'search',
     'search',
     'sidecar',
     'sidecar',
     'tagImage',
     'tagImage',
+    'trash',
   };
   };
 }
 }
 
 

+ 11 - 3
mobile/openapi/lib/model/system_config_dto.dart

@@ -22,6 +22,7 @@ class SystemConfigDto {
     required this.reverseGeocoding,
     required this.reverseGeocoding,
     required this.storageTemplate,
     required this.storageTemplate,
     required this.thumbnail,
     required this.thumbnail,
+    required this.trash,
   });
   });
 
 
   SystemConfigFFmpegDto ffmpeg;
   SystemConfigFFmpegDto ffmpeg;
@@ -42,6 +43,8 @@ class SystemConfigDto {
 
 
   SystemConfigThumbnailDto thumbnail;
   SystemConfigThumbnailDto thumbnail;
 
 
+  SystemConfigTrashDto trash;
+
   @override
   @override
   bool operator ==(Object other) => identical(this, other) || other is SystemConfigDto &&
   bool operator ==(Object other) => identical(this, other) || other is SystemConfigDto &&
      other.ffmpeg == ffmpeg &&
      other.ffmpeg == ffmpeg &&
@@ -52,7 +55,8 @@ class SystemConfigDto {
      other.passwordLogin == passwordLogin &&
      other.passwordLogin == passwordLogin &&
      other.reverseGeocoding == reverseGeocoding &&
      other.reverseGeocoding == reverseGeocoding &&
      other.storageTemplate == storageTemplate &&
      other.storageTemplate == storageTemplate &&
-     other.thumbnail == thumbnail;
+     other.thumbnail == thumbnail &&
+     other.trash == trash;
 
 
   @override
   @override
   int get hashCode =>
   int get hashCode =>
@@ -65,10 +69,11 @@ class SystemConfigDto {
     (passwordLogin.hashCode) +
     (passwordLogin.hashCode) +
     (reverseGeocoding.hashCode) +
     (reverseGeocoding.hashCode) +
     (storageTemplate.hashCode) +
     (storageTemplate.hashCode) +
-    (thumbnail.hashCode);
+    (thumbnail.hashCode) +
+    (trash.hashCode);
 
 
   @override
   @override
-  String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, machineLearning=$machineLearning, map=$map, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, storageTemplate=$storageTemplate, thumbnail=$thumbnail]';
+  String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, machineLearning=$machineLearning, map=$map, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, storageTemplate=$storageTemplate, thumbnail=$thumbnail, trash=$trash]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
@@ -81,6 +86,7 @@ class SystemConfigDto {
       json[r'reverseGeocoding'] = this.reverseGeocoding;
       json[r'reverseGeocoding'] = this.reverseGeocoding;
       json[r'storageTemplate'] = this.storageTemplate;
       json[r'storageTemplate'] = this.storageTemplate;
       json[r'thumbnail'] = this.thumbnail;
       json[r'thumbnail'] = this.thumbnail;
+      json[r'trash'] = this.trash;
     return json;
     return json;
   }
   }
 
 
@@ -101,6 +107,7 @@ class SystemConfigDto {
         reverseGeocoding: SystemConfigReverseGeocodingDto.fromJson(json[r'reverseGeocoding'])!,
         reverseGeocoding: SystemConfigReverseGeocodingDto.fromJson(json[r'reverseGeocoding'])!,
         storageTemplate: SystemConfigStorageTemplateDto.fromJson(json[r'storageTemplate'])!,
         storageTemplate: SystemConfigStorageTemplateDto.fromJson(json[r'storageTemplate'])!,
         thumbnail: SystemConfigThumbnailDto.fromJson(json[r'thumbnail'])!,
         thumbnail: SystemConfigThumbnailDto.fromJson(json[r'thumbnail'])!,
+        trash: SystemConfigTrashDto.fromJson(json[r'trash'])!,
       );
       );
     }
     }
     return null;
     return null;
@@ -157,6 +164,7 @@ class SystemConfigDto {
     'reverseGeocoding',
     'reverseGeocoding',
     'storageTemplate',
     'storageTemplate',
     'thumbnail',
     'thumbnail',
+    'trash',
   };
   };
 }
 }
 
 

+ 32 - 32
mobile/openapi/lib/model/delete_asset_response_dto.dart → mobile/openapi/lib/model/system_config_trash_dto.dart

@@ -10,58 +10,58 @@
 
 
 part of openapi.api;
 part of openapi.api;
 
 
-class DeleteAssetResponseDto {
-  /// Returns a new [DeleteAssetResponseDto] instance.
-  DeleteAssetResponseDto({
-    required this.id,
-    required this.status,
+class SystemConfigTrashDto {
+  /// Returns a new [SystemConfigTrashDto] instance.
+  SystemConfigTrashDto({
+    required this.days,
+    required this.enabled,
   });
   });
 
 
-  String id;
+  int days;
 
 
-  DeleteAssetStatus status;
+  bool enabled;
 
 
   @override
   @override
-  bool operator ==(Object other) => identical(this, other) || other is DeleteAssetResponseDto &&
-     other.id == id &&
-     other.status == status;
+  bool operator ==(Object other) => identical(this, other) || other is SystemConfigTrashDto &&
+     other.days == days &&
+     other.enabled == enabled;
 
 
   @override
   @override
   int get hashCode =>
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     // ignore: unnecessary_parenthesis
-    (id.hashCode) +
-    (status.hashCode);
+    (days.hashCode) +
+    (enabled.hashCode);
 
 
   @override
   @override
-  String toString() => 'DeleteAssetResponseDto[id=$id, status=$status]';
+  String toString() => 'SystemConfigTrashDto[days=$days, enabled=$enabled]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
-      json[r'id'] = this.id;
-      json[r'status'] = this.status;
+      json[r'days'] = this.days;
+      json[r'enabled'] = this.enabled;
     return json;
     return json;
   }
   }
 
 
-  /// Returns a new [DeleteAssetResponseDto] instance and imports its values from
+  /// Returns a new [SystemConfigTrashDto] instance and imports its values from
   /// [value] if it's a [Map], null otherwise.
   /// [value] if it's a [Map], null otherwise.
   // ignore: prefer_constructors_over_static_methods
   // ignore: prefer_constructors_over_static_methods
-  static DeleteAssetResponseDto? fromJson(dynamic value) {
+  static SystemConfigTrashDto? fromJson(dynamic value) {
     if (value is Map) {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
       final json = value.cast<String, dynamic>();
 
 
-      return DeleteAssetResponseDto(
-        id: mapValueOfType<String>(json, r'id')!,
-        status: DeleteAssetStatus.fromJson(json[r'status'])!,
+      return SystemConfigTrashDto(
+        days: mapValueOfType<int>(json, r'days')!,
+        enabled: mapValueOfType<bool>(json, r'enabled')!,
       );
       );
     }
     }
     return null;
     return null;
   }
   }
 
 
-  static List<DeleteAssetResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
-    final result = <DeleteAssetResponseDto>[];
+  static List<SystemConfigTrashDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SystemConfigTrashDto>[];
     if (json is List && json.isNotEmpty) {
     if (json is List && json.isNotEmpty) {
       for (final row in json) {
       for (final row in json) {
-        final value = DeleteAssetResponseDto.fromJson(row);
+        final value = SystemConfigTrashDto.fromJson(row);
         if (value != null) {
         if (value != null) {
           result.add(value);
           result.add(value);
         }
         }
@@ -70,12 +70,12 @@ class DeleteAssetResponseDto {
     return result.toList(growable: growable);
     return result.toList(growable: growable);
   }
   }
 
 
-  static Map<String, DeleteAssetResponseDto> mapFromJson(dynamic json) {
-    final map = <String, DeleteAssetResponseDto>{};
+  static Map<String, SystemConfigTrashDto> mapFromJson(dynamic json) {
+    final map = <String, SystemConfigTrashDto>{};
     if (json is Map && json.isNotEmpty) {
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       for (final entry in json.entries) {
       for (final entry in json.entries) {
-        final value = DeleteAssetResponseDto.fromJson(entry.value);
+        final value = SystemConfigTrashDto.fromJson(entry.value);
         if (value != null) {
         if (value != null) {
           map[entry.key] = value;
           map[entry.key] = value;
         }
         }
@@ -84,14 +84,14 @@ class DeleteAssetResponseDto {
     return map;
     return map;
   }
   }
 
 
-  // maps a json object with a list of DeleteAssetResponseDto-objects as value to a dart map
-  static Map<String, List<DeleteAssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
-    final map = <String, List<DeleteAssetResponseDto>>{};
+  // maps a json object with a list of SystemConfigTrashDto-objects as value to a dart map
+  static Map<String, List<SystemConfigTrashDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SystemConfigTrashDto>>{};
     if (json is Map && json.isNotEmpty) {
     if (json is Map && json.isNotEmpty) {
       // ignore: parameter_assignments
       // ignore: parameter_assignments
       json = json.cast<String, dynamic>();
       json = json.cast<String, dynamic>();
       for (final entry in json.entries) {
       for (final entry in json.entries) {
-        map[entry.key] = DeleteAssetResponseDto.listFromJson(entry.value, growable: growable,);
+        map[entry.key] = SystemConfigTrashDto.listFromJson(entry.value, growable: growable,);
       }
       }
     }
     }
     return map;
     return map;
@@ -99,8 +99,8 @@ class DeleteAssetResponseDto {
 
 
   /// The list of required keys that must be present in a JSON.
   /// The list of required keys that must be present in a JSON.
   static const requiredKeys = <String>{
   static const requiredKeys = <String>{
-    'id',
-    'status',
+    'days',
+    'enabled',
   };
   };
 }
 }
 
 

+ 20 - 5
mobile/openapi/test/asset_api_test.dart

@@ -38,8 +38,8 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
-    //Future<List<DeleteAssetResponseDto>> deleteAsset(DeleteAssetDto deleteAssetDto) async
-    test('test deleteAsset', () async {
+    //Future deleteAssets(AssetBulkDeleteDto assetBulkDeleteDto) async
+    test('test deleteAssets', () async {
       // TODO
       // TODO
     });
     });
 
 
@@ -53,6 +53,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    //Future emptyTrash() async
+    test('test emptyTrash', () async {
+      // TODO
+    });
+
     // Get all AssetEntity belong to the user
     // Get all AssetEntity belong to the user
     //
     //
     //Future<List<AssetResponseDto>> getAllAssets({ String userId, bool isFavorite, bool isArchived, num skip, DateTime updatedAfter, String ifNoneMatch }) async
     //Future<List<AssetResponseDto>> getAllAssets({ String userId, bool isFavorite, bool isArchived, num skip, DateTime updatedAfter, String ifNoneMatch }) async
@@ -72,7 +77,7 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
-    //Future<AssetStatsResponseDto> getAssetStats({ bool isArchived, bool isFavorite }) async
+    //Future<AssetStatsResponseDto> getAssetStats({ bool isArchived, bool isFavorite, bool isTrashed }) async
     test('test getAssetStats', () async {
     test('test getAssetStats', () async {
       // TODO
       // TODO
     });
     });
@@ -82,7 +87,7 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
-    //Future<List<AssetResponseDto>> getByTimeBucket(TimeBucketSize size, String timeBucket, { String userId, String albumId, String personId, bool isArchived, bool isFavorite, String key }) async
+    //Future<List<AssetResponseDto>> getByTimeBucket(TimeBucketSize size, String timeBucket, { String userId, String albumId, String personId, bool isArchived, bool isFavorite, bool isTrashed, String key }) async
     test('test getByTimeBucket', () async {
     test('test getByTimeBucket', () async {
       // TODO
       // TODO
     });
     });
@@ -117,7 +122,7 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
-    //Future<List<TimeBucketResponseDto>> getTimeBuckets(TimeBucketSize size, { String userId, String albumId, String personId, bool isArchived, bool isFavorite, String key }) async
+    //Future<List<TimeBucketResponseDto>> getTimeBuckets(TimeBucketSize size, { String userId, String albumId, String personId, bool isArchived, bool isFavorite, bool isTrashed, String key }) async
     test('test getTimeBuckets', () async {
     test('test getTimeBuckets', () async {
       // TODO
       // TODO
     });
     });
@@ -134,6 +139,16 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    //Future restoreAssets(BulkIdsDto bulkIdsDto) async
+    test('test restoreAssets', () async {
+      // TODO
+    });
+
+    //Future restoreTrash() async
+    test('test restoreTrash', () async {
+      // TODO
+    });
+
     //Future runAssetJobs(AssetJobsDto assetJobsDto) async
     //Future runAssetJobs(AssetJobsDto assetJobsDto) async
     test('test runAssetJobs', () async {
     test('test runAssetJobs', () async {
       // TODO
       // TODO

+ 8 - 3
mobile/openapi/test/delete_asset_dto_test.dart → mobile/openapi/test/asset_bulk_delete_dto_test.dart

@@ -11,11 +11,16 @@
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
 import 'package:test/test.dart';
 import 'package:test/test.dart';
 
 
-// tests for DeleteAssetDto
+// tests for AssetBulkDeleteDto
 void main() {
 void main() {
-  // final instance = DeleteAssetDto();
+  // final instance = AssetBulkDeleteDto();
+
+  group('test AssetBulkDeleteDto', () {
+    // bool force
+    test('to test the property `force`', () async {
+      // TODO
+    });
 
 
-  group('test DeleteAssetDto', () {
     // List<String> ids (default value: const [])
     // List<String> ids (default value: const [])
     test('to test the property `ids`', () async {
     test('to test the property `ids`', () async {
       // TODO
       // TODO

+ 5 - 0
mobile/openapi/test/asset_response_dto_test.dart

@@ -82,6 +82,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    // bool isTrashed
+    test('to test the property `isTrashed`', () async {
+      // TODO
+    });
+
     // String libraryId
     // String libraryId
     test('to test the property `libraryId`', () async {
     test('to test the property `libraryId`', () async {
       // TODO
       // TODO

+ 0 - 21
mobile/openapi/test/delete_asset_status_test.dart

@@ -1,21 +0,0 @@
-//
-// AUTO-GENERATED FILE, DO NOT MODIFY!
-//
-// @dart=2.12
-
-// 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
-
-import 'package:openapi/api.dart';
-import 'package:test/test.dart';
-
-// tests for DeleteAssetStatus
-void main() {
-
-  group('test DeleteAssetStatus', () {
-
-  });
-
-}

+ 5 - 0
mobile/openapi/test/server_config_dto_test.dart

@@ -31,6 +31,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    // int trashDays
+    test('to test the property `trashDays`', () async {
+      // TODO
+    });
+
 
 
   });
   });
 
 

+ 5 - 0
mobile/openapi/test/server_features_dto_test.dart

@@ -71,6 +71,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    // bool trash
+    test('to test the property `trash`', () async {
+      // TODO
+    });
+
 
 
   });
   });
 
 

+ 5 - 0
mobile/openapi/test/system_config_dto_test.dart

@@ -61,6 +61,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    // SystemConfigTrashDto trash
+    test('to test the property `trash`', () async {
+      // TODO
+    });
+
 
 
   });
   });
 
 

+ 7 - 7
mobile/openapi/test/delete_asset_response_dto_test.dart → mobile/openapi/test/system_config_trash_dto_test.dart

@@ -11,18 +11,18 @@
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
 import 'package:test/test.dart';
 import 'package:test/test.dart';
 
 
-// tests for DeleteAssetResponseDto
+// tests for SystemConfigTrashDto
 void main() {
 void main() {
-  // final instance = DeleteAssetResponseDto();
+  // final instance = SystemConfigTrashDto();
 
 
-  group('test DeleteAssetResponseDto', () {
-    // String id
-    test('to test the property `id`', () async {
+  group('test SystemConfigTrashDto', () {
+    // int days
+    test('to test the property `days`', () async {
       // TODO
       // TODO
     });
     });
 
 
-    // DeleteAssetStatus status
-    test('to test the property `status`', () async {
+    // bool enabled
+    test('to test the property `enabled`', () async {
       // TODO
       // TODO
     });
     });
 
 

+ 1 - 0
mobile/test/asset_grid_data_structure_test.dart

@@ -24,6 +24,7 @@ void main() {
         fileName: '',
         fileName: '',
         isFavorite: false,
         isFavorite: false,
         isArchived: false,
         isArchived: false,
+        isTrashed: false,
       ),
       ),
     );
     );
   }
   }

+ 1 - 0
mobile/test/sync_service_test.dart

@@ -34,6 +34,7 @@ void main() {
       fileName: localId ?? remoteId ?? "",
       fileName: localId ?? remoteId ?? "",
       isFavorite: false,
       isFavorite: false,
       isArchived: false,
       isArchived: false,
+      isTrashed: false,
     );
     );
   }
   }
 
 

+ 162 - 56
server/immich-openapi-specs.json

@@ -681,30 +681,20 @@
     },
     },
     "/asset": {
     "/asset": {
       "delete": {
       "delete": {
-        "operationId": "deleteAsset",
+        "operationId": "deleteAssets",
         "parameters": [],
         "parameters": [],
         "requestBody": {
         "requestBody": {
           "content": {
           "content": {
             "application/json": {
             "application/json": {
               "schema": {
               "schema": {
-                "$ref": "#/components/schemas/DeleteAssetDto"
+                "$ref": "#/components/schemas/AssetBulkDeleteDto"
               }
               }
             }
             }
           },
           },
           "required": true
           "required": true
         },
         },
         "responses": {
         "responses": {
-          "200": {
-            "content": {
-              "application/json": {
-                "schema": {
-                  "items": {
-                    "$ref": "#/components/schemas/DeleteAssetResponseDto"
-                  },
-                  "type": "array"
-                }
-              }
-            },
+          "204": {
             "description": ""
             "description": ""
           }
           }
         },
         },
@@ -1568,6 +1558,41 @@
         ]
         ]
       }
       }
     },
     },
+    "/asset/restore": {
+      "post": {
+        "operationId": "restoreAssets",
+        "parameters": [],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/BulkIdsDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "204": {
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Asset"
+        ]
+      }
+    },
     "/asset/search": {
     "/asset/search": {
       "post": {
       "post": {
         "operationId": "searchAsset",
         "operationId": "searchAsset",
@@ -1667,6 +1692,14 @@
             "schema": {
             "schema": {
               "type": "boolean"
               "type": "boolean"
             }
             }
+          },
+          {
+            "name": "isTrashed",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "boolean"
+            }
           }
           }
         ],
         ],
         "responses": {
         "responses": {
@@ -1817,6 +1850,14 @@
               "type": "boolean"
               "type": "boolean"
             }
             }
           },
           },
+          {
+            "name": "isTrashed",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "boolean"
+            }
+          },
           {
           {
             "name": "timeBucket",
             "name": "timeBucket",
             "required": true,
             "required": true,
@@ -1929,6 +1970,14 @@
               "type": "boolean"
               "type": "boolean"
             }
             }
           },
           },
+          {
+            "name": "isTrashed",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "boolean"
+            }
+          },
           {
           {
             "name": "key",
             "name": "key",
             "required": false,
             "required": false,
@@ -1978,6 +2027,56 @@
         ]
         ]
       }
       }
     },
     },
+    "/asset/trash/empty": {
+      "post": {
+        "operationId": "emptyTrash",
+        "parameters": [],
+        "responses": {
+          "204": {
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Asset"
+        ]
+      }
+    },
+    "/asset/trash/restore": {
+      "post": {
+        "operationId": "restoreTrash",
+        "parameters": [],
+        "responses": {
+          "204": {
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Asset"
+        ]
+      }
+    },
     "/asset/upload": {
     "/asset/upload": {
       "post": {
       "post": {
         "operationId": "uploadFile",
         "operationId": "uploadFile",
@@ -5398,6 +5497,24 @@
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },
+      "AssetBulkDeleteDto": {
+        "properties": {
+          "force": {
+            "type": "boolean"
+          },
+          "ids": {
+            "items": {
+              "format": "uuid",
+              "type": "string"
+            },
+            "type": "array"
+          }
+        },
+        "required": [
+          "ids"
+        ],
+        "type": "object"
+      },
       "AssetBulkUpdateDto": {
       "AssetBulkUpdateDto": {
         "properties": {
         "properties": {
           "ids": {
           "ids": {
@@ -5616,6 +5733,9 @@
           "isReadOnly": {
           "isReadOnly": {
             "type": "boolean"
             "type": "boolean"
           },
           },
+          "isTrashed": {
+            "type": "boolean"
+          },
           "libraryId": {
           "libraryId": {
             "type": "string"
             "type": "string"
           },
           },
@@ -5686,6 +5806,7 @@
           "updatedAt",
           "updatedAt",
           "isFavorite",
           "isFavorite",
           "isArchived",
           "isArchived",
+          "isTrashed",
           "localDateTime",
           "localDateTime",
           "isOffline",
           "isOffline",
           "isExternal",
           "isExternal",
@@ -6222,48 +6343,6 @@
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },
-      "DeleteAssetDto": {
-        "properties": {
-          "ids": {
-            "example": [
-              "bf973405-3f2a-48d2-a687-2ed4167164be",
-              "dd41870b-5d00-46d2-924e-1d8489a0aa0f",
-              "fad77c3f-deef-4e7e-9608-14c1aa4e559a"
-            ],
-            "items": {
-              "type": "string"
-            },
-            "title": "Array of asset IDs to delete",
-            "type": "array"
-          }
-        },
-        "required": [
-          "ids"
-        ],
-        "type": "object"
-      },
-      "DeleteAssetResponseDto": {
-        "properties": {
-          "id": {
-            "type": "string"
-          },
-          "status": {
-            "$ref": "#/components/schemas/DeleteAssetStatus"
-          }
-        },
-        "required": [
-          "status",
-          "id"
-        ],
-        "type": "object"
-      },
-      "DeleteAssetStatus": {
-        "enum": [
-          "SUCCESS",
-          "FAILED"
-        ],
-        "type": "string"
-      },
       "DownloadArchiveInfo": {
       "DownloadArchiveInfo": {
         "properties": {
         "properties": {
           "assetIds": {
           "assetIds": {
@@ -7225,9 +7304,13 @@
           },
           },
           "oauthButtonText": {
           "oauthButtonText": {
             "type": "string"
             "type": "string"
+          },
+          "trashDays": {
+            "type": "integer"
           }
           }
         },
         },
         "required": [
         "required": [
+          "trashDays",
           "oauthButtonText",
           "oauthButtonText",
           "loginPageMessage",
           "loginPageMessage",
           "mapTileUrl"
           "mapTileUrl"
@@ -7268,6 +7351,9 @@
           },
           },
           "tagImage": {
           "tagImage": {
             "type": "boolean"
             "type": "boolean"
+          },
+          "trash": {
+            "type": "boolean"
           }
           }
         },
         },
         "required": [
         "required": [
@@ -7275,6 +7361,7 @@
           "configFile",
           "configFile",
           "facialRecognition",
           "facialRecognition",
           "map",
           "map",
+          "trash",
           "reverseGeocoding",
           "reverseGeocoding",
           "oauth",
           "oauth",
           "oauthAutoLaunch",
           "oauthAutoLaunch",
@@ -7630,6 +7717,9 @@
           },
           },
           "thumbnail": {
           "thumbnail": {
             "$ref": "#/components/schemas/SystemConfigThumbnailDto"
             "$ref": "#/components/schemas/SystemConfigThumbnailDto"
+          },
+          "trash": {
+            "$ref": "#/components/schemas/SystemConfigTrashDto"
           }
           }
         },
         },
         "required": [
         "required": [
@@ -7641,7 +7731,8 @@
           "reverseGeocoding",
           "reverseGeocoding",
           "storageTemplate",
           "storageTemplate",
           "job",
           "job",
-          "thumbnail"
+          "thumbnail",
+          "trash"
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },
@@ -7991,6 +8082,21 @@
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },
+      "SystemConfigTrashDto": {
+        "properties": {
+          "days": {
+            "type": "integer"
+          },
+          "enabled": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "days",
+          "enabled"
+        ],
+        "type": "object"
+      },
       "TagResponseDto": {
       "TagResponseDto": {
         "properties": {
         "properties": {
           "id": {
           "id": {

+ 4 - 0
server/src/domain/access/access.core.ts

@@ -7,6 +7,7 @@ export enum Permission {
   ASSET_READ = 'asset.read',
   ASSET_READ = 'asset.read',
   ASSET_UPDATE = 'asset.update',
   ASSET_UPDATE = 'asset.update',
   ASSET_DELETE = 'asset.delete',
   ASSET_DELETE = 'asset.delete',
+  ASSET_RESTORE = 'asset.restore',
   ASSET_SHARE = 'asset.share',
   ASSET_SHARE = 'asset.share',
   ASSET_VIEW = 'asset.view',
   ASSET_VIEW = 'asset.view',
   ASSET_DOWNLOAD = 'asset.download',
   ASSET_DOWNLOAD = 'asset.download',
@@ -128,6 +129,9 @@ export class AccessCore {
       case Permission.ASSET_DELETE:
       case Permission.ASSET_DELETE:
         return this.repository.asset.hasOwnerAccess(authUser.id, id);
         return this.repository.asset.hasOwnerAccess(authUser.id, id);
 
 
+      case Permission.ASSET_RESTORE:
+        return this.repository.asset.hasOwnerAccess(authUser.id, id);
+
       case Permission.ASSET_SHARE:
       case Permission.ASSET_SHARE:
         return (
         return (
           (await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
           (await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||

+ 8 - 3
server/src/domain/asset/asset.repository.ts

@@ -6,10 +6,12 @@ export type AssetStats = Record<AssetType, number>;
 export interface AssetStatsOptions {
 export interface AssetStatsOptions {
   isFavorite?: boolean;
   isFavorite?: boolean;
   isArchived?: boolean;
   isArchived?: boolean;
+  isTrashed?: boolean;
 }
 }
 
 
 export interface AssetSearchOptions {
 export interface AssetSearchOptions {
   isVisible?: boolean;
   isVisible?: boolean;
+  trashedBefore?: Date;
   type?: AssetType;
   type?: AssetType;
   order?: 'ASC' | 'DESC';
   order?: 'ASC' | 'DESC';
 }
 }
@@ -58,6 +60,7 @@ export interface TimeBucketOptions {
   size: TimeBucketSize;
   size: TimeBucketSize;
   isArchived?: boolean;
   isArchived?: boolean;
   isFavorite?: boolean;
   isFavorite?: boolean;
+  isTrashed?: boolean;
   albumId?: string;
   albumId?: string;
   personId?: string;
   personId?: string;
   userId?: string;
   userId?: string;
@@ -98,7 +101,8 @@ export interface IAssetRepository {
   getByDayOfYear(ownerId: string, monthDay: MonthDay): Promise<AssetEntity[]>;
   getByDayOfYear(ownerId: string, monthDay: MonthDay): Promise<AssetEntity[]>;
   getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null>;
   getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null>;
   getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
   getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
-  getByUserId(pagination: PaginationOptions, userId: string): Paginated<AssetEntity>;
+  getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated<AssetEntity>;
+  getById(id: string): Promise<AssetEntity | null>;
   getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
   getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
   getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated<AssetEntity>;
   getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated<AssetEntity>;
   getRandom(userId: string, count: number): Promise<AssetEntity[]>;
   getRandom(userId: string, count: number): Promise<AssetEntity[]>;
@@ -110,12 +114,13 @@ export interface IAssetRepository {
   getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
   getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
   updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void>;
   updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void>;
   save(asset: Pick<AssetEntity, 'id'> & Partial<AssetEntity>): Promise<AssetEntity>;
   save(asset: Pick<AssetEntity, 'id'> & Partial<AssetEntity>): Promise<AssetEntity>;
+  remove(asset: AssetEntity): Promise<void>;
+  softDeleteAll(ids: string[]): Promise<void>;
+  restoreAll(ids: string[]): Promise<void>;
   findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
   findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
   getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
   getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
   getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>;
   getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>;
   getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]>;
   getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]>;
   getByTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>;
   getByTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>;
-  remove(asset: AssetEntity): Promise<AssetEntity>;
-  getById(assetId: string): Promise<AssetEntity>;
   upsertExif(exif: Partial<ExifEntity>): Promise<void>;
   upsertExif(exif: Partial<ExifEntity>): Promise<void>;
 }
 }

+ 215 - 4
server/src/domain/asset/asset.service.spec.ts

@@ -1,20 +1,23 @@
-import { AssetType } from '@app/infra/entities';
+import { AssetEntity, AssetType } from '@app/infra/entities';
 import { BadRequestException, UnauthorizedException } from '@nestjs/common';
 import { BadRequestException, UnauthorizedException } from '@nestjs/common';
 import {
 import {
   IAccessRepositoryMock,
   IAccessRepositoryMock,
   assetStub,
   assetStub,
   authStub,
   authStub,
+  faceStub,
   newAccessRepositoryMock,
   newAccessRepositoryMock,
   newAssetRepositoryMock,
   newAssetRepositoryMock,
   newCryptoRepositoryMock,
   newCryptoRepositoryMock,
   newJobRepositoryMock,
   newJobRepositoryMock,
   newStorageRepositoryMock,
   newStorageRepositoryMock,
+  newSystemConfigRepositoryMock,
 } from '@test';
 } from '@test';
 import { when } from 'jest-when';
 import { when } from 'jest-when';
 import { Readable } from 'stream';
 import { Readable } from 'stream';
 import { ICryptoRepository } from '../crypto';
 import { ICryptoRepository } from '../crypto';
-import { IJobRepository, JobName } from '../job';
+import { IJobRepository, JobItem, JobName } from '../job';
 import { IStorageRepository } from '../storage';
 import { IStorageRepository } from '../storage';
+import { ISystemConfigRepository } from '../system-config';
 import { AssetStats, IAssetRepository, TimeBucketSize } from './asset.repository';
 import { AssetStats, IAssetRepository, TimeBucketSize } from './asset.repository';
 import { AssetService, UploadFieldName } from './asset.service';
 import { AssetService, UploadFieldName } from './asset.service';
 import { AssetJobName, AssetStatsResponseDto, DownloadResponseDto } from './dto';
 import { AssetJobName, AssetStatsResponseDto, DownloadResponseDto } from './dto';
@@ -150,6 +153,7 @@ describe(AssetService.name, () => {
   let cryptoMock: jest.Mocked<ICryptoRepository>;
   let cryptoMock: jest.Mocked<ICryptoRepository>;
   let jobMock: jest.Mocked<IJobRepository>;
   let jobMock: jest.Mocked<IJobRepository>;
   let storageMock: jest.Mocked<IStorageRepository>;
   let storageMock: jest.Mocked<IStorageRepository>;
+  let configMock: jest.Mocked<ISystemConfigRepository>;
 
 
   it('should work', () => {
   it('should work', () => {
     expect(sut).toBeDefined();
     expect(sut).toBeDefined();
@@ -161,7 +165,15 @@ describe(AssetService.name, () => {
     cryptoMock = newCryptoRepositoryMock();
     cryptoMock = newCryptoRepositoryMock();
     jobMock = newJobRepositoryMock();
     jobMock = newJobRepositoryMock();
     storageMock = newStorageRepositoryMock();
     storageMock = newStorageRepositoryMock();
-    sut = new AssetService(accessMock, assetMock, cryptoMock, jobMock, storageMock);
+    configMock = newSystemConfigRepositoryMock();
+    sut = new AssetService(accessMock, assetMock, cryptoMock, jobMock, configMock, storageMock);
+
+    when(assetMock.getById)
+      .calledWith(assetStub.livePhotoStillAsset.id)
+      .mockResolvedValue(assetStub.livePhotoStillAsset as AssetEntity);
+    when(assetMock.getById)
+      .calledWith(assetStub.livePhotoMotionAsset.id)
+      .mockResolvedValue(assetStub.livePhotoMotionAsset as AssetEntity);
   });
   });
 
 
   describe('canUpload', () => {
   describe('canUpload', () => {
@@ -476,7 +488,9 @@ describe(AssetService.name, () => {
         downloadResponse,
         downloadResponse,
       );
       );
 
 
-      expect(assetMock.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.id);
+      expect(assetMock.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.id, {
+        isVisible: true,
+      });
     });
     });
 
 
     it('should split archives by size', async () => {
     it('should split archives by size', async () => {
@@ -596,6 +610,203 @@ describe(AssetService.name, () => {
     });
     });
   });
   });
 
 
+  describe('deleteAll', () => {
+    it('should required asset delete access for all ids', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
+      await expect(
+        sut.deleteAll(authStub.user1, {
+          ids: ['asset-1'],
+        }),
+      ).rejects.toBeInstanceOf(BadRequestException);
+    });
+
+    it('should force delete a batch of assets', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+
+      await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true });
+
+      expect(jobMock.queue.mock.calls).toEqual([
+        [{ name: JobName.ASSET_DELETION, data: { id: 'asset1' } }],
+        [{ name: JobName.ASSET_DELETION, data: { id: 'asset2' } }],
+      ]);
+    });
+
+    it('should soft delete a batch of assets', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+
+      await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false });
+
+      expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['asset1', 'asset2']);
+      expect(jobMock.queue.mock.calls).toEqual([
+        [
+          {
+            name: JobName.SEARCH_REMOVE_ASSET,
+            data: { ids: ['asset1', 'asset2'] },
+          },
+        ],
+      ]);
+    });
+  });
+
+  describe('restoreAll', () => {
+    it('should required asset restore access for all ids', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
+      await expect(
+        sut.deleteAll(authStub.user1, {
+          ids: ['asset-1'],
+        }),
+      ).rejects.toBeInstanceOf(BadRequestException);
+    });
+
+    it('should restore a batch of assets', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+
+      await sut.restoreAll(authStub.user1, { ids: ['asset1', 'asset2'] });
+
+      expect(assetMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']);
+      expect(jobMock.queue.mock.calls).toEqual([
+        [
+          {
+            name: JobName.SEARCH_INDEX_ASSET,
+            data: { ids: ['asset1', 'asset2'] },
+          },
+        ],
+      ]);
+    });
+  });
+
+  describe('handleAssetDeletion', () => {
+    beforeEach(() => {
+      when(jobMock.queue)
+        .calledWith(
+          expect.objectContaining({
+            name: JobName.ASSET_DELETION,
+          }),
+        )
+        .mockImplementation(async (item: JobItem) => {
+          const jobData = (item as { data?: any })?.data || {};
+          await sut.handleAssetDeletion(jobData);
+        });
+    });
+
+    it('should remove faces', async () => {
+      const assetWithFace = { ...(assetStub.image as AssetEntity), faces: [faceStub.face1, faceStub.mergeFace1] };
+
+      when(assetMock.getById).calledWith(assetWithFace.id).mockResolvedValue(assetWithFace);
+
+      await sut.handleAssetDeletion({ id: assetWithFace.id });
+
+      expect(jobMock.queue.mock.calls).toEqual([
+        [
+          {
+            name: JobName.SEARCH_REMOVE_FACE,
+            data: { assetId: faceStub.face1.assetId, personId: faceStub.face1.personId },
+          },
+        ],
+        [
+          {
+            name: JobName.SEARCH_REMOVE_FACE,
+            data: { assetId: faceStub.mergeFace1.assetId, personId: faceStub.mergeFace1.personId },
+          },
+        ],
+        [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [assetWithFace.id] } }],
+        [
+          {
+            name: JobName.DELETE_FILES,
+            data: {
+              files: [
+                assetWithFace.webpPath,
+                assetWithFace.resizePath,
+                assetWithFace.encodedVideoPath,
+                assetWithFace.sidecarPath,
+                assetWithFace.originalPath,
+              ],
+            },
+          },
+        ],
+      ]);
+
+      expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace);
+    });
+
+    it('should not schedule delete-files job for readonly assets', async () => {
+      when(assetMock.getById)
+        .calledWith(assetStub.readOnly.id)
+        .mockResolvedValue(assetStub.readOnly as AssetEntity);
+
+      await sut.handleAssetDeletion({ id: assetStub.readOnly.id });
+
+      expect(jobMock.queue.mock.calls).toEqual([
+        [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [assetStub.readOnly.id] } }],
+      ]);
+
+      expect(assetMock.remove).toHaveBeenCalledWith(assetStub.readOnly);
+    });
+
+    it('should not process assets from external library without fromExternal flag', async () => {
+      when(assetMock.getById)
+        .calledWith(assetStub.external.id)
+        .mockResolvedValue(assetStub.external as AssetEntity);
+
+      await sut.handleAssetDeletion({ id: assetStub.external.id });
+
+      expect(jobMock.queue).not.toBeCalled();
+      expect(assetMock.remove).not.toBeCalled();
+    });
+
+    it('should process assets from external library with fromExternal flag', async () => {
+      when(assetMock.getById)
+        .calledWith(assetStub.external.id)
+        .mockResolvedValue(assetStub.external as AssetEntity);
+
+      await sut.handleAssetDeletion({ id: assetStub.external.id, fromExternal: true });
+
+      expect(assetMock.remove).toHaveBeenCalledWith(assetStub.external);
+      expect(jobMock.queue.mock.calls).toEqual([
+        [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [assetStub.external.id] } }],
+        [
+          {
+            name: JobName.DELETE_FILES,
+            data: {
+              files: [
+                assetStub.external.webpPath,
+                assetStub.external.resizePath,
+                assetStub.external.encodedVideoPath,
+                assetStub.external.sidecarPath,
+              ],
+            },
+          },
+        ],
+      ]);
+    });
+
+    it('should delete a live photo', async () => {
+      await sut.handleAssetDeletion({ id: assetStub.livePhotoStillAsset.id });
+
+      expect(jobMock.queue.mock.calls).toEqual([
+        [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [assetStub.livePhotoStillAsset.id] } }],
+        [{ name: JobName.ASSET_DELETION, data: { id: assetStub.livePhotoMotionAsset.id } }],
+        [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [assetStub.livePhotoMotionAsset.id] } }],
+        [
+          {
+            name: JobName.DELETE_FILES,
+            data: {
+              files: [undefined, undefined, undefined, undefined, 'fake_path/asset_1.mp4'],
+            },
+          },
+        ],
+        [
+          {
+            name: JobName.DELETE_FILES,
+            data: {
+              files: [undefined, undefined, undefined, undefined, 'fake_path/asset_1.jpeg'],
+            },
+          },
+        ],
+      ]);
+    });
+  });
+
   describe('run', () => {
   describe('run', () => {
     it('should run the refresh metadata job', async () => {
     it('should run the refresh metadata job', async () => {
       accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
       accessMock.asset.hasOwnerAccess.mockResolvedValue(true);

+ 120 - 4
server/src/domain/asset/asset.service.ts

@@ -1,6 +1,7 @@
-import { AssetEntity } from '@app/infra/entities';
+import { AssetEntity, LibraryType } from '@app/infra/entities';
 import { BadRequestException, Inject, Logger } from '@nestjs/common';
 import { BadRequestException, Inject, Logger } from '@nestjs/common';
 import _ from 'lodash';
 import _ from 'lodash';
+import { DateTime, Duration } from 'luxon';
 import { extname } from 'path';
 import { extname } from 'path';
 import sanitize from 'sanitize-filename';
 import sanitize from 'sanitize-filename';
 import { AccessCore, IAccessRepository, Permission } from '../access';
 import { AccessCore, IAccessRepository, Permission } from '../access';
@@ -8,10 +9,12 @@ import { AuthUserDto } from '../auth';
 import { ICryptoRepository } from '../crypto';
 import { ICryptoRepository } from '../crypto';
 import { mimeTypes } from '../domain.constant';
 import { mimeTypes } from '../domain.constant';
 import { HumanReadableSize, usePagination } from '../domain.util';
 import { HumanReadableSize, usePagination } from '../domain.util';
-import { IJobRepository, JobName } from '../job';
+import { IAssetDeletionJob, IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
 import { IStorageRepository, ImmichReadStream, StorageCore, StorageFolder } from '../storage';
 import { IStorageRepository, ImmichReadStream, StorageCore, StorageFolder } from '../storage';
+import { ISystemConfigRepository, SystemConfigCore } from '../system-config';
 import { IAssetRepository } from './asset.repository';
 import { IAssetRepository } from './asset.repository';
 import {
 import {
+  AssetBulkDeleteDto,
   AssetBulkUpdateDto,
   AssetBulkUpdateDto,
   AssetIdsDto,
   AssetIdsDto,
   AssetJobName,
   AssetJobName,
@@ -24,11 +27,13 @@ import {
   MemoryLaneDto,
   MemoryLaneDto,
   TimeBucketAssetDto,
   TimeBucketAssetDto,
   TimeBucketDto,
   TimeBucketDto,
+  TrashAction,
   UpdateAssetDto,
   UpdateAssetDto,
   mapStats,
   mapStats,
 } from './dto';
 } from './dto';
 import {
 import {
   AssetResponseDto,
   AssetResponseDto,
+  BulkIdsDto,
   MapMarkerResponseDto,
   MapMarkerResponseDto,
   MemoryLaneResponseDto,
   MemoryLaneResponseDto,
   TimeBucketResponseDto,
   TimeBucketResponseDto,
@@ -57,6 +62,7 @@ export interface UploadFile {
 export class AssetService {
 export class AssetService {
   private logger = new Logger(AssetService.name);
   private logger = new Logger(AssetService.name);
   private access: AccessCore;
   private access: AccessCore;
+  private configCore: SystemConfigCore;
   private storageCore: StorageCore;
   private storageCore: StorageCore;
 
 
   constructor(
   constructor(
@@ -64,10 +70,12 @@ export class AssetService {
     @Inject(IAssetRepository) private assetRepository: IAssetRepository,
     @Inject(IAssetRepository) private assetRepository: IAssetRepository,
     @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
     @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
+    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
     @Inject(IStorageRepository) private storageRepository: IStorageRepository,
     @Inject(IStorageRepository) private storageRepository: IStorageRepository,
   ) {
   ) {
     this.access = new AccessCore(accessRepository);
     this.access = new AccessCore(accessRepository);
     this.storageCore = new StorageCore(storageRepository);
     this.storageCore = new StorageCore(storageRepository);
+    this.configCore = new SystemConfigCore(configRepository);
   }
   }
 
 
   canUploadFile({ authUser, fieldName, file }: UploadRequest): true {
   canUploadFile({ authUser, fieldName, file }: UploadRequest): true {
@@ -274,7 +282,9 @@ export class AssetService {
     if (dto.userId) {
     if (dto.userId) {
       const userId = dto.userId;
       const userId = dto.userId;
       await this.access.requirePermission(authUser, Permission.TIMELINE_DOWNLOAD, userId);
       await this.access.requirePermission(authUser, Permission.TIMELINE_DOWNLOAD, userId);
-      return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByUserId(pagination, userId));
+      return usePagination(PAGINATION_SIZE, (pagination) =>
+        this.assetRepository.getByUserId(pagination, userId, { isVisible: true }),
+      );
     }
     }
 
 
     throw new BadRequestException('assetIds, albumId, or userId is required');
     throw new BadRequestException('assetIds, albumId, or userId is required');
@@ -303,13 +313,119 @@ export class AssetService {
     return mapAsset(asset);
     return mapAsset(asset);
   }
   }
 
 
-  async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto) {
+  async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise<void> {
     const { ids, ...options } = dto;
     const { ids, ...options } = dto;
     await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
     await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
     await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
     await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
     await this.assetRepository.updateAll(ids, options);
     await this.assetRepository.updateAll(ids, options);
   }
   }
 
 
+  async handleAssetDeletionCheck() {
+    const config = await this.configCore.getConfig();
+    const trashedDays = config.trash.enabled ? config.trash.days : 0;
+    const trashedBefore = DateTime.now()
+      .minus(Duration.fromObject({ days: trashedDays }))
+      .toJSDate();
+    const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
+      this.assetRepository.getAll(pagination, { trashedBefore }),
+    );
+
+    for await (const assets of assetPagination) {
+      for (const asset of assets) {
+        await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.id } });
+      }
+    }
+
+    return true;
+  }
+
+  async handleAssetDeletion(job: IAssetDeletionJob) {
+    const { id, fromExternal } = job;
+
+    const asset = await this.assetRepository.getById(id);
+    if (!asset) {
+      return false;
+    }
+
+    // Ignore requests that are not from external library job but is for an external asset
+    if (!fromExternal && (!asset.library || asset.library.type === LibraryType.EXTERNAL)) {
+      return false;
+    }
+
+    if (asset.faces) {
+      await Promise.all(
+        asset.faces.map(({ assetId, personId }) =>
+          this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId } }),
+        ),
+      );
+    }
+
+    await this.assetRepository.remove(asset);
+    await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } });
+
+    // TODO refactor this to use cascades
+    if (asset.livePhotoVideoId) {
+      await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } });
+    }
+
+    const files = [asset.webpPath, asset.resizePath, asset.encodedVideoPath, asset.sidecarPath];
+    if (!fromExternal) {
+      files.push(asset.originalPath);
+    }
+
+    if (!asset.isReadOnly) {
+      await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } });
+    }
+
+    return true;
+  }
+
+  async deleteAll(authUser: AuthUserDto, dto: AssetBulkDeleteDto): Promise<void> {
+    const { ids, force } = dto;
+
+    await this.access.requirePermission(authUser, Permission.ASSET_DELETE, ids);
+
+    if (force) {
+      for (const id of ids) {
+        await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id } });
+      }
+    } else {
+      await this.assetRepository.softDeleteAll(ids);
+      await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids } });
+    }
+  }
+
+  async handleTrashAction(authUser: AuthUserDto, action: TrashAction): Promise<void> {
+    const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
+      this.assetRepository.getByUserId(pagination, authUser.id, { trashedBefore: DateTime.now().toJSDate() }),
+    );
+
+    if (action == TrashAction.RESTORE_ALL) {
+      for await (const assets of assetPagination) {
+        const ids = assets.map((a) => a.id);
+        await this.assetRepository.restoreAll(ids);
+        await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
+      }
+      return;
+    }
+
+    if (action == TrashAction.EMPTY_ALL) {
+      for await (const assets of assetPagination) {
+        for (const asset of assets) {
+          await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.id } });
+        }
+      }
+      return;
+    }
+  }
+
+  async restoreAll(authUser: AuthUserDto, dto: BulkIdsDto): Promise<void> {
+    const { ids } = dto;
+    await this.access.requirePermission(authUser, Permission.ASSET_RESTORE, ids);
+    await this.assetRepository.restoreAll(ids);
+    await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
+  }
+
   async run(authUser: AuthUserDto, dto: AssetJobsDto) {
   async run(authUser: AuthUserDto, dto: AssetJobsDto) {
     await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds);
     await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds);
 
 

+ 5 - 0
server/src/domain/asset/dto/asset-statistics.dto.ts

@@ -15,6 +15,11 @@ export class AssetStatsDto {
   @Transform(toBoolean)
   @Transform(toBoolean)
   @Optional()
   @Optional()
   isFavorite?: boolean;
   isFavorite?: boolean;
+
+  @IsBoolean()
+  @Transform(toBoolean)
+  @Optional()
+  isTrashed?: boolean;
 }
 }
 
 
 export class AssetStatsResponseDto {
 export class AssetStatsResponseDto {

+ 11 - 0
server/src/domain/asset/dto/asset.dto.ts

@@ -34,3 +34,14 @@ export class RandomAssetsDto {
   @Type(() => Number)
   @Type(() => Number)
   count?: number;
   count?: number;
 }
 }
+
+export enum TrashAction {
+  EMPTY_ALL = 'empty-all',
+  RESTORE_ALL = 'restore-all',
+}
+
+export class AssetBulkDeleteDto extends BulkIdsDto {
+  @Optional()
+  @IsBoolean()
+  force?: boolean;
+}

+ 5 - 0
server/src/domain/asset/dto/time-bucket.dto.ts

@@ -28,6 +28,11 @@ export class TimeBucketDto {
   @IsBoolean()
   @IsBoolean()
   @Transform(toBoolean)
   @Transform(toBoolean)
   isFavorite?: boolean;
   isFavorite?: boolean;
+
+  @Optional()
+  @IsBoolean()
+  @Transform(toBoolean)
+  isTrashed?: boolean;
 }
 }
 
 
 export class TimeBucketAssetDto extends TimeBucketDto {
 export class TimeBucketAssetDto extends TimeBucketDto {

+ 2 - 0
server/src/domain/asset/response-dto/asset-response.dto.ts

@@ -26,6 +26,7 @@ export class AssetResponseDto {
   updatedAt!: Date;
   updatedAt!: Date;
   isFavorite!: boolean;
   isFavorite!: boolean;
   isArchived!: boolean;
   isArchived!: boolean;
+  isTrashed!: boolean;
   localDateTime!: Date;
   localDateTime!: Date;
   isOffline!: boolean;
   isOffline!: boolean;
   isExternal!: boolean;
   isExternal!: boolean;
@@ -59,6 +60,7 @@ function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto {
     updatedAt: entity.updatedAt,
     updatedAt: entity.updatedAt,
     isFavorite: entity.isFavorite,
     isFavorite: entity.isFavorite,
     isArchived: entity.isArchived,
     isArchived: entity.isArchived,
+    isTrashed: !!entity.deletedAt,
     duration: entity.duration ?? '0:00:00.00000',
     duration: entity.duration ?? '0:00:00.00000',
     exifInfo: withExif ? (entity.exifInfo ? mapExif(entity.exifInfo) : undefined) : undefined,
     exifInfo: withExif ? (entity.exifInfo ? mapExif(entity.exifInfo) : undefined) : undefined,
     smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
     smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,

+ 2 - 0
server/src/domain/communication/communication.repository.ts

@@ -2,8 +2,10 @@ export const ICommunicationRepository = 'ICommunicationRepository';
 
 
 export enum CommunicationEvent {
 export enum CommunicationEvent {
   UPLOAD_SUCCESS = 'on_upload_success',
   UPLOAD_SUCCESS = 'on_upload_success',
+  CONFIG_UPDATE = 'on_config_update',
 }
 }
 
 
 export interface ICommunicationRepository {
 export interface ICommunicationRepository {
   send(event: CommunicationEvent, userId: string, data: any): void;
   send(event: CommunicationEvent, userId: string, data: any): void;
+  broadcast(event: CommunicationEvent, data: any): void;
 }
 }

+ 6 - 0
server/src/domain/job/job.constants.ts

@@ -41,6 +41,10 @@ export enum JobName {
   USER_DELETION = 'user-deletion',
   USER_DELETION = 'user-deletion',
   USER_DELETE_CHECK = 'user-delete-check',
   USER_DELETE_CHECK = 'user-delete-check',
 
 
+  // asset
+  ASSET_DELETION = 'asset-deletion',
+  ASSET_DELETION_CHECK = 'asset-deletion-check',
+
   // storage template
   // storage template
   STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration',
   STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration',
   STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single',
   STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single',
@@ -99,6 +103,8 @@ export const JOBS_ASSET_PAGINATION_SIZE = 1000;
 
 
 export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
 export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
   // misc
   // misc
+  [JobName.ASSET_DELETION]: QueueName.BACKGROUND_TASK,
+  [JobName.ASSET_DELETION_CHECK]: QueueName.BACKGROUND_TASK,
   [JobName.USER_DELETE_CHECK]: QueueName.BACKGROUND_TASK,
   [JobName.USER_DELETE_CHECK]: QueueName.BACKGROUND_TASK,
   [JobName.USER_DELETION]: QueueName.BACKGROUND_TASK,
   [JobName.USER_DELETION]: QueueName.BACKGROUND_TASK,
   [JobName.DELETE_FILES]: QueueName.BACKGROUND_TASK,
   [JobName.DELETE_FILES]: QueueName.BACKGROUND_TASK,

+ 4 - 0
server/src/domain/job/job.interface.ts

@@ -12,6 +12,10 @@ export interface IEntityJob extends IBaseJob {
   source?: 'upload';
   source?: 'upload';
 }
 }
 
 
+export interface IAssetDeletionJob extends IEntityJob {
+  fromExternal?: boolean;
+}
+
 export interface IOfflineLibraryFileJob extends IEntityJob {
 export interface IOfflineLibraryFileJob extends IEntityJob {
   assetPath: string;
   assetPath: string;
 }
 }

+ 3 - 0
server/src/domain/job/job.repository.ts

@@ -1,6 +1,7 @@
 import { JobName, QueueName } from './job.constants';
 import { JobName, QueueName } from './job.constants';
 
 
 import {
 import {
+  IAssetDeletionJob,
   IAssetFaceJob,
   IAssetFaceJob,
   IBaseJob,
   IBaseJob,
   IBulkEntityJob,
   IBulkEntityJob,
@@ -82,6 +83,8 @@ export type JobItem =
 
 
   // Asset Deletion
   // Asset Deletion
   | { name: JobName.PERSON_CLEANUP; data?: IBaseJob }
   | { name: JobName.PERSON_CLEANUP; data?: IBaseJob }
+  | { name: JobName.ASSET_DELETION; data: IAssetDeletionJob }
+  | { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob }
 
 
   // Library Managment
   // Library Managment
   | { name: JobName.LIBRARY_SCAN_ASSET; data: ILibraryFileJob }
   | { name: JobName.LIBRARY_SCAN_ASSET; data: ILibraryFileJob }

+ 1 - 0
server/src/domain/job/job.service.spec.ts

@@ -48,6 +48,7 @@ describe(JobService.name, () => {
       await sut.handleNightlyJobs();
       await sut.handleNightlyJobs();
 
 
       expect(jobMock.queue.mock.calls).toEqual([
       expect(jobMock.queue.mock.calls).toEqual([
+        [{ name: JobName.ASSET_DELETION_CHECK }],
         [{ name: JobName.USER_DELETE_CHECK }],
         [{ name: JobName.USER_DELETE_CHECK }],
         [{ name: JobName.PERSON_CLEANUP }],
         [{ name: JobName.PERSON_CLEANUP }],
         [{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }],
         [{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }],

+ 1 - 0
server/src/domain/job/job.service.ts

@@ -140,6 +140,7 @@ export class JobService {
   }
   }
 
 
   async handleNightlyJobs() {
   async handleNightlyJobs() {
+    await this.jobRepository.queue({ name: JobName.ASSET_DELETION_CHECK });
     await this.jobRepository.queue({ name: JobName.USER_DELETE_CHECK });
     await this.jobRepository.queue({ name: JobName.USER_DELETE_CHECK });
     await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP });
     await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP });
     await this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } });
     await this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } });

+ 2 - 17
server/src/domain/library/library.service.spec.ts

@@ -1182,23 +1182,8 @@ describe(LibraryService.name, () => {
       expect(jobMock.queue.mock.calls).toEqual([
       expect(jobMock.queue.mock.calls).toEqual([
         [
         [
           {
           {
-            name: JobName.SEARCH_REMOVE_ASSET,
-            data: {
-              ids: [assetStub.image1.id],
-            },
-          },
-        ],
-        [
-          {
-            name: JobName.DELETE_FILES,
-            data: {
-              files: [
-                assetStub.image1.webpPath,
-                assetStub.image1.resizePath,
-                assetStub.image1.encodedVideoPath,
-                assetStub.image1.sidecarPath,
-              ],
-            },
+            name: JobName.ASSET_DELETION,
+            data: { id: assetStub.image1.id, fromExternal: true },
           },
           },
         ],
         ],
       ]);
       ]);

+ 5 - 19
server/src/domain/library/library.service.ts

@@ -439,31 +439,17 @@ export class LibraryService {
   }
   }
 
 
   private async deleteAssets(assetIds: string[]) {
   private async deleteAssets(assetIds: string[]) {
-    // TODO: this should be refactored to a centralized asset deletion service
     for (const assetId of assetIds) {
     for (const assetId of assetIds) {
       const asset = await this.assetRepository.getById(assetId);
       const asset = await this.assetRepository.getById(assetId);
-      this.logger.debug(`Removing asset from library: ${asset.originalPath}`);
-
-      if (asset.faces) {
-        await Promise.all(
-          asset.faces.map(({ assetId, personId }) =>
-            this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId } }),
-          ),
-        );
+      if (!asset) {
+        continue;
       }
       }
-
-      await this.assetRepository.remove(asset);
-      await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } });
+      this.logger.debug(`Removing asset from library: ${asset.originalPath}`);
 
 
       await this.jobRepository.queue({
       await this.jobRepository.queue({
-        name: JobName.DELETE_FILES,
-        data: { files: [asset.webpPath, asset.resizePath, asset.encodedVideoPath, asset.sidecarPath] },
+        name: JobName.ASSET_DELETION,
+        data: { id: asset.id, fromExternal: true },
       });
       });
-
-      // TODO refactor this to use cascades
-      if (asset.livePhotoVideoId && !assetIds.includes(asset.livePhotoVideoId)) {
-        assetIds.push(asset.livePhotoVideoId);
-      }
     }
     }
   }
   }
 }
 }

+ 3 - 0
server/src/domain/server-info/server-info.dto.ts

@@ -83,6 +83,8 @@ export class ServerConfigDto {
   oauthButtonText!: string;
   oauthButtonText!: string;
   loginPageMessage!: string;
   loginPageMessage!: string;
   mapTileUrl!: string;
   mapTileUrl!: string;
+  @ApiProperty({ type: 'integer' })
+  trashDays!: number;
 }
 }
 
 
 export class ServerFeaturesDto implements FeatureFlags {
 export class ServerFeaturesDto implements FeatureFlags {
@@ -90,6 +92,7 @@ export class ServerFeaturesDto implements FeatureFlags {
   configFile!: boolean;
   configFile!: boolean;
   facialRecognition!: boolean;
   facialRecognition!: boolean;
   map!: boolean;
   map!: boolean;
+  trash!: boolean;
   reverseGeocoding!: boolean;
   reverseGeocoding!: boolean;
   oauth!: boolean;
   oauth!: boolean;
   oauthAutoLaunch!: boolean;
   oauthAutoLaunch!: boolean;

+ 2 - 0
server/src/domain/server-info/server-info.service.spec.ts

@@ -159,6 +159,7 @@ describe(ServerInfoService.name, () => {
         sidecar: true,
         sidecar: true,
         tagImage: true,
         tagImage: true,
         configFile: false,
         configFile: false,
+        trash: true,
       });
       });
       expect(configMock.load).toHaveBeenCalled();
       expect(configMock.load).toHaveBeenCalled();
     });
     });
@@ -169,6 +170,7 @@ describe(ServerInfoService.name, () => {
       await expect(sut.getConfig()).resolves.toEqual({
       await expect(sut.getConfig()).resolves.toEqual({
         loginPageMessage: '',
         loginPageMessage: '',
         oauthButtonText: 'Login with OAuth',
         oauthButtonText: 'Login with OAuth',
+        trashDays: 30,
         mapTileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
         mapTileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
       });
       });
       expect(configMock.load).toHaveBeenCalled();
       expect(configMock.load).toHaveBeenCalled();

+ 1 - 0
server/src/domain/server-info/server-info.service.ts

@@ -66,6 +66,7 @@ export class ServerInfoService {
     return {
     return {
       loginPageMessage,
       loginPageMessage,
       mapTileUrl: config.map.tileUrl,
       mapTileUrl: config.map.tileUrl,
+      trashDays: config.trash.days,
       oauthButtonText: config.oauth.buttonText,
       oauthButtonText: config.oauth.buttonText,
     };
     };
   }
   }

+ 1 - 0
server/src/domain/system-config/dto/index.ts

@@ -3,4 +3,5 @@ export * from './system-config-oauth.dto';
 export * from './system-config-password-login.dto';
 export * from './system-config-password-login.dto';
 export * from './system-config-storage-template.dto';
 export * from './system-config-storage-template.dto';
 export * from './system-config-thumbnail.dto';
 export * from './system-config-thumbnail.dto';
+export * from './system-config-trash.dto';
 export * from './system-config.dto';
 export * from './system-config.dto';

+ 14 - 0
server/src/domain/system-config/dto/system-config-trash.dto.ts

@@ -0,0 +1,14 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import { IsBoolean, IsInt, Min } from 'class-validator';
+
+export class SystemConfigTrashDto {
+  @IsBoolean()
+  enabled!: boolean;
+
+  @IsInt()
+  @Min(0)
+  @Type(() => Number)
+  @ApiProperty({ type: 'integer' })
+  days!: number;
+}

+ 6 - 1
server/src/domain/system-config/dto/system-config.dto.ts

@@ -1,4 +1,4 @@
-import { SystemConfigThumbnailDto } from '@app/domain/system-config';
+import { SystemConfigThumbnailDto, SystemConfigTrashDto } from '@app/domain/system-config';
 import { SystemConfig } from '@app/infra/entities';
 import { SystemConfig } from '@app/infra/entities';
 import { Type } from 'class-transformer';
 import { Type } from 'class-transformer';
 import { IsObject, ValidateNested } from 'class-validator';
 import { IsObject, ValidateNested } from 'class-validator';
@@ -56,6 +56,11 @@ export class SystemConfigDto implements SystemConfig {
   @ValidateNested()
   @ValidateNested()
   @IsObject()
   @IsObject()
   thumbnail!: SystemConfigThumbnailDto;
   thumbnail!: SystemConfigThumbnailDto;
+
+  @Type(() => SystemConfigTrashDto)
+  @ValidateNested()
+  @IsObject()
+  trash!: SystemConfigTrashDto;
 }
 }
 
 
 export function mapConfig(config: SystemConfig): SystemConfigDto {
 export function mapConfig(config: SystemConfig): SystemConfigDto {

+ 6 - 2
server/src/domain/system-config/system-config.core.ts

@@ -102,17 +102,19 @@ export const defaults = Object.freeze<SystemConfig>({
   passwordLogin: {
   passwordLogin: {
     enabled: true,
     enabled: true,
   },
   },
-
   storageTemplate: {
   storageTemplate: {
     template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
     template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
   },
   },
-
   thumbnail: {
   thumbnail: {
     webpSize: 250,
     webpSize: 250,
     jpegSize: 1440,
     jpegSize: 1440,
     quality: 80,
     quality: 80,
     colorspace: Colorspace.P3,
     colorspace: Colorspace.P3,
   },
   },
+  trash: {
+    enabled: true,
+    days: 30,
+  },
 });
 });
 
 
 export enum FeatureFlag {
 export enum FeatureFlag {
@@ -127,6 +129,7 @@ export enum FeatureFlag {
   OAUTH_AUTO_LAUNCH = 'oauthAutoLaunch',
   OAUTH_AUTO_LAUNCH = 'oauthAutoLaunch',
   PASSWORD_LOGIN = 'passwordLogin',
   PASSWORD_LOGIN = 'passwordLogin',
   CONFIG_FILE = 'configFile',
   CONFIG_FILE = 'configFile',
+  TRASH = 'trash',
 }
 }
 
 
 export type FeatureFlags = Record<FeatureFlag, boolean>;
 export type FeatureFlags = Record<FeatureFlag, boolean>;
@@ -186,6 +189,7 @@ export class SystemConfigCore {
       [FeatureFlag.REVERSE_GEOCODING]: config.reverseGeocoding.enabled,
       [FeatureFlag.REVERSE_GEOCODING]: config.reverseGeocoding.enabled,
       [FeatureFlag.SIDECAR]: true,
       [FeatureFlag.SIDECAR]: true,
       [FeatureFlag.SEARCH]: process.env.TYPESENSE_ENABLED !== 'false',
       [FeatureFlag.SEARCH]: process.env.TYPESENSE_ENABLED !== 'false',
+      [FeatureFlag.TRASH]: config.trash.enabled,
 
 
       // TODO: use these instead of `POST oauth/config`
       // TODO: use these instead of `POST oauth/config`
       [FeatureFlag.OAUTH]: config.oauth.enabled,
       [FeatureFlag.OAUTH]: config.oauth.enabled,

+ 12 - 3
server/src/domain/system-config/system-config.service.spec.ts

@@ -12,7 +12,8 @@ import {
   VideoCodec,
   VideoCodec,
 } from '@app/infra/entities';
 } from '@app/infra/entities';
 import { BadRequestException } from '@nestjs/common';
 import { BadRequestException } from '@nestjs/common';
-import { newJobRepositoryMock, newSystemConfigRepositoryMock } from '@test';
+import { newCommunicationRepositoryMock, newJobRepositoryMock, newSystemConfigRepositoryMock } from '@test';
+import { ICommunicationRepository } from '..';
 import { IJobRepository, JobName, QueueName } from '../job';
 import { IJobRepository, JobName, QueueName } from '../job';
 import { SystemConfigValidator, defaults } from './system-config.core';
 import { SystemConfigValidator, defaults } from './system-config.core';
 import { ISystemConfigRepository } from './system-config.repository';
 import { ISystemConfigRepository } from './system-config.repository';
@@ -21,6 +22,7 @@ import { SystemConfigService } from './system-config.service';
 const updates: SystemConfigEntity[] = [
 const updates: SystemConfigEntity[] = [
   { key: SystemConfigKey.FFMPEG_CRF, value: 30 },
   { key: SystemConfigKey.FFMPEG_CRF, value: 30 },
   { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
   { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
+  { key: SystemConfigKey.TRASH_DAYS, value: 10 },
 ];
 ];
 
 
 const updatedConfig = Object.freeze<SystemConfig>({
 const updatedConfig = Object.freeze<SystemConfig>({
@@ -110,18 +112,24 @@ const updatedConfig = Object.freeze<SystemConfig>({
     quality: 80,
     quality: 80,
     colorspace: Colorspace.P3,
     colorspace: Colorspace.P3,
   },
   },
+  trash: {
+    enabled: true,
+    days: 10,
+  },
 });
 });
 
 
 describe(SystemConfigService.name, () => {
 describe(SystemConfigService.name, () => {
   let sut: SystemConfigService;
   let sut: SystemConfigService;
   let configMock: jest.Mocked<ISystemConfigRepository>;
   let configMock: jest.Mocked<ISystemConfigRepository>;
+  let communicationMock: jest.Mocked<ICommunicationRepository>;
   let jobMock: jest.Mocked<IJobRepository>;
   let jobMock: jest.Mocked<IJobRepository>;
 
 
   beforeEach(async () => {
   beforeEach(async () => {
     delete process.env.IMMICH_CONFIG_FILE;
     delete process.env.IMMICH_CONFIG_FILE;
     configMock = newSystemConfigRepositoryMock();
     configMock = newSystemConfigRepositoryMock();
+    communicationMock = newCommunicationRepositoryMock();
     jobMock = newJobRepositoryMock();
     jobMock = newJobRepositoryMock();
-    sut = new SystemConfigService(configMock, jobMock);
+    sut = new SystemConfigService(configMock, communicationMock, jobMock);
   });
   });
 
 
   it('should work', () => {
   it('should work', () => {
@@ -157,6 +165,7 @@ describe(SystemConfigService.name, () => {
       configMock.load.mockResolvedValue([
       configMock.load.mockResolvedValue([
         { key: SystemConfigKey.FFMPEG_CRF, value: 30 },
         { key: SystemConfigKey.FFMPEG_CRF, value: 30 },
         { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
         { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
+        { key: SystemConfigKey.TRASH_DAYS, value: 10 },
       ]);
       ]);
 
 
       await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
       await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
@@ -164,7 +173,7 @@ describe(SystemConfigService.name, () => {
 
 
     it('should load the config from a file', async () => {
     it('should load the config from a file', async () => {
       process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
       process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
-      const partialConfig = { ffmpeg: { crf: 30 }, oauth: { autoLaunch: true } };
+      const partialConfig = { ffmpeg: { crf: 30 }, oauth: { autoLaunch: true }, trash: { days: 10 } };
       configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify(partialConfig)));
       configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify(partialConfig)));
 
 
       await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
       await expect(sut.getConfig()).resolves.toEqual(updatedConfig);

+ 3 - 0
server/src/domain/system-config/system-config.service.ts

@@ -1,5 +1,6 @@
 import { Inject, Injectable } from '@nestjs/common';
 import { Inject, Injectable } from '@nestjs/common';
 import { ISystemConfigRepository } from '.';
 import { ISystemConfigRepository } from '.';
+import { CommunicationEvent, ICommunicationRepository } from '../communication';
 import { IJobRepository, JobName } from '../job';
 import { IJobRepository, JobName } from '../job';
 import { SystemConfigDto, mapConfig } from './dto/system-config.dto';
 import { SystemConfigDto, mapConfig } from './dto/system-config.dto';
 import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto';
 import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto';
@@ -20,6 +21,7 @@ export class SystemConfigService {
   private core: SystemConfigCore;
   private core: SystemConfigCore;
   constructor(
   constructor(
     @Inject(ISystemConfigRepository) repository: ISystemConfigRepository,
     @Inject(ISystemConfigRepository) repository: ISystemConfigRepository,
+    @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
   ) {
   ) {
     this.core = new SystemConfigCore(repository);
     this.core = new SystemConfigCore(repository);
@@ -42,6 +44,7 @@ export class SystemConfigService {
   async updateConfig(dto: SystemConfigDto): Promise<SystemConfigDto> {
   async updateConfig(dto: SystemConfigDto): Promise<SystemConfigDto> {
     const config = await this.core.updateConfig(dto);
     const config = await this.core.updateConfig(dto);
     await this.jobRepository.queue({ name: JobName.SYSTEM_CONFIG_CHANGE });
     await this.jobRepository.queue({ name: JobName.SYSTEM_CONFIG_CHANGE });
+    this.communicationRepository.broadcast(CommunicationEvent.CONFIG_UPDATE, {});
     return mapConfig(config);
     return mapConfig(config);
   }
   }
 
 

+ 5 - 5
server/src/immich/api-v1/asset/asset-repository.ts

@@ -23,7 +23,6 @@ export interface AssetOwnerCheck extends AssetCheck {
 export interface IAssetRepository {
 export interface IAssetRepository {
   get(id: string): Promise<AssetEntity | null>;
   get(id: string): Promise<AssetEntity | null>;
   create(asset: AssetCreate): Promise<AssetEntity>;
   create(asset: AssetCreate): Promise<AssetEntity>;
-  remove(asset: AssetEntity): Promise<void>;
   getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
   getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
   getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
   getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
   getById(assetId: string): Promise<AssetEntity>;
   getById(assetId: string): Promise<AssetEntity>;
@@ -111,6 +110,8 @@ export class AssetRepository implements IAssetRepository {
           person: true,
           person: true,
         },
         },
       },
       },
+      // We are specifically asking for this asset. Return it even if it is soft deleted
+      withDeleted: true,
     });
     });
   }
   }
 
 
@@ -135,6 +136,7 @@ export class AssetRepository implements IAssetRepository {
       order: {
       order: {
         fileCreatedAt: 'DESC',
         fileCreatedAt: 'DESC',
       },
       },
+      withDeleted: true,
     });
     });
   }
   }
 
 
@@ -147,6 +149,7 @@ export class AssetRepository implements IAssetRepository {
         },
         },
         library: true,
         library: true,
       },
       },
+      withDeleted: true,
     });
     });
   }
   }
 
 
@@ -154,10 +157,6 @@ export class AssetRepository implements IAssetRepository {
     return this.assetRepository.save(asset);
     return this.assetRepository.save(asset);
   }
   }
 
 
-  async remove(asset: AssetEntity): Promise<void> {
-    await this.assetRepository.remove(asset);
-  }
-
   /**
   /**
    * Get assets by device's Id on the database
    * Get assets by device's Id on the database
    * @param ownerId
    * @param ownerId
@@ -194,6 +193,7 @@ export class AssetRepository implements IAssetRepository {
         ownerId,
         ownerId,
         checksum: In(checksums),
         checksum: In(checksums),
       },
       },
+      withDeleted: true,
     });
     });
   }
   }
 
 

+ 0 - 11
server/src/immich/api-v1/asset/asset.controller.ts

@@ -2,7 +2,6 @@ import { AssetResponseDto, AuthUserDto } from '@app/domain';
 import {
 import {
   Body,
   Body,
   Controller,
   Controller,
-  Delete,
   Get,
   Get,
   HttpCode,
   HttpCode,
   HttpStatus,
   HttpStatus,
@@ -27,7 +26,6 @@ import { AssetSearchDto } from './dto/asset-search.dto';
 import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
 import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
 import { CreateAssetDto, ImportAssetDto } from './dto/create-asset.dto';
 import { CreateAssetDto, ImportAssetDto } from './dto/create-asset.dto';
-import { DeleteAssetDto } from './dto/delete-asset.dto';
 import { DeviceIdDto } from './dto/device-id.dto';
 import { DeviceIdDto } from './dto/device-id.dto';
 import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto';
 import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto';
 import { SearchAssetDto } from './dto/search-asset.dto';
 import { SearchAssetDto } from './dto/search-asset.dto';
@@ -38,7 +36,6 @@ import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-a
 import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
 import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
 import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
 import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
 import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
 import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
-import { DeleteAssetResponseDto } from './response-dto/delete-asset-response.dto';
 
 
 interface UploadFiles {
 interface UploadFiles {
   assetData: ImmichFile[];
   assetData: ImmichFile[];
@@ -192,14 +189,6 @@ export class AssetController {
     return this.assetService.getAssetById(authUser, id);
     return this.assetService.getAssetById(authUser, id);
   }
   }
 
 
-  @Delete('/')
-  deleteAsset(
-    @AuthUser() authUser: AuthUserDto,
-    @Body(ValidationPipe) dto: DeleteAssetDto,
-  ): Promise<DeleteAssetResponseDto[]> {
-    return this.assetService.deleteAll(authUser, dto);
-  }
-
   /**
   /**
    * Check duplicated asset before uploading - for Web upload used
    * Check duplicated asset before uploading - for Web upload used
    */
    */

+ 1 - 0
server/src/immich/api-v1/asset/asset.core.ts

@@ -30,6 +30,7 @@ export class AssetCore {
       fileCreatedAt: dto.fileCreatedAt,
       fileCreatedAt: dto.fileCreatedAt,
       fileModifiedAt: dto.fileModifiedAt,
       fileModifiedAt: dto.fileModifiedAt,
       localDateTime: dto.fileCreatedAt,
       localDateTime: dto.fileCreatedAt,
+      deletedAt: null,
 
 
       type: mimeTypes.assetType(file.originalPath),
       type: mimeTypes.assetType(file.originalPath),
       isFavorite: dto.isFavorite,
       isFavorite: dto.isFavorite,

+ 0 - 128
server/src/immich/api-v1/asset/asset.service.spec.ts

@@ -6,7 +6,6 @@ import {
   assetStub,
   assetStub,
   authStub,
   authStub,
   fileStub,
   fileStub,
-  libraryStub,
   newAccessRepositoryMock,
   newAccessRepositoryMock,
   newCryptoRepositoryMock,
   newCryptoRepositoryMock,
   newJobRepositoryMock,
   newJobRepositoryMock,
@@ -98,7 +97,6 @@ describe('AssetService', () => {
     assetRepositoryMock = {
     assetRepositoryMock = {
       get: jest.fn(),
       get: jest.fn(),
       create: jest.fn(),
       create: jest.fn(),
-      remove: jest.fn(),
 
 
       getAllByUserId: jest.fn(),
       getAllByUserId: jest.fn(),
       getAllByDeviceId: jest.fn(),
       getAllByDeviceId: jest.fn(),
@@ -212,132 +210,6 @@ describe('AssetService', () => {
     expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
     expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
   });
   });
 
 
-  describe('deleteAll', () => {
-    it('should return failed status when an asset is missing', async () => {
-      assetRepositoryMock.get.mockResolvedValue(null);
-      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
-
-      await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
-        { id: 'asset1', status: 'FAILED' },
-      ]);
-
-      expect(jobMock.queue).not.toHaveBeenCalled();
-    });
-
-    it('should return failed status a delete fails', async () => {
-      assetRepositoryMock.get.mockResolvedValue({
-        id: 'asset1',
-        library: libraryStub.uploadLibrary1,
-      } as AssetEntity);
-      assetRepositoryMock.remove.mockRejectedValue('delete failed');
-      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
-
-      await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
-        { id: 'asset1', status: 'FAILED' },
-      ]);
-
-      expect(jobMock.queue).not.toHaveBeenCalled();
-    });
-
-    it('should delete a live photo', async () => {
-      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
-
-      await expect(sut.deleteAll(authStub.user1, { ids: [assetStub.livePhotoStillAsset.id] })).resolves.toEqual([
-        { id: assetStub.livePhotoStillAsset.id, status: 'SUCCESS' },
-        { id: assetStub.livePhotoMotionAsset.id, status: 'SUCCESS' },
-      ]);
-
-      expect(jobMock.queue).toHaveBeenCalledWith({
-        name: JobName.DELETE_FILES,
-        data: {
-          files: [
-            'fake_path/asset_1.jpeg',
-            undefined,
-            undefined,
-            undefined,
-            undefined,
-            'fake_path/asset_1.mp4',
-            undefined,
-            undefined,
-            undefined,
-            undefined,
-          ],
-        },
-      });
-    });
-
-    it('should delete a batch of assets', async () => {
-      const asset1 = {
-        id: 'asset1',
-        originalPath: 'original-path-1',
-        resizePath: 'resize-path-1',
-        webpPath: 'web-path-1',
-        library: libraryStub.uploadLibrary1,
-      };
-
-      const asset2 = {
-        id: 'asset2',
-        originalPath: 'original-path-2',
-        resizePath: 'resize-path-2',
-        webpPath: 'web-path-2',
-        encodedVideoPath: 'encoded-video-path-2',
-        library: libraryStub.uploadLibrary1,
-      };
-
-      // Can't be deleted since it's external
-      const asset3 = {
-        id: 'asset3',
-        originalPath: 'original-path-3',
-        resizePath: 'resize-path-3',
-        webpPath: 'web-path-3',
-        encodedVideoPath: 'encoded-video-path-2',
-        library: libraryStub.externalLibrary1,
-      };
-
-      when(assetRepositoryMock.get)
-        .calledWith(asset1.id)
-        .mockResolvedValue(asset1 as AssetEntity);
-      when(assetRepositoryMock.get)
-        .calledWith(asset2.id)
-        .mockResolvedValue(asset2 as AssetEntity);
-      when(assetRepositoryMock.get)
-        .calledWith(asset3.id)
-        .mockResolvedValue(asset3 as AssetEntity);
-
-      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
-
-      await expect(sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2', 'asset3'] })).resolves.toEqual([
-        { id: 'asset1', status: 'SUCCESS' },
-        { id: 'asset2', status: 'SUCCESS' },
-        { id: 'asset3', status: 'FAILED' },
-      ]);
-
-      expect(jobMock.queue.mock.calls).toEqual([
-        [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: ['asset1'] } }],
-        [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: ['asset2'] } }],
-        [
-          {
-            name: JobName.DELETE_FILES,
-            data: {
-              files: [
-                'original-path-1',
-                'web-path-1',
-                'resize-path-1',
-                undefined,
-                undefined,
-                'original-path-2',
-                'web-path-2',
-                'resize-path-2',
-                'encoded-video-path-2',
-                undefined,
-              ],
-            },
-          },
-        ],
-      ]);
-    });
-  });
-
   describe('bulkUploadCheck', () => {
   describe('bulkUploadCheck', () => {
     it('should accept hex and base64 checksums', async () => {
     it('should accept hex and base64 checksums', async () => {
       const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
       const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');

+ 0 - 62
server/src/immich/api-v1/asset/asset.service.ts

@@ -37,7 +37,6 @@ import { AssetSearchDto } from './dto/asset-search.dto';
 import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
 import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
 import { CreateAssetDto, ImportAssetDto } from './dto/create-asset.dto';
 import { CreateAssetDto, ImportAssetDto } from './dto/create-asset.dto';
-import { DeleteAssetDto } from './dto/delete-asset.dto';
 import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
 import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
 import { SearchAssetDto } from './dto/search-asset.dto';
 import { SearchAssetDto } from './dto/search-asset.dto';
 import { SearchPropertiesDto } from './dto/search-properties.dto';
 import { SearchPropertiesDto } from './dto/search-properties.dto';
@@ -52,7 +51,6 @@ import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-a
 import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
 import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
 import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
 import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
 import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
 import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
-import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
 
 
 @Injectable()
 @Injectable()
 export class AssetService {
 export class AssetService {
@@ -246,66 +244,6 @@ export class AssetService {
     await this.sendFile(res, filepath);
     await this.sendFile(res, filepath);
   }
   }
 
 
-  public async deleteAll(authUser: AuthUserDto, dto: DeleteAssetDto): Promise<DeleteAssetResponseDto[]> {
-    const deleteQueue: Array<string | null> = [];
-    const result: DeleteAssetResponseDto[] = [];
-
-    const ids = dto.ids.slice();
-    for (const id of ids) {
-      const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_DELETE, id);
-      if (!hasAccess) {
-        result.push({ id, status: DeleteAssetStatusEnum.FAILED });
-        continue;
-      }
-
-      const asset = await this._assetRepository.get(id);
-      if (!asset || !asset.library || asset.library.type === LibraryType.EXTERNAL) {
-        // We don't allow deletions assets belong to an external library
-        result.push({ id, status: DeleteAssetStatusEnum.FAILED });
-        continue;
-      }
-
-      try {
-        if (asset.faces) {
-          await Promise.all(
-            asset.faces.map(({ assetId, personId }) =>
-              this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId } }),
-            ),
-          );
-        }
-
-        await this._assetRepository.remove(asset);
-        await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [id] } });
-
-        result.push({ id, status: DeleteAssetStatusEnum.SUCCESS });
-
-        if (!asset.isReadOnly) {
-          deleteQueue.push(
-            asset.originalPath,
-            asset.webpPath,
-            asset.resizePath,
-            asset.encodedVideoPath,
-            asset.sidecarPath,
-          );
-        }
-
-        // TODO refactor this to use cascades
-        if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) {
-          ids.push(asset.livePhotoVideoId);
-        }
-      } catch (error) {
-        this.logger.error(`Error deleting asset ${id}`, error);
-        result.push({ id, status: DeleteAssetStatusEnum.FAILED });
-      }
-    }
-
-    if (deleteQueue.length > 0) {
-      await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: deleteQueue } });
-    }
-
-    return result;
-  }
-
   async getAssetSearchTerm(authUser: AuthUserDto): Promise<string[]> {
   async getAssetSearchTerm(authUser: AuthUserDto): Promise<string[]> {
     const possibleSearchTerm = new Set<string>();
     const possibleSearchTerm = new Set<string>();
 
 

+ 0 - 17
server/src/immich/api-v1/asset/dto/delete-asset.dto.ts

@@ -1,17 +0,0 @@
-import { ApiProperty } from '@nestjs/swagger';
-import { IsNotEmpty } from 'class-validator';
-
-export class DeleteAssetDto {
-  @IsNotEmpty()
-  @ApiProperty({
-    isArray: true,
-    type: String,
-    title: 'Array of asset IDs to delete',
-    example: [
-      'bf973405-3f2a-48d2-a687-2ed4167164be',
-      'dd41870b-5d00-46d2-924e-1d8489a0aa0f',
-      'fad77c3f-deef-4e7e-9608-14c1aa4e559a',
-    ],
-  })
-  ids!: string[];
-}

+ 0 - 13
server/src/immich/api-v1/asset/response-dto/delete-asset-response.dto.ts

@@ -1,13 +0,0 @@
-import { ApiProperty } from '@nestjs/swagger';
-
-export enum DeleteAssetStatusEnum {
-  SUCCESS = 'SUCCESS',
-  FAILED = 'FAILED',
-}
-
-export class DeleteAssetResponseDto {
-  id!: string;
-
-  @ApiProperty({ type: 'string', enum: DeleteAssetStatusEnum, enumName: 'DeleteAssetStatus' })
-  status!: DeleteAssetStatusEnum;
-}

+ 40 - 1
server/src/immich/controllers/asset.controller.ts

@@ -1,4 +1,5 @@
 import {
 import {
+  AssetBulkDeleteDto,
   AssetBulkUpdateDto,
   AssetBulkUpdateDto,
   AssetIdsDto,
   AssetIdsDto,
   AssetJobsDto,
   AssetJobsDto,
@@ -7,6 +8,7 @@ import {
   AssetStatsDto,
   AssetStatsDto,
   AssetStatsResponseDto,
   AssetStatsResponseDto,
   AuthUserDto,
   AuthUserDto,
+  BulkIdsDto,
   DownloadInfoDto,
   DownloadInfoDto,
   DownloadResponseDto,
   DownloadResponseDto,
   MapMarkerDto,
   MapMarkerDto,
@@ -17,9 +19,22 @@ import {
   TimeBucketAssetDto,
   TimeBucketAssetDto,
   TimeBucketDto,
   TimeBucketDto,
   TimeBucketResponseDto,
   TimeBucketResponseDto,
+  TrashAction,
   UpdateAssetDto as UpdateDto,
   UpdateAssetDto as UpdateDto,
 } from '@app/domain';
 } from '@app/domain';
-import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put, Query, StreamableFile } from '@nestjs/common';
+import {
+  Body,
+  Controller,
+  Delete,
+  Get,
+  HttpCode,
+  HttpStatus,
+  Param,
+  Post,
+  Put,
+  Query,
+  StreamableFile,
+} from '@nestjs/common';
 import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
 import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
 import { AuthUser, Authenticated, SharedLinkRoute } from '../app.guard';
 import { AuthUser, Authenticated, SharedLinkRoute } from '../app.guard';
 import { UseValidation, asStreamableFile } from '../app.utils';
 import { UseValidation, asStreamableFile } from '../app.utils';
@@ -98,6 +113,30 @@ export class AssetController {
     return this.service.updateAll(authUser, dto);
     return this.service.updateAll(authUser, dto);
   }
   }
 
 
+  @Delete()
+  @HttpCode(HttpStatus.NO_CONTENT)
+  deleteAssets(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetBulkDeleteDto): Promise<void> {
+    return this.service.deleteAll(authUser, dto);
+  }
+
+  @Post('restore')
+  @HttpCode(HttpStatus.NO_CONTENT)
+  restoreAssets(@AuthUser() authUser: AuthUserDto, @Body() dto: BulkIdsDto): Promise<void> {
+    return this.service.restoreAll(authUser, dto);
+  }
+
+  @Post('trash/empty')
+  @HttpCode(HttpStatus.NO_CONTENT)
+  emptyTrash(@AuthUser() authUser: AuthUserDto): Promise<void> {
+    return this.service.handleTrashAction(authUser, TrashAction.EMPTY_ALL);
+  }
+
+  @Post('trash/restore')
+  @HttpCode(HttpStatus.NO_CONTENT)
+  restoreTrash(@AuthUser() authUser: AuthUserDto): Promise<void> {
+    return this.service.handleTrashAction(authUser, TrashAction.RESTORE_ALL);
+  }
+
   @Put(':id')
   @Put(':id')
   updateAsset(
   updateAsset(
     @AuthUser() authUser: AuthUserDto,
     @AuthUser() authUser: AuthUserDto,

+ 4 - 0
server/src/infra/entities/asset.entity.ts

@@ -1,6 +1,7 @@
 import {
 import {
   Column,
   Column,
   CreateDateColumn,
   CreateDateColumn,
+  DeleteDateColumn,
   Entity,
   Entity,
   Index,
   Index,
   JoinColumn,
   JoinColumn,
@@ -77,6 +78,9 @@ export class AssetEntity {
   @UpdateDateColumn({ type: 'timestamptz' })
   @UpdateDateColumn({ type: 'timestamptz' })
   updatedAt!: Date;
   updatedAt!: Date;
 
 
+  @DeleteDateColumn({ type: 'timestamptz', nullable: true })
+  deletedAt!: Date | null;
+
   @Column({ type: 'timestamptz' })
   @Column({ type: 'timestamptz' })
   fileCreatedAt!: Date;
   fileCreatedAt!: Date;
 
 

+ 7 - 0
server/src/infra/entities/system-config.entity.ts

@@ -87,6 +87,9 @@ export enum SystemConfigKey {
   THUMBNAIL_JPEG_SIZE = 'thumbnail.jpegSize',
   THUMBNAIL_JPEG_SIZE = 'thumbnail.jpegSize',
   THUMBNAIL_QUALITY = 'thumbnail.quality',
   THUMBNAIL_QUALITY = 'thumbnail.quality',
   THUMBNAIL_COLORSPACE = 'thumbnail.colorspace',
   THUMBNAIL_COLORSPACE = 'thumbnail.colorspace',
+
+  TRASH_ENABLED = 'trash.enabled',
+  TRASH_DAYS = 'trash.days',
 }
 }
 
 
 export enum TranscodePolicy {
 export enum TranscodePolicy {
@@ -214,4 +217,8 @@ export interface SystemConfig {
     quality: number;
     quality: number;
     colorspace: Colorspace;
     colorspace: Colorspace;
   };
   };
+  trash: {
+    enabled: boolean;
+    days: number;
+  };
 }
 }

+ 14 - 0
server/src/infra/migrations/1694204416744-AddAssetDeletedAtColumn.ts

@@ -0,0 +1,14 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class AddAssetDeletedAtColumn1694204416744 implements MigrationInterface {
+    name = 'AddAssetDeletedAtColumn1694204416744'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "assets" ADD "deletedAt" TIMESTAMP WITH TIME ZONE`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "deletedAt"`);
+    }
+
+}

+ 1 - 0
server/src/infra/repositories/access.repository.ts

@@ -86,6 +86,7 @@ export class AccessRepository implements IAccessRepository {
           id: assetId,
           id: assetId,
           ownerId: userId,
           ownerId: userId,
         },
         },
+        withDeleted: true,
       });
       });
     },
     },
 
 

+ 45 - 26
server/src/infra/repositories/asset.repository.ts

@@ -19,7 +19,7 @@ import {
 import { Injectable } from '@nestjs/common';
 import { Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { InjectRepository } from '@nestjs/typeorm';
 import { DateTime } from 'luxon';
 import { DateTime } from 'luxon';
-import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm';
+import { And, FindOptionsRelations, FindOptionsWhere, In, IsNull, LessThan, Not, Repository } from 'typeorm';
 import { AssetEntity, AssetType, ExifEntity } from '../entities';
 import { AssetEntity, AssetType, ExifEntity } from '../entities';
 import OptionalBetween from '../utils/optional-between.util';
 import OptionalBetween from '../utils/optional-between.util';
 import { paginate } from '../utils/pagination.util';
 import { paginate } from '../utils/pagination.util';
@@ -109,6 +109,7 @@ export class AssetRepository implements IAssetRepository {
           person: true,
           person: true,
         },
         },
       },
       },
+      withDeleted: true,
     });
     });
   }
   }
 
 
@@ -130,15 +131,17 @@ export class AssetRepository implements IAssetRepository {
     });
     });
   }
   }
 
 
-  getByUserId(pagination: PaginationOptions, userId: string): Paginated<AssetEntity> {
+  getByUserId(pagination: PaginationOptions, userId: string, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
     return paginate(this.repository, pagination, {
     return paginate(this.repository, pagination, {
       where: {
       where: {
         ownerId: userId,
         ownerId: userId,
-        isVisible: true,
+        isVisible: options.isVisible,
+        deletedAt: options.trashedBefore ? And(Not(IsNull()), LessThan(options.trashedBefore)) : undefined,
       },
       },
       relations: {
       relations: {
         exifInfo: true,
         exifInfo: true,
       },
       },
+      withDeleted: !!options.trashedBefore,
     });
     });
   }
   }
 
 
@@ -154,32 +157,12 @@ export class AssetRepository implements IAssetRepository {
     });
     });
   }
   }
 
 
-  getById(assetId: string): Promise<AssetEntity> {
-    return this.repository.findOneOrFail({
-      where: {
-        id: assetId,
-      },
-      relations: {
-        exifInfo: true,
-        tags: true,
-        sharedLinks: true,
-        smartInfo: true,
-        faces: {
-          person: true,
-        },
-      },
-    });
-  }
-
-  remove(asset: AssetEntity): Promise<AssetEntity> {
-    return this.repository.remove(asset);
-  }
-
   getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
   getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
     return paginate(this.repository, pagination, {
     return paginate(this.repository, pagination, {
       where: {
       where: {
         isVisible: options.isVisible,
         isVisible: options.isVisible,
         type: options.type,
         type: options.type,
+        deletedAt: options.trashedBefore ? And(Not(IsNull()), LessThan(options.trashedBefore)) : undefined,
       },
       },
       relations: {
       relations: {
         exifInfo: true,
         exifInfo: true,
@@ -189,6 +172,7 @@ export class AssetRepository implements IAssetRepository {
           person: true,
           person: true,
         },
         },
       },
       },
+      withDeleted: !!options.trashedBefore,
       order: {
       order: {
         // Ensures correct order when paginating
         // Ensures correct order when paginating
         createdAt: options.order ?? 'ASC',
         createdAt: options.order ?? 'ASC',
@@ -196,10 +180,32 @@ export class AssetRepository implements IAssetRepository {
     });
     });
   }
   }
 
 
+  getById(id: string): Promise<AssetEntity | null> {
+    return this.repository.findOne({
+      where: { id },
+      relations: {
+        faces: {
+          person: true,
+        },
+        library: true,
+      },
+      // We are specifically asking for this asset. Return it even if it is soft deleted
+      withDeleted: true,
+    });
+  }
+
   async updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void> {
   async updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void> {
     await this.repository.update({ id: In(ids) }, options);
     await this.repository.update({ id: In(ids) }, options);
   }
   }
 
 
+  async softDeleteAll(ids: string[]): Promise<void> {
+    await this.repository.softDelete({ id: In(ids), isExternal: false });
+  }
+
+  async restoreAll(ids: string[]): Promise<void> {
+    await this.repository.restore({ id: In(ids) });
+  }
+
   async save(asset: Partial<AssetEntity>): Promise<AssetEntity> {
   async save(asset: Partial<AssetEntity>): Promise<AssetEntity> {
     const { id } = await this.repository.save(asset);
     const { id } = await this.repository.save(asset);
     return this.repository.findOneOrFail({
     return this.repository.findOneOrFail({
@@ -213,9 +219,14 @@ export class AssetRepository implements IAssetRepository {
           person: true,
           person: true,
         },
         },
       },
       },
+      withDeleted: true,
     });
     });
   }
   }
 
 
+  async remove(asset: AssetEntity): Promise<void> {
+    await this.repository.remove(asset);
+  }
+
   getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null> {
   getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null> {
     return this.repository.findOne({ where: { ownerId: userId, checksum } });
     return this.repository.findOne({ where: { ownerId: userId, checksum } });
   }
   }
@@ -424,7 +435,7 @@ export class AssetRepository implements IAssetRepository {
       .andWhere('asset.isVisible = true')
       .andWhere('asset.isVisible = true')
       .groupBy('asset.type');
       .groupBy('asset.type');
 
 
-    const { isArchived, isFavorite } = options;
+    const { isArchived, isFavorite, isTrashed } = options;
     if (isArchived !== undefined) {
     if (isArchived !== undefined) {
       builder = builder.andWhere(`asset.isArchived = :isArchived`, { isArchived });
       builder = builder.andWhere(`asset.isArchived = :isArchived`, { isArchived });
     }
     }
@@ -433,6 +444,10 @@ export class AssetRepository implements IAssetRepository {
       builder = builder.andWhere(`asset.isFavorite = :isFavorite`, { isFavorite });
       builder = builder.andWhere(`asset.isFavorite = :isFavorite`, { isFavorite });
     }
     }
 
 
+    if (isTrashed !== undefined) {
+      builder = builder.withDeleted().andWhere(`asset.deletedAt is not null`);
+    }
+
     const items = await builder.getRawMany();
     const items = await builder.getRawMany();
 
 
     const result: AssetStats = {
     const result: AssetStats = {
@@ -481,7 +496,7 @@ export class AssetRepository implements IAssetRepository {
   }
   }
 
 
   private getBuilder(options: TimeBucketOptions) {
   private getBuilder(options: TimeBucketOptions) {
-    const { isArchived, isFavorite, albumId, personId, userId } = options;
+    const { isArchived, isFavorite, isTrashed, albumId, personId, userId } = options;
 
 
     let builder = this.repository
     let builder = this.repository
       .createQueryBuilder('asset')
       .createQueryBuilder('asset')
@@ -504,6 +519,10 @@ export class AssetRepository implements IAssetRepository {
       builder = builder.andWhere('asset.isFavorite = :isFavorite', { isFavorite });
       builder = builder.andWhere('asset.isFavorite = :isFavorite', { isFavorite });
     }
     }
 
 
+    if (isTrashed !== undefined) {
+      builder = builder.andWhere(`asset.deletedAt ${isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted();
+    }
+
     if (personId !== undefined) {
     if (personId !== undefined) {
       builder = builder
       builder = builder
         .innerJoin('asset.faces', 'faces')
         .innerJoin('asset.faces', 'faces')

+ 4 - 0
server/src/infra/repositories/communication.repository.ts

@@ -9,4 +9,8 @@ export class CommunicationRepository {
   send(event: CommunicationEvent, userId: string, data: any) {
   send(event: CommunicationEvent, userId: string, data: any) {
     this.ws.server.to(userId).emit(event, JSON.stringify(data));
     this.ws.server.to(userId).emit(event, JSON.stringify(data));
   }
   }
+
+  broadcast(event: CommunicationEvent, data: any) {
+    this.ws.server.emit(event, data);
+  }
 }
 }

+ 1 - 0
server/src/infra/repositories/person.repository.ts

@@ -67,6 +67,7 @@ export class PersonRepository implements IPersonRepository {
       .createQueryBuilder('person')
       .createQueryBuilder('person')
       .leftJoin('person.faces', 'face')
       .leftJoin('person.faces', 'face')
       .where('person.ownerId = :userId', { userId })
       .where('person.ownerId = :userId', { userId })
+      .innerJoin('face.asset', 'asset')
       .orderBy('person.isHidden', 'ASC')
       .orderBy('person.isHidden', 'ASC')
       .addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC')
       .addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC')
       .addOrderBy('COUNT(face.assetId)', 'DESC')
       .addOrderBy('COUNT(face.assetId)', 'DESC')

+ 4 - 0
server/src/microservices/app.service.ts

@@ -1,4 +1,5 @@
 import {
 import {
+  AssetService,
   AuditService,
   AuditService,
   IDeleteFilesJob,
   IDeleteFilesJob,
   JobName,
   JobName,
@@ -23,6 +24,7 @@ export class AppService {
 
 
   constructor(
   constructor(
     private jobService: JobService,
     private jobService: JobService,
+    private assetService: AssetService,
     private mediaService: MediaService,
     private mediaService: MediaService,
     private metadataService: MetadataService,
     private metadataService: MetadataService,
     private personService: PersonService,
     private personService: PersonService,
@@ -38,6 +40,8 @@ export class AppService {
 
 
   async init() {
   async init() {
     await this.jobService.registerHandlers({
     await this.jobService.registerHandlers({
+      [JobName.ASSET_DELETION]: (data) => this.assetService.handleAssetDeletion(data),
+      [JobName.ASSET_DELETION_CHECK]: () => this.assetService.handleAssetDeletionCheck(),
       [JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data),
       [JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data),
       [JobName.CLEAN_OLD_AUDIT_LOGS]: () => this.auditService.handleCleanup(),
       [JobName.CLEAN_OLD_AUDIT_LOGS]: () => this.auditService.handleCleanup(),
       [JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(),
       [JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(),

+ 2 - 0
server/test/e2e/server-info.e2e-spec.ts

@@ -92,6 +92,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
         search: false,
         search: false,
         sidecar: true,
         sidecar: true,
         tagImage: true,
         tagImage: true,
+        trash: true,
       });
       });
     });
     });
   });
   });
@@ -104,6 +105,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
         loginPageMessage: '',
         loginPageMessage: '',
         oauthButtonText: 'Login with OAuth',
         oauthButtonText: 'Login with OAuth',
         mapTileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
         mapTileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
+        trashDays: 30,
       });
       });
     });
     });
   });
   });

+ 18 - 1
server/test/fixtures/asset.stub.ts

@@ -35,6 +35,7 @@ export const assetStub = {
     faces: [],
     faces: [],
     sidecarPath: null,
     sidecarPath: null,
     isReadOnly: false,
     isReadOnly: false,
+    deletedAt: null,
     isOffline: false,
     isOffline: false,
     isExternal: false,
     isExternal: false,
     libraryId: 'library-id',
     libraryId: 'library-id',
@@ -77,6 +78,7 @@ export const assetStub = {
     exifInfo: {
     exifInfo: {
       fileSizeInByte: 123_000,
       fileSizeInByte: 123_000,
     } as ExifEntity,
     } as ExifEntity,
+    deletedAt: null,
   }),
   }),
   noThumbhash: Object.freeze<AssetEntity>({
   noThumbhash: Object.freeze<AssetEntity>({
     id: 'asset-id',
     id: 'asset-id',
@@ -112,6 +114,7 @@ export const assetStub = {
     originalFileName: 'asset-id.ext',
     originalFileName: 'asset-id.ext',
     faces: [],
     faces: [],
     sidecarPath: null,
     sidecarPath: null,
+    deletedAt: null,
   }),
   }),
   image: Object.freeze<AssetEntity>({
   image: Object.freeze<AssetEntity>({
     id: 'asset-id',
     id: 'asset-id',
@@ -146,6 +149,7 @@ export const assetStub = {
     sharedLinks: [],
     sharedLinks: [],
     originalFileName: 'asset-id.jpg',
     originalFileName: 'asset-id.jpg',
     faces: [],
     faces: [],
+    deletedAt: null,
     sidecarPath: null,
     sidecarPath: null,
     exifInfo: {
     exifInfo: {
       fileSizeInByte: 5_000,
       fileSizeInByte: 5_000,
@@ -179,11 +183,12 @@ export const assetStub = {
     livePhotoVideoId: null,
     livePhotoVideoId: null,
     isOffline: false,
     isOffline: false,
     libraryId: 'library-id',
     libraryId: 'library-id',
-    library: libraryStub.uploadLibrary1,
+    library: libraryStub.externalLibrary1,
     tags: [],
     tags: [],
     sharedLinks: [],
     sharedLinks: [],
     originalFileName: 'asset-id.jpg',
     originalFileName: 'asset-id.jpg',
     faces: [],
     faces: [],
+    deletedAt: null,
     sidecarPath: null,
     sidecarPath: null,
     exifInfo: {
     exifInfo: {
       fileSizeInByte: 5_000,
       fileSizeInByte: 5_000,
@@ -226,6 +231,7 @@ export const assetStub = {
     exifInfo: {
     exifInfo: {
       fileSizeInByte: 5_000,
       fileSizeInByte: 5_000,
     } as ExifEntity,
     } as ExifEntity,
+    deletedAt: null,
   }),
   }),
   image1: Object.freeze<AssetEntity>({
   image1: Object.freeze<AssetEntity>({
     id: 'asset-id-1',
     id: 'asset-id-1',
@@ -244,6 +250,7 @@ export const assetStub = {
     encodedVideoPath: null,
     encodedVideoPath: null,
     createdAt: new Date('2023-02-23T05:06:29.716Z'),
     createdAt: new Date('2023-02-23T05:06:29.716Z'),
     updatedAt: new Date('2023-02-23T05:06:29.716Z'),
     updatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    deletedAt: null,
     localDateTime: new Date('2023-02-23T05:06:29.716Z'),
     localDateTime: new Date('2023-02-23T05:06:29.716Z'),
     isFavorite: true,
     isFavorite: true,
     isArchived: false,
     isArchived: false,
@@ -302,6 +309,7 @@ export const assetStub = {
     exifInfo: {
     exifInfo: {
       fileSizeInByte: 5_000,
       fileSizeInByte: 5_000,
     } as ExifEntity,
     } as ExifEntity,
+    deletedAt: null,
   }),
   }),
   video: Object.freeze<AssetEntity>({
   video: Object.freeze<AssetEntity>({
     id: 'asset-id',
     id: 'asset-id',
@@ -340,6 +348,7 @@ export const assetStub = {
     exifInfo: {
     exifInfo: {
       fileSizeInByte: 100_000,
       fileSizeInByte: 100_000,
     } as ExifEntity,
     } as ExifEntity,
+    deletedAt: null,
   }),
   }),
   livePhotoMotionAsset: Object.freeze({
   livePhotoMotionAsset: Object.freeze({
     id: 'live-photo-motion-asset',
     id: 'live-photo-motion-asset',
@@ -411,6 +420,7 @@ export const assetStub = {
       longitude: 100,
       longitude: 100,
       fileSizeInByte: 23_456,
       fileSizeInByte: 23_456,
     } as ExifEntity,
     } as ExifEntity,
+    deletedAt: null,
   }),
   }),
   sidecar: Object.freeze<AssetEntity>({
   sidecar: Object.freeze<AssetEntity>({
     id: 'asset-id',
     id: 'asset-id',
@@ -446,5 +456,12 @@ export const assetStub = {
     originalFileName: 'asset-id.ext',
     originalFileName: 'asset-id.ext',
     faces: [],
     faces: [],
     sidecarPath: '/original/path.ext.xmp',
     sidecarPath: '/original/path.ext.xmp',
+    deletedAt: null,
+  }),
+  readOnly: Object.freeze({
+    id: 'read-only-asset',
+    isReadOnly: true,
+    libraryId: 'library-id',
+    library: libraryStub.uploadLibrary1,
   }),
   }),
 };
 };

+ 2 - 0
server/test/fixtures/shared-link.stub.ts

@@ -69,6 +69,7 @@ const assetResponse: AssetResponseDto = {
   tags: [],
   tags: [],
   people: [],
   people: [],
   checksum: 'ZmlsZSBoYXNo',
   checksum: 'ZmlsZSBoYXNo',
+  isTrashed: false,
   libraryId: 'library-id',
   libraryId: 'library-id',
 };
 };
 
 
@@ -235,6 +236,7 @@ export const sharedLinkStub = {
           sharedLinks: [],
           sharedLinks: [],
           faces: [],
           faces: [],
           sidecarPath: null,
           sidecarPath: null,
+          deletedAt: null,
         },
         },
       ],
       ],
     },
     },

+ 4 - 2
server/test/repositories/asset.repository.mock.ts

@@ -9,6 +9,7 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
     getByIds: jest.fn().mockResolvedValue([]),
     getByIds: jest.fn().mockResolvedValue([]),
     getByAlbumId: jest.fn(),
     getByAlbumId: jest.fn(),
     getByUserId: jest.fn(),
     getByUserId: jest.fn(),
+    getById: jest.fn(),
     getWithout: jest.fn(),
     getWithout: jest.fn(),
     getByChecksum: jest.fn(),
     getByChecksum: jest.fn(),
     getWith: jest.fn(),
     getWith: jest.fn(),
@@ -18,15 +19,16 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
     getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
     getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
     updateAll: jest.fn(),
     updateAll: jest.fn(),
     getByLibraryId: jest.fn(),
     getByLibraryId: jest.fn(),
-    getById: jest.fn(),
     getByLibraryIdAndOriginalPath: jest.fn(),
     getByLibraryIdAndOriginalPath: jest.fn(),
     deleteAll: jest.fn(),
     deleteAll: jest.fn(),
     save: jest.fn(),
     save: jest.fn(),
+    remove: jest.fn(),
     findLivePhotoMatch: jest.fn(),
     findLivePhotoMatch: jest.fn(),
     getMapMarkers: jest.fn(),
     getMapMarkers: jest.fn(),
     getStatistics: jest.fn(),
     getStatistics: jest.fn(),
     getByTimeBucket: jest.fn(),
     getByTimeBucket: jest.fn(),
     getTimeBuckets: jest.fn(),
     getTimeBuckets: jest.fn(),
-    remove: jest.fn(),
+    restoreAll: jest.fn(),
+    softDeleteAll: jest.fn(),
   };
   };
 };
 };

+ 1 - 0
server/test/repositories/communication.repository.mock.ts

@@ -3,5 +3,6 @@ import { ICommunicationRepository } from '@app/domain';
 export const newCommunicationRepositoryMock = (): jest.Mocked<ICommunicationRepository> => {
 export const newCommunicationRepositoryMock = (): jest.Mocked<ICommunicationRepository> => {
   return {
   return {
     send: jest.fn(),
     send: jest.fn(),
+    broadcast: jest.fn(),
   };
   };
 };
 };

+ 354 - 83
web/src/api/open-api/api.ts

@@ -356,6 +356,25 @@ export interface AllJobStatusResponseDto {
      */
      */
     'videoConversion': JobStatusDto;
     'videoConversion': JobStatusDto;
 }
 }
+/**
+ * 
+ * @export
+ * @interface AssetBulkDeleteDto
+ */
+export interface AssetBulkDeleteDto {
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetBulkDeleteDto
+     */
+    'force'?: boolean;
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof AssetBulkDeleteDto
+     */
+    'ids': Array<string>;
+}
 /**
 /**
  * 
  * 
  * @export
  * @export
@@ -657,6 +676,12 @@ export interface AssetResponseDto {
      * @memberof AssetResponseDto
      * @memberof AssetResponseDto
      */
      */
     'isReadOnly': boolean;
     'isReadOnly': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetResponseDto
+     */
+    'isTrashed': boolean;
     /**
     /**
      * 
      * 
      * @type {string}
      * @type {string}
@@ -1357,54 +1382,6 @@ export interface CuratedObjectsResponseDto {
      */
      */
     'resizePath': string;
     'resizePath': string;
 }
 }
-/**
- * 
- * @export
- * @interface DeleteAssetDto
- */
-export interface DeleteAssetDto {
-    /**
-     * 
-     * @type {Array<string>}
-     * @memberof DeleteAssetDto
-     */
-    'ids': Array<string>;
-}
-/**
- * 
- * @export
- * @interface DeleteAssetResponseDto
- */
-export interface DeleteAssetResponseDto {
-    /**
-     * 
-     * @type {string}
-     * @memberof DeleteAssetResponseDto
-     */
-    'id': string;
-    /**
-     * 
-     * @type {DeleteAssetStatus}
-     * @memberof DeleteAssetResponseDto
-     */
-    'status': DeleteAssetStatus;
-}
-
-
-/**
- * 
- * @export
- * @enum {string}
- */
-
-export const DeleteAssetStatus = {
-    Success: 'SUCCESS',
-    Failed: 'FAILED'
-} as const;
-
-export type DeleteAssetStatus = typeof DeleteAssetStatus[keyof typeof DeleteAssetStatus];
-
-
 /**
 /**
  * 
  * 
  * @export
  * @export
@@ -2623,6 +2600,12 @@ export interface ServerConfigDto {
      * @memberof ServerConfigDto
      * @memberof ServerConfigDto
      */
      */
     'oauthButtonText': string;
     'oauthButtonText': string;
+    /**
+     * 
+     * @type {number}
+     * @memberof ServerConfigDto
+     */
+    'trashDays': number;
 }
 }
 /**
 /**
  * 
  * 
@@ -2696,6 +2679,12 @@ export interface ServerFeaturesDto {
      * @memberof ServerFeaturesDto
      * @memberof ServerFeaturesDto
      */
      */
     'tagImage': boolean;
     'tagImage': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof ServerFeaturesDto
+     */
+    'trash': boolean;
 }
 }
 /**
 /**
  * 
  * 
@@ -3139,6 +3128,12 @@ export interface SystemConfigDto {
      * @memberof SystemConfigDto
      * @memberof SystemConfigDto
      */
      */
     'thumbnail': SystemConfigThumbnailDto;
     'thumbnail': SystemConfigThumbnailDto;
+    /**
+     * 
+     * @type {SystemConfigTrashDto}
+     * @memberof SystemConfigDto
+     */
+    'trash': SystemConfigTrashDto;
 }
 }
 /**
 /**
  * 
  * 
@@ -3594,6 +3589,25 @@ export interface SystemConfigThumbnailDto {
 }
 }
 
 
 
 
+/**
+ * 
+ * @export
+ * @interface SystemConfigTrashDto
+ */
+export interface SystemConfigTrashDto {
+    /**
+     * 
+     * @type {number}
+     * @memberof SystemConfigTrashDto
+     */
+    'days': number;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof SystemConfigTrashDto
+     */
+    'enabled': boolean;
+}
 /**
 /**
  * 
  * 
  * @export
  * @export
@@ -5682,13 +5696,13 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
         },
         },
         /**
         /**
          * 
          * 
-         * @param {DeleteAssetDto} deleteAssetDto 
+         * @param {AssetBulkDeleteDto} assetBulkDeleteDto 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        deleteAsset: async (deleteAssetDto: DeleteAssetDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
-            // verify required parameter 'deleteAssetDto' is not null or undefined
-            assertParamExists('deleteAsset', 'deleteAssetDto', deleteAssetDto)
+        deleteAssets: async (assetBulkDeleteDto: AssetBulkDeleteDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'assetBulkDeleteDto' is not null or undefined
+            assertParamExists('deleteAssets', 'assetBulkDeleteDto', assetBulkDeleteDto)
             const localVarPath = `/asset`;
             const localVarPath = `/asset`;
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -5717,7 +5731,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
-            localVarRequestOptions.data = serializeDataIfNeeded(deleteAssetDto, localVarRequestOptions, configuration)
+            localVarRequestOptions.data = serializeDataIfNeeded(assetBulkDeleteDto, localVarRequestOptions, configuration)
 
 
             return {
             return {
                 url: toPathString(localVarUrlObj),
                 url: toPathString(localVarUrlObj),
@@ -5811,6 +5825,44 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
 
 
 
 
     
     
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        emptyTrash: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/asset/trash/empty`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -5979,10 +6031,11 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
          * 
          * 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {boolean} [isFavorite] 
+         * @param {boolean} [isTrashed] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        getAssetStats: async (isArchived?: boolean, isFavorite?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+        getAssetStats: async (isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             const localVarPath = `/asset/statistics`;
             const localVarPath = `/asset/statistics`;
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -6012,6 +6065,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 localVarQueryParameter['isFavorite'] = isFavorite;
                 localVarQueryParameter['isFavorite'] = isFavorite;
             }
             }
 
 
+            if (isTrashed !== undefined) {
+                localVarQueryParameter['isTrashed'] = isTrashed;
+            }
+
 
 
     
     
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -6084,11 +6141,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
          * @param {string} [personId] 
          * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {boolean} [isFavorite] 
+         * @param {boolean} [isTrashed] 
          * @param {string} [key] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+        getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             // verify required parameter 'size' is not null or undefined
             // verify required parameter 'size' is not null or undefined
             assertParamExists('getByTimeBucket', 'size', size)
             assertParamExists('getByTimeBucket', 'size', size)
             // verify required parameter 'timeBucket' is not null or undefined
             // verify required parameter 'timeBucket' is not null or undefined
@@ -6138,6 +6196,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 localVarQueryParameter['isFavorite'] = isFavorite;
                 localVarQueryParameter['isFavorite'] = isFavorite;
             }
             }
 
 
+            if (isTrashed !== undefined) {
+                localVarQueryParameter['isTrashed'] = isTrashed;
+            }
+
             if (timeBucket !== undefined) {
             if (timeBucket !== undefined) {
                 localVarQueryParameter['timeBucket'] = timeBucket;
                 localVarQueryParameter['timeBucket'] = timeBucket;
             }
             }
@@ -6447,11 +6509,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
          * @param {string} [personId] 
          * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {boolean} [isFavorite] 
+         * @param {boolean} [isTrashed] 
          * @param {string} [key] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+        getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             // verify required parameter 'size' is not null or undefined
             // verify required parameter 'size' is not null or undefined
             assertParamExists('getTimeBuckets', 'size', size)
             assertParamExists('getTimeBuckets', 'size', size)
             const localVarPath = `/asset/time-buckets`;
             const localVarPath = `/asset/time-buckets`;
@@ -6499,6 +6562,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 localVarQueryParameter['isFavorite'] = isFavorite;
                 localVarQueryParameter['isFavorite'] = isFavorite;
             }
             }
 
 
+            if (isTrashed !== undefined) {
+                localVarQueryParameter['isTrashed'] = isTrashed;
+            }
+
             if (key !== undefined) {
             if (key !== undefined) {
                 localVarQueryParameter['key'] = key;
                 localVarQueryParameter['key'] = key;
             }
             }
@@ -6600,6 +6667,88 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 options: localVarRequestOptions,
                 options: localVarRequestOptions,
             };
             };
         },
         },
+        /**
+         * 
+         * @param {BulkIdsDto} bulkIdsDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        restoreAssets: async (bulkIdsDto: BulkIdsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'bulkIdsDto' is not null or undefined
+            assertParamExists('restoreAssets', 'bulkIdsDto', bulkIdsDto)
+            const localVarPath = `/asset/restore`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        restoreTrash: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/asset/trash/restore`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
         /**
         /**
          * 
          * 
          * @param {AssetJobsDto} assetJobsDto 
          * @param {AssetJobsDto} assetJobsDto 
@@ -7014,12 +7163,12 @@ export const AssetApiFp = function(configuration?: Configuration) {
         },
         },
         /**
         /**
          * 
          * 
-         * @param {DeleteAssetDto} deleteAssetDto 
+         * @param {AssetBulkDeleteDto} assetBulkDeleteDto 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        async deleteAsset(deleteAssetDto: DeleteAssetDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<DeleteAssetResponseDto>>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAsset(deleteAssetDto, options);
+        async deleteAssets(assetBulkDeleteDto: AssetBulkDeleteDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAssets(assetBulkDeleteDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
         /**
         /**
@@ -7044,6 +7193,15 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(id, key, options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(id, key, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async emptyTrash(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.emptyTrash(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
         /**
          * Get all AssetEntity belong to the user
          * Get all AssetEntity belong to the user
          * @param {string} [userId] 
          * @param {string} [userId] 
@@ -7083,11 +7241,12 @@ export const AssetApiFp = function(configuration?: Configuration) {
          * 
          * 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {boolean} [isFavorite] 
+         * @param {boolean} [isTrashed] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        async getAssetStats(isArchived?: boolean, isFavorite?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetStatsResponseDto>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetStats(isArchived, isFavorite, options);
+        async getAssetStats(isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetStatsResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetStats(isArchived, isFavorite, isTrashed, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
         /**
         /**
@@ -7111,12 +7270,13 @@ export const AssetApiFp = function(configuration?: Configuration) {
          * @param {string} [personId] 
          * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {boolean} [isFavorite] 
+         * @param {boolean} [isTrashed] 
          * @param {string} [key] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, key, options);
+        async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, key, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
         /**
         /**
@@ -7190,12 +7350,13 @@ export const AssetApiFp = function(configuration?: Configuration) {
          * @param {string} [personId] 
          * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {boolean} [isFavorite] 
+         * @param {boolean} [isTrashed] 
          * @param {string} [key] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<TimeBucketResponseDto>>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, key, options);
+        async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<TimeBucketResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, key, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
         /**
         /**
@@ -7218,6 +7379,25 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
+        /**
+         * 
+         * @param {BulkIdsDto} bulkIdsDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async restoreAssets(bulkIdsDto: BulkIdsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.restoreAssets(bulkIdsDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async restoreTrash(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.restoreTrash(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
         /**
          * 
          * 
          * @param {AssetJobsDto} assetJobsDto 
          * @param {AssetJobsDto} assetJobsDto 
@@ -7336,12 +7516,12 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         },
         },
         /**
         /**
          * 
          * 
-         * @param {AssetApiDeleteAssetRequest} requestParameters Request parameters.
+         * @param {AssetApiDeleteAssetsRequest} requestParameters Request parameters.
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        deleteAsset(requestParameters: AssetApiDeleteAssetRequest, options?: AxiosRequestConfig): AxiosPromise<Array<DeleteAssetResponseDto>> {
-            return localVarFp.deleteAsset(requestParameters.deleteAssetDto, options).then((request) => request(axios, basePath));
+        deleteAssets(requestParameters: AssetApiDeleteAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
+            return localVarFp.deleteAssets(requestParameters.assetBulkDeleteDto, options).then((request) => request(axios, basePath));
         },
         },
         /**
         /**
          * 
          * 
@@ -7361,6 +7541,14 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         downloadFile(requestParameters: AssetApiDownloadFileRequest, options?: AxiosRequestConfig): AxiosPromise<File> {
         downloadFile(requestParameters: AssetApiDownloadFileRequest, options?: AxiosRequestConfig): AxiosPromise<File> {
             return localVarFp.downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(axios, basePath));
             return localVarFp.downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(axios, basePath));
         },
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        emptyTrash(options?: AxiosRequestConfig): AxiosPromise<void> {
+            return localVarFp.emptyTrash(options).then((request) => request(axios, basePath));
+        },
         /**
         /**
          * Get all AssetEntity belong to the user
          * Get all AssetEntity belong to the user
          * @param {AssetApiGetAllAssetsRequest} requestParameters Request parameters.
          * @param {AssetApiGetAllAssetsRequest} requestParameters Request parameters.
@@ -7394,7 +7582,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
         getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig): AxiosPromise<AssetStatsResponseDto> {
         getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig): AxiosPromise<AssetStatsResponseDto> {
-            return localVarFp.getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, options).then((request) => request(axios, basePath));
+            return localVarFp.getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, options).then((request) => request(axios, basePath));
         },
         },
         /**
         /**
          * 
          * 
@@ -7412,7 +7600,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
         getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> {
         getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> {
-            return localVarFp.getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(axios, basePath));
+            return localVarFp.getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(axios, basePath));
         },
         },
         /**
         /**
          * 
          * 
@@ -7473,7 +7661,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
         getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig): AxiosPromise<Array<TimeBucketResponseDto>> {
         getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig): AxiosPromise<Array<TimeBucketResponseDto>> {
-            return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(axios, basePath));
+            return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(axios, basePath));
         },
         },
         /**
         /**
          * Get all asset of a device that are in the database, ID only.
          * Get all asset of a device that are in the database, ID only.
@@ -7493,6 +7681,23 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig): AxiosPromise<AssetFileUploadResponseDto> {
         importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig): AxiosPromise<AssetFileUploadResponseDto> {
             return localVarFp.importFile(requestParameters.importAssetDto, options).then((request) => request(axios, basePath));
             return localVarFp.importFile(requestParameters.importAssetDto, options).then((request) => request(axios, basePath));
         },
         },
+        /**
+         * 
+         * @param {AssetApiRestoreAssetsRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        restoreAssets(requestParameters: AssetApiRestoreAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
+            return localVarFp.restoreAssets(requestParameters.bulkIdsDto, options).then((request) => request(axios, basePath));
+        },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        restoreTrash(options?: AxiosRequestConfig): AxiosPromise<void> {
+            return localVarFp.restoreTrash(options).then((request) => request(axios, basePath));
+        },
         /**
         /**
          * 
          * 
          * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters.
          * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters.
@@ -7600,17 +7805,17 @@ export interface AssetApiCheckExistingAssetsRequest {
 }
 }
 
 
 /**
 /**
- * Request parameters for deleteAsset operation in AssetApi.
+ * Request parameters for deleteAssets operation in AssetApi.
  * @export
  * @export
- * @interface AssetApiDeleteAssetRequest
+ * @interface AssetApiDeleteAssetsRequest
  */
  */
-export interface AssetApiDeleteAssetRequest {
+export interface AssetApiDeleteAssetsRequest {
     /**
     /**
      * 
      * 
-     * @type {DeleteAssetDto}
-     * @memberof AssetApiDeleteAsset
+     * @type {AssetBulkDeleteDto}
+     * @memberof AssetApiDeleteAssets
      */
      */
-    readonly deleteAssetDto: DeleteAssetDto
+    readonly assetBulkDeleteDto: AssetBulkDeleteDto
 }
 }
 
 
 /**
 /**
@@ -7744,6 +7949,13 @@ export interface AssetApiGetAssetStatsRequest {
      * @memberof AssetApiGetAssetStats
      * @memberof AssetApiGetAssetStats
      */
      */
     readonly isFavorite?: boolean
     readonly isFavorite?: boolean
+
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetApiGetAssetStats
+     */
+    readonly isTrashed?: boolean
 }
 }
 
 
 /**
 /**
@@ -7829,6 +8041,13 @@ export interface AssetApiGetByTimeBucketRequest {
      */
      */
     readonly isFavorite?: boolean
     readonly isFavorite?: boolean
 
 
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetApiGetByTimeBucket
+     */
+    readonly isTrashed?: boolean
+
     /**
     /**
      * 
      * 
      * @type {string}
      * @type {string}
@@ -7976,6 +8195,13 @@ export interface AssetApiGetTimeBucketsRequest {
      */
      */
     readonly isFavorite?: boolean
     readonly isFavorite?: boolean
 
 
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetApiGetTimeBuckets
+     */
+    readonly isTrashed?: boolean
+
     /**
     /**
      * 
      * 
      * @type {string}
      * @type {string}
@@ -8012,6 +8238,20 @@ export interface AssetApiImportFileRequest {
     readonly importAssetDto: ImportAssetDto
     readonly importAssetDto: ImportAssetDto
 }
 }
 
 
+/**
+ * Request parameters for restoreAssets operation in AssetApi.
+ * @export
+ * @interface AssetApiRestoreAssetsRequest
+ */
+export interface AssetApiRestoreAssetsRequest {
+    /**
+     * 
+     * @type {BulkIdsDto}
+     * @memberof AssetApiRestoreAssets
+     */
+    readonly bulkIdsDto: BulkIdsDto
+}
+
 /**
 /**
  * Request parameters for runAssetJobs operation in AssetApi.
  * Request parameters for runAssetJobs operation in AssetApi.
  * @export
  * @export
@@ -8271,13 +8511,13 @@ export class AssetApi extends BaseAPI {
 
 
     /**
     /**
      * 
      * 
-     * @param {AssetApiDeleteAssetRequest} requestParameters Request parameters.
+     * @param {AssetApiDeleteAssetsRequest} requestParameters Request parameters.
      * @param {*} [options] Override http request option.
      * @param {*} [options] Override http request option.
      * @throws {RequiredError}
      * @throws {RequiredError}
      * @memberof AssetApi
      * @memberof AssetApi
      */
      */
-    public deleteAsset(requestParameters: AssetApiDeleteAssetRequest, options?: AxiosRequestConfig) {
-        return AssetApiFp(this.configuration).deleteAsset(requestParameters.deleteAssetDto, options).then((request) => request(this.axios, this.basePath));
+    public deleteAssets(requestParameters: AssetApiDeleteAssetsRequest, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).deleteAssets(requestParameters.assetBulkDeleteDto, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
     /**
     /**
@@ -8302,6 +8542,16 @@ export class AssetApi extends BaseAPI {
         return AssetApiFp(this.configuration).downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
         return AssetApiFp(this.configuration).downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public emptyTrash(options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).emptyTrash(options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
     /**
      * Get all AssetEntity belong to the user
      * Get all AssetEntity belong to the user
      * @param {AssetApiGetAllAssetsRequest} requestParameters Request parameters.
      * @param {AssetApiGetAllAssetsRequest} requestParameters Request parameters.
@@ -8342,7 +8592,7 @@ export class AssetApi extends BaseAPI {
      * @memberof AssetApi
      * @memberof AssetApi
      */
      */
     public getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig) {
     public getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig) {
-        return AssetApiFp(this.configuration).getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, options).then((request) => request(this.axios, this.basePath));
+        return AssetApiFp(this.configuration).getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
     /**
     /**
@@ -8364,7 +8614,7 @@ export class AssetApi extends BaseAPI {
      * @memberof AssetApi
      * @memberof AssetApi
      */
      */
     public getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig) {
     public getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig) {
-        return AssetApiFp(this.configuration).getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
+        return AssetApiFp(this.configuration).getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
     /**
     /**
@@ -8439,7 +8689,7 @@ export class AssetApi extends BaseAPI {
      * @memberof AssetApi
      * @memberof AssetApi
      */
      */
     public getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig) {
     public getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig) {
-        return AssetApiFp(this.configuration).getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
+        return AssetApiFp(this.configuration).getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
     /**
     /**
@@ -8464,6 +8714,27 @@ export class AssetApi extends BaseAPI {
         return AssetApiFp(this.configuration).importFile(requestParameters.importAssetDto, options).then((request) => request(this.axios, this.basePath));
         return AssetApiFp(this.configuration).importFile(requestParameters.importAssetDto, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
+    /**
+     * 
+     * @param {AssetApiRestoreAssetsRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public restoreAssets(requestParameters: AssetApiRestoreAssetsRequest, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).restoreAssets(requestParameters.bulkIdsDto, options).then((request) => request(this.axios, this.basePath));
+    }
+
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public restoreTrash(options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).restoreTrash(options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
     /**
      * 
      * 
      * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters.
      * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters.

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است