From 7587f858ae37d2326cca197c70689055a784af54 Mon Sep 17 00:00:00 2001
From: Alex <alex.tran1502@gmail.com>
Date: Thu, 6 Oct 2022 11:25:54 -0500
Subject: [PATCH] feat(server/web) Add manual job trigger mechanism to the web
 (#767)

---
 mobile/openapi/.openapi-generator/FILES       |  14 +
 mobile/openapi/README.md                      |   9 +
 mobile/openapi/doc/AllJobStatusResponseDto.md |  22 +
 mobile/openapi/doc/CreateJobDto.md            |  15 +
 mobile/openapi/doc/ExifResponseDto.md         |   4 +-
 mobile/openapi/doc/JobApi.md                  | 155 +++++++
 mobile/openapi/doc/JobCommand.md              |  14 +
 mobile/openapi/doc/JobCommandDto.md           |  15 +
 mobile/openapi/doc/JobCounts.md               |  19 +
 mobile/openapi/doc/JobId.md                   |  14 +
 mobile/openapi/doc/JobStatusResponseDto.md    |  16 +
 mobile/openapi/doc/JobType.md                 |  14 +
 mobile/openapi/lib/api.dart                   |   7 +
 mobile/openapi/lib/api/job_api.dart           | 159 +++++++
 mobile/openapi/lib/api_client.dart            |  12 +
 mobile/openapi/lib/api_helper.dart            |   6 +
 .../model/all_job_status_response_dto.dart    | 167 +++++++
 .../openapi/lib/model/asset_response_dto.dart | 123 +++---
 mobile/openapi/lib/model/create_job_dto.dart  | 111 +++++
 .../openapi/lib/model/exif_response_dto.dart  |  30 +-
 mobile/openapi/lib/model/job_command.dart     |  85 ++++
 mobile/openapi/lib/model/job_command_dto.dart | 111 +++++
 mobile/openapi/lib/model/job_counts.dart      | 153 +++++++
 mobile/openapi/lib/model/job_id.dart          |  91 ++++
 .../lib/model/job_status_response_dto.dart    | 119 +++++
 mobile/openapi/lib/model/job_type.dart        |  91 ++++
 .../all_job_status_response_dto_test.dart     |  52 +++
 mobile/openapi/test/create_job_dto_test.dart  |  27 ++
 mobile/openapi/test/job_api_test.dart         |  41 ++
 mobile/openapi/test/job_command_dto_test.dart |  27 ++
 mobile/openapi/test/job_command_test.dart     |  21 +
 mobile/openapi/test/job_counts_test.dart      |  47 ++
 mobile/openapi/test/job_id_test.dart          |  21 +
 .../test/job_status_response_dto_test.dart    |  32 ++
 mobile/openapi/test/job_type_test.dart        |  21 +
 server/.dockerignore                          |   2 +-
 .../src/api-v1/album/album.service.spec.ts    |   3 +
 .../src/api-v1/asset/asset-repository.ts      |  30 ++
 .../src/api-v1/asset/asset.controller.ts      |   4 +-
 .../immich/src/api-v1/asset/asset.module.ts   |   4 +-
 .../src/api-v1/asset/asset.service.spec.ts    |   3 +
 .../asset/response-dto/exif-response.dto.ts   |  10 +-
 .../immich/src/api-v1/job/dto/get-job.dto.ts  |  21 +
 .../src/api-v1/job/dto/job-command.dto.ts     |  12 +
 .../immich/src/api-v1/job/job.controller.ts   |  43 ++
 .../apps/immich/src/api-v1/job/job.module.ts  |  82 ++++
 .../apps/immich/src/api-v1/job/job.service.ts | 180 ++++++++
 .../all-job-status-response.dto.ts            |  35 ++
 .../response-dto/job-status-response.dto.ts   |   6 +
 .../response-dto/server-info-response.dto.ts  |   6 +-
 server/apps/immich/src/app.module.ts          |   3 +
 .../schedule-tasks/schedule-tasks.module.ts   |  12 +-
 .../schedule-tasks/schedule-tasks.service.ts  |  20 +-
 .../microservices/src/microservices.module.ts |  28 +-
 .../src/microservices.service.ts              |  16 +-
 .../processors/asset-uploaded.processor.ts    |  13 +-
 .../processors/generate-checksum.processor.ts |   6 +-
 .../processors/machine-learning.processor.ts  |  60 +++
 .../metadata-extraction.processor.ts          |  56 +--
 .../src/processors/thumbnail.processor.ts     |  31 +-
 .../processors/video-transcode.processor.ts   |   4 +-
 server/immich-openapi-specs.json              |   2 +-
 .../job/src/constants/job-name.constant.ts    |  11 +-
 .../job/src/constants/queue-name.constant.ts  |  13 +-
 .../interfaces/machine-learning.interface.ts  |   8 +
 server/package-lock.json                      |  69 ++-
 server/package.json                           |   2 +-
 web/src/api/api.ts                            |   4 +
 web/src/api/open-api/api.ts                   | 411 +++++++++++++++++-
 .../admin-page/jobs/job-tile.svelte           |  52 +++
 .../admin-page/jobs/jobs-panel.svelte         | 138 ++++++
 .../scrollbar/scrollbar.svelte                |   2 +-
 web/src/lib/models/admin-sidebar-selection.ts |   4 +-
 web/src/routes/admin/+layout.svelte           |   3 +
 web/src/routes/admin/+page.svelte             |  16 +-
 75 files changed, 3052 insertions(+), 238 deletions(-)
 create mode 100644 mobile/openapi/doc/AllJobStatusResponseDto.md
 create mode 100644 mobile/openapi/doc/CreateJobDto.md
 create mode 100644 mobile/openapi/doc/JobApi.md
 create mode 100644 mobile/openapi/doc/JobCommand.md
 create mode 100644 mobile/openapi/doc/JobCommandDto.md
 create mode 100644 mobile/openapi/doc/JobCounts.md
 create mode 100644 mobile/openapi/doc/JobId.md
 create mode 100644 mobile/openapi/doc/JobStatusResponseDto.md
 create mode 100644 mobile/openapi/doc/JobType.md
 create mode 100644 mobile/openapi/lib/api/job_api.dart
 create mode 100644 mobile/openapi/lib/model/all_job_status_response_dto.dart
 create mode 100644 mobile/openapi/lib/model/create_job_dto.dart
 create mode 100644 mobile/openapi/lib/model/job_command.dart
 create mode 100644 mobile/openapi/lib/model/job_command_dto.dart
 create mode 100644 mobile/openapi/lib/model/job_counts.dart
 create mode 100644 mobile/openapi/lib/model/job_id.dart
 create mode 100644 mobile/openapi/lib/model/job_status_response_dto.dart
 create mode 100644 mobile/openapi/lib/model/job_type.dart
 create mode 100644 mobile/openapi/test/all_job_status_response_dto_test.dart
 create mode 100644 mobile/openapi/test/create_job_dto_test.dart
 create mode 100644 mobile/openapi/test/job_api_test.dart
 create mode 100644 mobile/openapi/test/job_command_dto_test.dart
 create mode 100644 mobile/openapi/test/job_command_test.dart
 create mode 100644 mobile/openapi/test/job_counts_test.dart
 create mode 100644 mobile/openapi/test/job_id_test.dart
 create mode 100644 mobile/openapi/test/job_status_response_dto_test.dart
 create mode 100644 mobile/openapi/test/job_type_test.dart
 create mode 100644 server/apps/immich/src/api-v1/job/dto/get-job.dto.ts
 create mode 100644 server/apps/immich/src/api-v1/job/dto/job-command.dto.ts
 create mode 100644 server/apps/immich/src/api-v1/job/job.controller.ts
 create mode 100644 server/apps/immich/src/api-v1/job/job.module.ts
 create mode 100644 server/apps/immich/src/api-v1/job/job.service.ts
 create mode 100644 server/apps/immich/src/api-v1/job/response-dto/all-job-status-response.dto.ts
 create mode 100644 server/apps/immich/src/api-v1/job/response-dto/job-status-response.dto.ts
 create mode 100644 server/apps/microservices/src/processors/machine-learning.processor.ts
 create mode 100644 server/libs/job/src/interfaces/machine-learning.interface.ts
 create mode 100644 web/src/lib/components/admin-page/jobs/job-tile.svelte
 create mode 100644 web/src/lib/components/admin-page/jobs/jobs-panel.svelte
 create mode 100644 web/src/routes/admin/+layout.svelte

diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES
index a5d73e1474..3a8edc0030 100644
--- a/mobile/openapi/.openapi-generator/FILES
+++ b/mobile/openapi/.openapi-generator/FILES
@@ -8,6 +8,7 @@ doc/AdminSignupResponseDto.md
 doc/AlbumApi.md
 doc/AlbumCountResponseDto.md
 doc/AlbumResponseDto.md
+doc/AllJobStatusResponseDto.md
 doc/AssetApi.md
 doc/AssetCountByTimeBucket.md
 doc/AssetCountByTimeBucketResponseDto.md
@@ -33,6 +34,12 @@ doc/DeviceTypeEnum.md
 doc/ExifResponseDto.md
 doc/GetAssetByTimeBucketDto.md
 doc/GetAssetCountByTimeBucketDto.md
+doc/JobApi.md
+doc/JobCommand.md
+doc/JobCommandDto.md
+doc/JobCounts.md
+doc/JobId.md
+doc/JobStatusResponseDto.md
 doc/LoginCredentialDto.md
 doc/LoginResponseDto.md
 doc/LogoutResponseDto.md
@@ -59,6 +66,7 @@ lib/api/album_api.dart
 lib/api/asset_api.dart
 lib/api/authentication_api.dart
 lib/api/device_info_api.dart
+lib/api/job_api.dart
 lib/api/server_info_api.dart
 lib/api/user_api.dart
 lib/api_client.dart
@@ -74,6 +82,7 @@ lib/model/add_users_dto.dart
 lib/model/admin_signup_response_dto.dart
 lib/model/album_count_response_dto.dart
 lib/model/album_response_dto.dart
+lib/model/all_job_status_response_dto.dart
 lib/model/asset_count_by_time_bucket.dart
 lib/model/asset_count_by_time_bucket_response_dto.dart
 lib/model/asset_count_by_user_id_response_dto.dart
@@ -96,6 +105,11 @@ lib/model/device_type_enum.dart
 lib/model/exif_response_dto.dart
 lib/model/get_asset_by_time_bucket_dto.dart
 lib/model/get_asset_count_by_time_bucket_dto.dart
+lib/model/job_command.dart
+lib/model/job_command_dto.dart
+lib/model/job_counts.dart
+lib/model/job_id.dart
+lib/model/job_status_response_dto.dart
 lib/model/login_credential_dto.dart
 lib/model/login_response_dto.dart
 lib/model/logout_response_dto.dart
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 0d037e60b3..3154654d42 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -97,6 +97,9 @@ Class | Method | HTTP request | Description
 *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | 
 *DeviceInfoApi* | [**createDeviceInfo**](doc//DeviceInfoApi.md#createdeviceinfo) | **POST** /device-info | 
 *DeviceInfoApi* | [**updateDeviceInfo**](doc//DeviceInfoApi.md#updatedeviceinfo) | **PATCH** /device-info | 
+*JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs | 
+*JobApi* | [**getJobStatus**](doc//JobApi.md#getjobstatus) | **GET** /jobs/{jobId} | 
+*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} | 
 *ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | 
 *ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | 
 *ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping | 
@@ -117,6 +120,7 @@ Class | Method | HTTP request | Description
  - [AdminSignupResponseDto](doc//AdminSignupResponseDto.md)
  - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
  - [AlbumResponseDto](doc//AlbumResponseDto.md)
+ - [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
  - [AssetCountByTimeBucket](doc//AssetCountByTimeBucket.md)
  - [AssetCountByTimeBucketResponseDto](doc//AssetCountByTimeBucketResponseDto.md)
  - [AssetCountByUserIdResponseDto](doc//AssetCountByUserIdResponseDto.md)
@@ -139,6 +143,11 @@ Class | Method | HTTP request | Description
  - [ExifResponseDto](doc//ExifResponseDto.md)
  - [GetAssetByTimeBucketDto](doc//GetAssetByTimeBucketDto.md)
  - [GetAssetCountByTimeBucketDto](doc//GetAssetCountByTimeBucketDto.md)
+ - [JobCommand](doc//JobCommand.md)
+ - [JobCommandDto](doc//JobCommandDto.md)
+ - [JobCounts](doc//JobCounts.md)
+ - [JobId](doc//JobId.md)
+ - [JobStatusResponseDto](doc//JobStatusResponseDto.md)
  - [LoginCredentialDto](doc//LoginCredentialDto.md)
  - [LoginResponseDto](doc//LoginResponseDto.md)
  - [LogoutResponseDto](doc//LogoutResponseDto.md)
diff --git a/mobile/openapi/doc/AllJobStatusResponseDto.md b/mobile/openapi/doc/AllJobStatusResponseDto.md
new file mode 100644
index 0000000000..3fa53791df
--- /dev/null
+++ b/mobile/openapi/doc/AllJobStatusResponseDto.md
@@ -0,0 +1,22 @@
+# openapi.model.AllJobStatusResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**thumbnailGenerationQueueCount** | [**JobCounts**](JobCounts.md) |  | 
+**metadataExtractionQueueCount** | [**JobCounts**](JobCounts.md) |  | 
+**videoConversionQueueCount** | [**JobCounts**](JobCounts.md) |  | 
+**machineLearningQueueCount** | [**JobCounts**](JobCounts.md) |  | 
+**isThumbnailGenerationActive** | **bool** |  | 
+**isMetadataExtractionActive** | **bool** |  | 
+**isVideoConversionActive** | **bool** |  | 
+**isMachineLearningActive** | **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)
+
+
diff --git a/mobile/openapi/doc/CreateJobDto.md b/mobile/openapi/doc/CreateJobDto.md
new file mode 100644
index 0000000000..64cdbf0184
--- /dev/null
+++ b/mobile/openapi/doc/CreateJobDto.md
@@ -0,0 +1,15 @@
+# openapi.model.CreateJobDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**jobType** | [**JobType**](JobType.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)
+
+
diff --git a/mobile/openapi/doc/ExifResponseDto.md b/mobile/openapi/doc/ExifResponseDto.md
index 0e96bdcbe9..af4bb349ec 100644
--- a/mobile/openapi/doc/ExifResponseDto.md
+++ b/mobile/openapi/doc/ExifResponseDto.md
@@ -8,13 +8,13 @@ import 'package:openapi/api.dart';
 ## Properties
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
-**id** | **String** |  | [optional] 
+**id** | **int** |  | [optional] 
+**fileSizeInByte** | **int** |  | [optional] 
 **make** | **String** |  | [optional] 
 **model** | **String** |  | [optional] 
 **imageName** | **String** |  | [optional] 
 **exifImageWidth** | **num** |  | [optional] 
 **exifImageHeight** | **num** |  | [optional] 
-**fileSizeInByte** | **num** |  | [optional] 
 **orientation** | **String** |  | [optional] 
 **dateTimeOriginal** | [**DateTime**](DateTime.md) |  | [optional] 
 **modifyDate** | [**DateTime**](DateTime.md) |  | [optional] 
diff --git a/mobile/openapi/doc/JobApi.md b/mobile/openapi/doc/JobApi.md
new file mode 100644
index 0000000000..124e3d2149
--- /dev/null
+++ b/mobile/openapi/doc/JobApi.md
@@ -0,0 +1,155 @@
+# openapi.api.JobApi
+
+## Load the API package
+```dart
+import 'package:openapi/api.dart';
+```
+
+All URIs are relative to */api*
+
+Method | HTTP request | Description
+------------- | ------------- | -------------
+[**getAllJobsStatus**](JobApi.md#getalljobsstatus) | **GET** /jobs | 
+[**getJobStatus**](JobApi.md#getjobstatus) | **GET** /jobs/{jobId} | 
+[**sendJobCommand**](JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} | 
+
+
+# **getAllJobsStatus**
+> AllJobStatusResponseDto getAllJobsStatus()
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// 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 = JobApi();
+
+try {
+    final result = api_instance.getAllJobsStatus();
+    print(result);
+} catch (e) {
+    print('Exception when calling JobApi->getAllJobsStatus: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+[**AllJobStatusResponseDto**](AllJobStatusResponseDto.md)
+
+### Authorization
+
+[bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[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)
+
+# **getJobStatus**
+> JobStatusResponseDto getJobStatus(jobId)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// 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 = JobApi();
+final jobId = ; // JobId | 
+
+try {
+    final result = api_instance.getJobStatus(jobId);
+    print(result);
+} catch (e) {
+    print('Exception when calling JobApi->getJobStatus: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **jobId** | [**JobId**](.md)|  | 
+
+### Return type
+
+[**JobStatusResponseDto**](JobStatusResponseDto.md)
+
+### Authorization
+
+[bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[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)
+
+# **sendJobCommand**
+> num sendJobCommand(jobId, jobCommandDto)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// 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 = JobApi();
+final jobId = ; // JobId | 
+final jobCommandDto = JobCommandDto(); // JobCommandDto | 
+
+try {
+    final result = api_instance.sendJobCommand(jobId, jobCommandDto);
+    print(result);
+} catch (e) {
+    print('Exception when calling JobApi->sendJobCommand: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **jobId** | [**JobId**](.md)|  | 
+ **jobCommandDto** | [**JobCommandDto**](JobCommandDto.md)|  | 
+
+### Return type
+
+**num**
+
+### Authorization
+
+[bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **Accept**: application/json
+
+[[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)
+
diff --git a/mobile/openapi/doc/JobCommand.md b/mobile/openapi/doc/JobCommand.md
new file mode 100644
index 0000000000..620e0439a5
--- /dev/null
+++ b/mobile/openapi/doc/JobCommand.md
@@ -0,0 +1,14 @@
+# openapi.model.JobCommand
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+
diff --git a/mobile/openapi/doc/JobCommandDto.md b/mobile/openapi/doc/JobCommandDto.md
new file mode 100644
index 0000000000..4e87fde8e8
--- /dev/null
+++ b/mobile/openapi/doc/JobCommandDto.md
@@ -0,0 +1,15 @@
+# openapi.model.JobCommandDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**command** | [**JobCommand**](JobCommand.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)
+
+
diff --git a/mobile/openapi/doc/JobCounts.md b/mobile/openapi/doc/JobCounts.md
new file mode 100644
index 0000000000..195ed86a65
--- /dev/null
+++ b/mobile/openapi/doc/JobCounts.md
@@ -0,0 +1,19 @@
+# openapi.model.JobCounts
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**active** | **num** |  | 
+**completed** | **num** |  | 
+**failed** | **num** |  | 
+**delayed** | **num** |  | 
+**waiting** | **num** |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+
diff --git a/mobile/openapi/doc/JobId.md b/mobile/openapi/doc/JobId.md
new file mode 100644
index 0000000000..d2f68234d0
--- /dev/null
+++ b/mobile/openapi/doc/JobId.md
@@ -0,0 +1,14 @@
+# openapi.model.JobId
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+
diff --git a/mobile/openapi/doc/JobStatusResponseDto.md b/mobile/openapi/doc/JobStatusResponseDto.md
new file mode 100644
index 0000000000..13325a5152
--- /dev/null
+++ b/mobile/openapi/doc/JobStatusResponseDto.md
@@ -0,0 +1,16 @@
+# openapi.model.JobStatusResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**isActive** | **bool** |  | 
+**queueCount** | [**Object**](.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)
+
+
diff --git a/mobile/openapi/doc/JobType.md b/mobile/openapi/doc/JobType.md
new file mode 100644
index 0000000000..6d7faab6b7
--- /dev/null
+++ b/mobile/openapi/doc/JobType.md
@@ -0,0 +1,14 @@
+# openapi.model.JobType
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 3c87fc703b..150d878f63 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -31,6 +31,7 @@ part 'api/album_api.dart';
 part 'api/asset_api.dart';
 part 'api/authentication_api.dart';
 part 'api/device_info_api.dart';
+part 'api/job_api.dart';
 part 'api/server_info_api.dart';
 part 'api/user_api.dart';
 
@@ -39,6 +40,7 @@ part 'model/add_users_dto.dart';
 part 'model/admin_signup_response_dto.dart';
 part 'model/album_count_response_dto.dart';
 part 'model/album_response_dto.dart';
+part 'model/all_job_status_response_dto.dart';
 part 'model/asset_count_by_time_bucket.dart';
 part 'model/asset_count_by_time_bucket_response_dto.dart';
 part 'model/asset_count_by_user_id_response_dto.dart';
@@ -61,6 +63,11 @@ part 'model/device_type_enum.dart';
 part 'model/exif_response_dto.dart';
 part 'model/get_asset_by_time_bucket_dto.dart';
 part 'model/get_asset_count_by_time_bucket_dto.dart';
+part 'model/job_command.dart';
+part 'model/job_command_dto.dart';
+part 'model/job_counts.dart';
+part 'model/job_id.dart';
+part 'model/job_status_response_dto.dart';
 part 'model/login_credential_dto.dart';
 part 'model/login_response_dto.dart';
 part 'model/logout_response_dto.dart';
diff --git a/mobile/openapi/lib/api/job_api.dart b/mobile/openapi/lib/api/job_api.dart
new file mode 100644
index 0000000000..b64a67c35a
--- /dev/null
+++ b/mobile/openapi/lib/api/job_api.dart
@@ -0,0 +1,159 @@
+//
+// 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 JobApi {
+  JobApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
+
+  final ApiClient apiClient;
+
+  /// Performs an HTTP 'GET /jobs' operation and returns the [Response].
+  Future<Response> getAllJobsStatusWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/jobs';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<AllJobStatusResponseDto?> getAllJobsStatus() async {
+    final response = await getAllJobsStatusWithHttpInfo();
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AllJobStatusResponseDto',) as AllJobStatusResponseDto;
+    
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'GET /jobs/{jobId}' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [JobId] jobId (required):
+  Future<Response> getJobStatusWithHttpInfo(JobId jobId,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/jobs/{jobId}'
+      .replaceAll('{jobId}', jobId.toString());
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [JobId] jobId (required):
+  Future<JobStatusResponseDto?> getJobStatus(JobId jobId,) async {
+    final response = await getJobStatusWithHttpInfo(jobId,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'JobStatusResponseDto',) as JobStatusResponseDto;
+    
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'PUT /jobs/{jobId}' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [JobId] jobId (required):
+  ///
+  /// * [JobCommandDto] jobCommandDto (required):
+  Future<Response> sendJobCommandWithHttpInfo(JobId jobId, JobCommandDto jobCommandDto,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/jobs/{jobId}'
+      .replaceAll('{jobId}', jobId.toString());
+
+    // ignore: prefer_final_locals
+    Object? postBody = jobCommandDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'PUT',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [JobId] jobId (required):
+  ///
+  /// * [JobCommandDto] jobCommandDto (required):
+  Future<num?> sendJobCommand(JobId jobId, JobCommandDto jobCommandDto,) async {
+    final response = await sendJobCommandWithHttpInfo(jobId, jobCommandDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'num',) as num;
+    
+    }
+    return null;
+  }
+}
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 13cc028967..827332e9c3 100644
--- a/mobile/openapi/lib/api_client.dart
+++ b/mobile/openapi/lib/api_client.dart
@@ -202,6 +202,8 @@ class ApiClient {
           return AlbumCountResponseDto.fromJson(value);
         case 'AlbumResponseDto':
           return AlbumResponseDto.fromJson(value);
+        case 'AllJobStatusResponseDto':
+          return AllJobStatusResponseDto.fromJson(value);
         case 'AssetCountByTimeBucket':
           return AssetCountByTimeBucket.fromJson(value);
         case 'AssetCountByTimeBucketResponseDto':
@@ -246,6 +248,16 @@ class ApiClient {
           return GetAssetByTimeBucketDto.fromJson(value);
         case 'GetAssetCountByTimeBucketDto':
           return GetAssetCountByTimeBucketDto.fromJson(value);
+        case 'JobCommand':
+          return JobCommandTypeTransformer().decode(value);
+        case 'JobCommandDto':
+          return JobCommandDto.fromJson(value);
+        case 'JobCounts':
+          return JobCounts.fromJson(value);
+        case 'JobId':
+          return JobIdTypeTransformer().decode(value);
+        case 'JobStatusResponseDto':
+          return JobStatusResponseDto.fromJson(value);
         case 'LoginCredentialDto':
           return LoginCredentialDto.fromJson(value);
         case 'LoginResponseDto':
diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart
index 26c90fd0c7..7db37768c4 100644
--- a/mobile/openapi/lib/api_helper.dart
+++ b/mobile/openapi/lib/api_helper.dart
@@ -64,6 +64,12 @@ String parameterToString(dynamic value) {
   if (value is DeviceTypeEnum) {
     return DeviceTypeEnumTypeTransformer().encode(value).toString();
   }
+  if (value is JobCommand) {
+    return JobCommandTypeTransformer().encode(value).toString();
+  }
+  if (value is JobId) {
+    return JobIdTypeTransformer().encode(value).toString();
+  }
   if (value is ThumbnailFormat) {
     return ThumbnailFormatTypeTransformer().encode(value).toString();
   }
diff --git a/mobile/openapi/lib/model/all_job_status_response_dto.dart b/mobile/openapi/lib/model/all_job_status_response_dto.dart
new file mode 100644
index 0000000000..7be7166a77
--- /dev/null
+++ b/mobile/openapi/lib/model/all_job_status_response_dto.dart
@@ -0,0 +1,167 @@
+//
+// 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 AllJobStatusResponseDto {
+  /// Returns a new [AllJobStatusResponseDto] instance.
+  AllJobStatusResponseDto({
+    required this.thumbnailGenerationQueueCount,
+    required this.metadataExtractionQueueCount,
+    required this.videoConversionQueueCount,
+    required this.machineLearningQueueCount,
+    required this.isThumbnailGenerationActive,
+    required this.isMetadataExtractionActive,
+    required this.isVideoConversionActive,
+    required this.isMachineLearningActive,
+  });
+
+  JobCounts thumbnailGenerationQueueCount;
+
+  JobCounts metadataExtractionQueueCount;
+
+  JobCounts videoConversionQueueCount;
+
+  JobCounts machineLearningQueueCount;
+
+  bool isThumbnailGenerationActive;
+
+  bool isMetadataExtractionActive;
+
+  bool isVideoConversionActive;
+
+  bool isMachineLearningActive;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto &&
+     other.thumbnailGenerationQueueCount == thumbnailGenerationQueueCount &&
+     other.metadataExtractionQueueCount == metadataExtractionQueueCount &&
+     other.videoConversionQueueCount == videoConversionQueueCount &&
+     other.machineLearningQueueCount == machineLearningQueueCount &&
+     other.isThumbnailGenerationActive == isThumbnailGenerationActive &&
+     other.isMetadataExtractionActive == isMetadataExtractionActive &&
+     other.isVideoConversionActive == isVideoConversionActive &&
+     other.isMachineLearningActive == isMachineLearningActive;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (thumbnailGenerationQueueCount.hashCode) +
+    (metadataExtractionQueueCount.hashCode) +
+    (videoConversionQueueCount.hashCode) +
+    (machineLearningQueueCount.hashCode) +
+    (isThumbnailGenerationActive.hashCode) +
+    (isMetadataExtractionActive.hashCode) +
+    (isVideoConversionActive.hashCode) +
+    (isMachineLearningActive.hashCode);
+
+  @override
+  String toString() => 'AllJobStatusResponseDto[thumbnailGenerationQueueCount=$thumbnailGenerationQueueCount, metadataExtractionQueueCount=$metadataExtractionQueueCount, videoConversionQueueCount=$videoConversionQueueCount, machineLearningQueueCount=$machineLearningQueueCount, isThumbnailGenerationActive=$isThumbnailGenerationActive, isMetadataExtractionActive=$isMetadataExtractionActive, isVideoConversionActive=$isVideoConversionActive, isMachineLearningActive=$isMachineLearningActive]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'thumbnailGenerationQueueCount'] = thumbnailGenerationQueueCount;
+      _json[r'metadataExtractionQueueCount'] = metadataExtractionQueueCount;
+      _json[r'videoConversionQueueCount'] = videoConversionQueueCount;
+      _json[r'machineLearningQueueCount'] = machineLearningQueueCount;
+      _json[r'isThumbnailGenerationActive'] = isThumbnailGenerationActive;
+      _json[r'isMetadataExtractionActive'] = isMetadataExtractionActive;
+      _json[r'isVideoConversionActive'] = isVideoConversionActive;
+      _json[r'isMachineLearningActive'] = isMachineLearningActive;
+    return _json;
+  }
+
+  /// Returns a new [AllJobStatusResponseDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static AllJobStatusResponseDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "AllJobStatusResponseDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "AllJobStatusResponseDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return AllJobStatusResponseDto(
+        thumbnailGenerationQueueCount: JobCounts.fromJson(json[r'thumbnailGenerationQueueCount'])!,
+        metadataExtractionQueueCount: JobCounts.fromJson(json[r'metadataExtractionQueueCount'])!,
+        videoConversionQueueCount: JobCounts.fromJson(json[r'videoConversionQueueCount'])!,
+        machineLearningQueueCount: JobCounts.fromJson(json[r'machineLearningQueueCount'])!,
+        isThumbnailGenerationActive: mapValueOfType<bool>(json, r'isThumbnailGenerationActive')!,
+        isMetadataExtractionActive: mapValueOfType<bool>(json, r'isMetadataExtractionActive')!,
+        isVideoConversionActive: mapValueOfType<bool>(json, r'isVideoConversionActive')!,
+        isMachineLearningActive: mapValueOfType<bool>(json, r'isMachineLearningActive')!,
+      );
+    }
+    return null;
+  }
+
+  static List<AllJobStatusResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <AllJobStatusResponseDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = AllJobStatusResponseDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, AllJobStatusResponseDto> mapFromJson(dynamic json) {
+    final map = <String, AllJobStatusResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = AllJobStatusResponseDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of AllJobStatusResponseDto-objects as value to a dart map
+  static Map<String, List<AllJobStatusResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<AllJobStatusResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = AllJobStatusResponseDto.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'thumbnailGenerationQueueCount',
+    'metadataExtractionQueueCount',
+    'videoConversionQueueCount',
+    'machineLearningQueueCount',
+    'isThumbnailGenerationActive',
+    'isMetadataExtractionActive',
+    'isVideoConversionActive',
+    'isMachineLearningActive',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart
index 21dcce7595..cd1e83c5f2 100644
--- a/mobile/openapi/lib/model/asset_response_dto.dart
+++ b/mobile/openapi/lib/model/asset_response_dto.dart
@@ -76,72 +76,69 @@ class AssetResponseDto {
   SmartInfoResponseDto? smartInfo;
 
   @override
-  bool operator ==(Object other) =>
-      identical(this, other) ||
-      other is AssetResponseDto &&
-          other.type == type &&
-          other.id == id &&
-          other.deviceAssetId == deviceAssetId &&
-          other.ownerId == ownerId &&
-          other.deviceId == deviceId &&
-          other.originalPath == originalPath &&
-          other.resizePath == resizePath &&
-          other.createdAt == createdAt &&
-          other.modifiedAt == modifiedAt &&
-          other.isFavorite == isFavorite &&
-          other.mimeType == mimeType &&
-          other.duration == duration &&
-          other.webpPath == webpPath &&
-          other.encodedVideoPath == encodedVideoPath &&
-          other.exifInfo == exifInfo &&
-          other.smartInfo == smartInfo;
+  bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
+     other.type == type &&
+     other.id == id &&
+     other.deviceAssetId == deviceAssetId &&
+     other.ownerId == ownerId &&
+     other.deviceId == deviceId &&
+     other.originalPath == originalPath &&
+     other.resizePath == resizePath &&
+     other.createdAt == createdAt &&
+     other.modifiedAt == modifiedAt &&
+     other.isFavorite == isFavorite &&
+     other.mimeType == mimeType &&
+     other.duration == duration &&
+     other.webpPath == webpPath &&
+     other.encodedVideoPath == encodedVideoPath &&
+     other.exifInfo == exifInfo &&
+     other.smartInfo == smartInfo;
 
   @override
   int get hashCode =>
-      // ignore: unnecessary_parenthesis
-      (type.hashCode) +
-      (id.hashCode) +
-      (deviceAssetId.hashCode) +
-      (ownerId.hashCode) +
-      (deviceId.hashCode) +
-      (originalPath.hashCode) +
-      (resizePath == null ? 0 : resizePath!.hashCode) +
-      (createdAt.hashCode) +
-      (modifiedAt.hashCode) +
-      (isFavorite.hashCode) +
-      (mimeType == null ? 0 : mimeType!.hashCode) +
-      (duration.hashCode) +
-      (webpPath == null ? 0 : webpPath!.hashCode) +
-      (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
-      (exifInfo == null ? 0 : exifInfo!.hashCode) +
-      (smartInfo == null ? 0 : smartInfo!.hashCode);
+    // ignore: unnecessary_parenthesis
+    (type.hashCode) +
+    (id.hashCode) +
+    (deviceAssetId.hashCode) +
+    (ownerId.hashCode) +
+    (deviceId.hashCode) +
+    (originalPath.hashCode) +
+    (resizePath == null ? 0 : resizePath!.hashCode) +
+    (createdAt.hashCode) +
+    (modifiedAt.hashCode) +
+    (isFavorite.hashCode) +
+    (mimeType == null ? 0 : mimeType!.hashCode) +
+    (duration.hashCode) +
+    (webpPath == null ? 0 : webpPath!.hashCode) +
+    (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
+    (exifInfo == null ? 0 : exifInfo!.hashCode) +
+    (smartInfo == null ? 0 : smartInfo!.hashCode);
 
   @override
-  String toString() =>
-      'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
+  String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
 
   Map<String, dynamic> toJson() {
     final _json = <String, dynamic>{};
-    _json[r'type'] = type;
-    _json[r'id'] = id;
-    _json[r'deviceAssetId'] = deviceAssetId;
-    _json[r'ownerId'] = ownerId;
-    _json[r'deviceId'] = deviceId;
-    _json[r'originalPath'] = originalPath;
+      _json[r'type'] = type;
+      _json[r'id'] = id;
+      _json[r'deviceAssetId'] = deviceAssetId;
+      _json[r'ownerId'] = ownerId;
+      _json[r'deviceId'] = deviceId;
+      _json[r'originalPath'] = originalPath;
     if (resizePath != null) {
       _json[r'resizePath'] = resizePath;
     } else {
       _json[r'resizePath'] = null;
     }
-    _json[r'createdAt'] = createdAt;
-    _json[r'modifiedAt'] = modifiedAt;
-    _json[r'isFavorite'] = isFavorite;
+      _json[r'createdAt'] = createdAt;
+      _json[r'modifiedAt'] = modifiedAt;
+      _json[r'isFavorite'] = isFavorite;
     if (mimeType != null) {
       _json[r'mimeType'] = mimeType;
     } else {
       _json[r'mimeType'] = null;
     }
-    _json[r'duration'] = duration;
+      _json[r'duration'] = duration;
     if (webpPath != null) {
       _json[r'webpPath'] = webpPath;
     } else {
@@ -175,13 +172,13 @@ class AssetResponseDto {
       // Ensure that the map contains the required keys.
       // Note 1: the values aren't checked for validity beyond being non-null.
       // Note 2: this code is stripped in release mode!
-      // assert(() {
-      //   requiredKeys.forEach((key) {
-      //     assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
-      //     assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
-      //   });
-      //   return true;
-      // }());
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
 
       return AssetResponseDto(
         type: AssetTypeEnum.fromJson(json[r'type'])!,
@@ -205,10 +202,7 @@ class AssetResponseDto {
     return null;
   }
 
-  static List<AssetResponseDto>? listFromJson(
-    dynamic json, {
-    bool growable = false,
-  }) {
+  static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
     final result = <AssetResponseDto>[];
     if (json is List && json.isNotEmpty) {
       for (final row in json) {
@@ -236,18 +230,12 @@ class AssetResponseDto {
   }
 
   // maps a json object with a list of AssetResponseDto-objects as value to a dart map
-  static Map<String, List<AssetResponseDto>> mapListFromJson(
-    dynamic json, {
-    bool growable = false,
-  }) {
+  static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
     final map = <String, List<AssetResponseDto>>{};
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       for (final entry in json.entries) {
-        final value = AssetResponseDto.listFromJson(
-          entry.value,
-          growable: growable,
-        );
+        final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
         if (value != null) {
           map[entry.key] = value;
         }
@@ -274,3 +262,4 @@ class AssetResponseDto {
     'encodedVideoPath',
   };
 }
+
diff --git a/mobile/openapi/lib/model/create_job_dto.dart b/mobile/openapi/lib/model/create_job_dto.dart
new file mode 100644
index 0000000000..1eaf678647
--- /dev/null
+++ b/mobile/openapi/lib/model/create_job_dto.dart
@@ -0,0 +1,111 @@
+//
+// 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 CreateJobDto {
+  /// Returns a new [CreateJobDto] instance.
+  CreateJobDto({
+    required this.jobType,
+  });
+
+  JobType jobType;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is CreateJobDto &&
+     other.jobType == jobType;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (jobType.hashCode);
+
+  @override
+  String toString() => 'CreateJobDto[jobType=$jobType]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'jobType'] = jobType;
+    return _json;
+  }
+
+  /// Returns a new [CreateJobDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static CreateJobDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "CreateJobDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "CreateJobDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return CreateJobDto(
+        jobType: JobType.fromJson(json[r'jobType'])!,
+      );
+    }
+    return null;
+  }
+
+  static List<CreateJobDto>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <CreateJobDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = CreateJobDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, CreateJobDto> mapFromJson(dynamic json) {
+    final map = <String, CreateJobDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = CreateJobDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of CreateJobDto-objects as value to a dart map
+  static Map<String, List<CreateJobDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<CreateJobDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = CreateJobDto.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'jobType',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart
index 199c955e93..b81f0e347b 100644
--- a/mobile/openapi/lib/model/exif_response_dto.dart
+++ b/mobile/openapi/lib/model/exif_response_dto.dart
@@ -14,12 +14,12 @@ class ExifResponseDto {
   /// Returns a new [ExifResponseDto] instance.
   ExifResponseDto({
     this.id,
+    this.fileSizeInByte,
     this.make,
     this.model,
     this.imageName,
     this.exifImageWidth,
     this.exifImageHeight,
-    this.fileSizeInByte,
     this.orientation,
     this.dateTimeOriginal,
     this.modifyDate,
@@ -35,7 +35,9 @@ class ExifResponseDto {
     this.country,
   });
 
-  String? id;
+  int? id;
+
+  int? fileSizeInByte;
 
   String? make;
 
@@ -47,8 +49,6 @@ class ExifResponseDto {
 
   num? exifImageHeight;
 
-  num? fileSizeInByte;
-
   String? orientation;
 
   DateTime? dateTimeOriginal;
@@ -78,12 +78,12 @@ class ExifResponseDto {
   @override
   bool operator ==(Object other) => identical(this, other) || other is ExifResponseDto &&
      other.id == id &&
+     other.fileSizeInByte == fileSizeInByte &&
      other.make == make &&
      other.model == model &&
      other.imageName == imageName &&
      other.exifImageWidth == exifImageWidth &&
      other.exifImageHeight == exifImageHeight &&
-     other.fileSizeInByte == fileSizeInByte &&
      other.orientation == orientation &&
      other.dateTimeOriginal == dateTimeOriginal &&
      other.modifyDate == modifyDate &&
@@ -102,12 +102,12 @@ class ExifResponseDto {
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     (id == null ? 0 : id!.hashCode) +
+    (fileSizeInByte == null ? 0 : fileSizeInByte!.hashCode) +
     (make == null ? 0 : make!.hashCode) +
     (model == null ? 0 : model!.hashCode) +
     (imageName == null ? 0 : imageName!.hashCode) +
     (exifImageWidth == null ? 0 : exifImageWidth!.hashCode) +
     (exifImageHeight == null ? 0 : exifImageHeight!.hashCode) +
-    (fileSizeInByte == null ? 0 : fileSizeInByte!.hashCode) +
     (orientation == null ? 0 : orientation!.hashCode) +
     (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) +
     (modifyDate == null ? 0 : modifyDate!.hashCode) +
@@ -123,7 +123,7 @@ class ExifResponseDto {
     (country == null ? 0 : country!.hashCode);
 
   @override
-  String toString() => 'ExifResponseDto[id=$id, make=$make, model=$model, imageName=$imageName, exifImageWidth=$exifImageWidth, exifImageHeight=$exifImageHeight, fileSizeInByte=$fileSizeInByte, orientation=$orientation, dateTimeOriginal=$dateTimeOriginal, modifyDate=$modifyDate, lensModel=$lensModel, fNumber=$fNumber, focalLength=$focalLength, iso=$iso, exposureTime=$exposureTime, latitude=$latitude, longitude=$longitude, city=$city, state=$state, country=$country]';
+  String toString() => 'ExifResponseDto[id=$id, fileSizeInByte=$fileSizeInByte, make=$make, model=$model, imageName=$imageName, exifImageWidth=$exifImageWidth, exifImageHeight=$exifImageHeight, orientation=$orientation, dateTimeOriginal=$dateTimeOriginal, modifyDate=$modifyDate, lensModel=$lensModel, fNumber=$fNumber, focalLength=$focalLength, iso=$iso, exposureTime=$exposureTime, latitude=$latitude, longitude=$longitude, city=$city, state=$state, country=$country]';
 
   Map<String, dynamic> toJson() {
     final _json = <String, dynamic>{};
@@ -132,6 +132,11 @@ class ExifResponseDto {
     } else {
       _json[r'id'] = null;
     }
+    if (fileSizeInByte != null) {
+      _json[r'fileSizeInByte'] = fileSizeInByte;
+    } else {
+      _json[r'fileSizeInByte'] = null;
+    }
     if (make != null) {
       _json[r'make'] = make;
     } else {
@@ -157,11 +162,6 @@ class ExifResponseDto {
     } else {
       _json[r'exifImageHeight'] = null;
     }
-    if (fileSizeInByte != null) {
-      _json[r'fileSizeInByte'] = fileSizeInByte;
-    } else {
-      _json[r'fileSizeInByte'] = null;
-    }
     if (orientation != null) {
       _json[r'orientation'] = orientation;
     } else {
@@ -249,7 +249,8 @@ class ExifResponseDto {
       }());
 
       return ExifResponseDto(
-        id: mapValueOfType<String>(json, r'id'),
+        id: mapValueOfType<int>(json, r'id'),
+        fileSizeInByte: mapValueOfType<int>(json, r'fileSizeInByte'),
         make: mapValueOfType<String>(json, r'make'),
         model: mapValueOfType<String>(json, r'model'),
         imageName: mapValueOfType<String>(json, r'imageName'),
@@ -259,9 +260,6 @@ class ExifResponseDto {
         exifImageHeight: json[r'exifImageHeight'] == null
             ? null
             : num.parse(json[r'exifImageHeight'].toString()),
-        fileSizeInByte: json[r'fileSizeInByte'] == null
-            ? null
-            : num.parse(json[r'fileSizeInByte'].toString()),
         orientation: mapValueOfType<String>(json, r'orientation'),
         dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', ''),
         modifyDate: mapDateTime(json, r'modifyDate', ''),
diff --git a/mobile/openapi/lib/model/job_command.dart b/mobile/openapi/lib/model/job_command.dart
new file mode 100644
index 0000000000..2734028076
--- /dev/null
+++ b/mobile/openapi/lib/model/job_command.dart
@@ -0,0 +1,85 @@
+//
+// 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 JobCommand {
+  /// Instantiate a new enum with the provided [value].
+  const JobCommand._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const start = JobCommand._(r'start');
+  static const stop = JobCommand._(r'stop');
+
+  /// List of all possible values in this [enum][JobCommand].
+  static const values = <JobCommand>[
+    start,
+    stop,
+  ];
+
+  static JobCommand? fromJson(dynamic value) => JobCommandTypeTransformer().decode(value);
+
+  static List<JobCommand>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <JobCommand>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = JobCommand.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [JobCommand] to String,
+/// and [decode] dynamic data back to [JobCommand].
+class JobCommandTypeTransformer {
+  factory JobCommandTypeTransformer() => _instance ??= const JobCommandTypeTransformer._();
+
+  const JobCommandTypeTransformer._();
+
+  String encode(JobCommand data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a JobCommand.
+  ///
+  /// 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.
+  JobCommand? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data.toString()) {
+        case r'start': return JobCommand.start;
+        case r'stop': return JobCommand.stop;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [JobCommandTypeTransformer] instance.
+  static JobCommandTypeTransformer? _instance;
+}
+
diff --git a/mobile/openapi/lib/model/job_command_dto.dart b/mobile/openapi/lib/model/job_command_dto.dart
new file mode 100644
index 0000000000..808eb50d75
--- /dev/null
+++ b/mobile/openapi/lib/model/job_command_dto.dart
@@ -0,0 +1,111 @@
+//
+// 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 JobCommandDto {
+  /// Returns a new [JobCommandDto] instance.
+  JobCommandDto({
+    required this.command,
+  });
+
+  JobCommand command;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is JobCommandDto &&
+     other.command == command;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (command.hashCode);
+
+  @override
+  String toString() => 'JobCommandDto[command=$command]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'command'] = command;
+    return _json;
+  }
+
+  /// Returns a new [JobCommandDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static JobCommandDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "JobCommandDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "JobCommandDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return JobCommandDto(
+        command: JobCommand.fromJson(json[r'command'])!,
+      );
+    }
+    return null;
+  }
+
+  static List<JobCommandDto>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <JobCommandDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = JobCommandDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, JobCommandDto> mapFromJson(dynamic json) {
+    final map = <String, JobCommandDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = JobCommandDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of JobCommandDto-objects as value to a dart map
+  static Map<String, List<JobCommandDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<JobCommandDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = JobCommandDto.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'command',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/job_counts.dart b/mobile/openapi/lib/model/job_counts.dart
new file mode 100644
index 0000000000..5c4f110f7e
--- /dev/null
+++ b/mobile/openapi/lib/model/job_counts.dart
@@ -0,0 +1,153 @@
+//
+// 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 JobCounts {
+  /// Returns a new [JobCounts] instance.
+  JobCounts({
+    required this.active,
+    required this.completed,
+    required this.failed,
+    required this.delayed,
+    required this.waiting,
+  });
+
+  num active;
+
+  num completed;
+
+  num failed;
+
+  num delayed;
+
+  num waiting;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is JobCounts &&
+     other.active == active &&
+     other.completed == completed &&
+     other.failed == failed &&
+     other.delayed == delayed &&
+     other.waiting == waiting;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (active.hashCode) +
+    (completed.hashCode) +
+    (failed.hashCode) +
+    (delayed.hashCode) +
+    (waiting.hashCode);
+
+  @override
+  String toString() => 'JobCounts[active=$active, completed=$completed, failed=$failed, delayed=$delayed, waiting=$waiting]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'active'] = active;
+      _json[r'completed'] = completed;
+      _json[r'failed'] = failed;
+      _json[r'delayed'] = delayed;
+      _json[r'waiting'] = waiting;
+    return _json;
+  }
+
+  /// Returns a new [JobCounts] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static JobCounts? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "JobCounts[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "JobCounts[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return JobCounts(
+        active: json[r'active'] == null
+            ? null
+            : num.parse(json[r'active'].toString()),
+        completed: json[r'completed'] == null
+            ? null
+            : num.parse(json[r'completed'].toString()),
+        failed: json[r'failed'] == null
+            ? null
+            : num.parse(json[r'failed'].toString()),
+        delayed: json[r'delayed'] == null
+            ? null
+            : num.parse(json[r'delayed'].toString()),
+        waiting: json[r'waiting'] == null
+            ? null
+            : num.parse(json[r'waiting'].toString()),
+      );
+    }
+    return null;
+  }
+
+  static List<JobCounts>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <JobCounts>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = JobCounts.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, JobCounts> mapFromJson(dynamic json) {
+    final map = <String, JobCounts>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = JobCounts.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of JobCounts-objects as value to a dart map
+  static Map<String, List<JobCounts>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<JobCounts>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = JobCounts.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'active',
+    'completed',
+    'failed',
+    'delayed',
+    'waiting',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/job_id.dart b/mobile/openapi/lib/model/job_id.dart
new file mode 100644
index 0000000000..308d9c06c1
--- /dev/null
+++ b/mobile/openapi/lib/model/job_id.dart
@@ -0,0 +1,91 @@
+//
+// 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 JobId {
+  /// Instantiate a new enum with the provided [value].
+  const JobId._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const thumbnailGeneration = JobId._(r'thumbnail-generation');
+  static const metadataExtraction = JobId._(r'metadata-extraction');
+  static const videoConversion = JobId._(r'video-conversion');
+  static const machineLearning = JobId._(r'machine-learning');
+
+  /// List of all possible values in this [enum][JobId].
+  static const values = <JobId>[
+    thumbnailGeneration,
+    metadataExtraction,
+    videoConversion,
+    machineLearning,
+  ];
+
+  static JobId? fromJson(dynamic value) => JobIdTypeTransformer().decode(value);
+
+  static List<JobId>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <JobId>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = JobId.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [JobId] to String,
+/// and [decode] dynamic data back to [JobId].
+class JobIdTypeTransformer {
+  factory JobIdTypeTransformer() => _instance ??= const JobIdTypeTransformer._();
+
+  const JobIdTypeTransformer._();
+
+  String encode(JobId data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a JobId.
+  ///
+  /// 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.
+  JobId? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data.toString()) {
+        case r'thumbnail-generation': return JobId.thumbnailGeneration;
+        case r'metadata-extraction': return JobId.metadataExtraction;
+        case r'video-conversion': return JobId.videoConversion;
+        case r'machine-learning': return JobId.machineLearning;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [JobIdTypeTransformer] instance.
+  static JobIdTypeTransformer? _instance;
+}
+
diff --git a/mobile/openapi/lib/model/job_status_response_dto.dart b/mobile/openapi/lib/model/job_status_response_dto.dart
new file mode 100644
index 0000000000..d3854b8f3a
--- /dev/null
+++ b/mobile/openapi/lib/model/job_status_response_dto.dart
@@ -0,0 +1,119 @@
+//
+// 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 JobStatusResponseDto {
+  /// Returns a new [JobStatusResponseDto] instance.
+  JobStatusResponseDto({
+    required this.isActive,
+    required this.queueCount,
+  });
+
+  bool isActive;
+
+  Object queueCount;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is JobStatusResponseDto &&
+     other.isActive == isActive &&
+     other.queueCount == queueCount;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (isActive.hashCode) +
+    (queueCount.hashCode);
+
+  @override
+  String toString() => 'JobStatusResponseDto[isActive=$isActive, queueCount=$queueCount]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'isActive'] = isActive;
+      _json[r'queueCount'] = queueCount;
+    return _json;
+  }
+
+  /// Returns a new [JobStatusResponseDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static JobStatusResponseDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "JobStatusResponseDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "JobStatusResponseDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return JobStatusResponseDto(
+        isActive: mapValueOfType<bool>(json, r'isActive')!,
+        queueCount: mapValueOfType<Object>(json, r'queueCount')!,
+      );
+    }
+    return null;
+  }
+
+  static List<JobStatusResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <JobStatusResponseDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = JobStatusResponseDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, JobStatusResponseDto> mapFromJson(dynamic json) {
+    final map = <String, JobStatusResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = JobStatusResponseDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of JobStatusResponseDto-objects as value to a dart map
+  static Map<String, List<JobStatusResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<JobStatusResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = JobStatusResponseDto.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'isActive',
+    'queueCount',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/job_type.dart b/mobile/openapi/lib/model/job_type.dart
new file mode 100644
index 0000000000..2cf21674ed
--- /dev/null
+++ b/mobile/openapi/lib/model/job_type.dart
@@ -0,0 +1,91 @@
+//
+// 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 JobType {
+  /// Instantiate a new enum with the provided [value].
+  const JobType._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const THUMBNAIL_GENERATION = JobType._(r'THUMBNAIL_GENERATION');
+  static const METADATA_EXTRACTION = JobType._(r'METADATA_EXTRACTION');
+  static const VIDEO_CONVERSION = JobType._(r'VIDEO_CONVERSION');
+  static const CHECKSUM_GENERATION = JobType._(r'CHECKSUM_GENERATION');
+
+  /// List of all possible values in this [enum][JobType].
+  static const values = <JobType>[
+    THUMBNAIL_GENERATION,
+    METADATA_EXTRACTION,
+    VIDEO_CONVERSION,
+    CHECKSUM_GENERATION,
+  ];
+
+  static JobType? fromJson(dynamic value) => JobTypeTypeTransformer().decode(value);
+
+  static List<JobType>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <JobType>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = JobType.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [JobType] to String,
+/// and [decode] dynamic data back to [JobType].
+class JobTypeTypeTransformer {
+  factory JobTypeTypeTransformer() => _instance ??= const JobTypeTypeTransformer._();
+
+  const JobTypeTypeTransformer._();
+
+  String encode(JobType data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a JobType.
+  ///
+  /// 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.
+  JobType? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data.toString()) {
+        case r'THUMBNAIL_GENERATION': return JobType.THUMBNAIL_GENERATION;
+        case r'METADATA_EXTRACTION': return JobType.METADATA_EXTRACTION;
+        case r'VIDEO_CONVERSION': return JobType.VIDEO_CONVERSION;
+        case r'CHECKSUM_GENERATION': return JobType.CHECKSUM_GENERATION;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [JobTypeTypeTransformer] instance.
+  static JobTypeTypeTransformer? _instance;
+}
+
diff --git a/mobile/openapi/test/all_job_status_response_dto_test.dart b/mobile/openapi/test/all_job_status_response_dto_test.dart
new file mode 100644
index 0000000000..0853da9d1b
--- /dev/null
+++ b/mobile/openapi/test/all_job_status_response_dto_test.dart
@@ -0,0 +1,52 @@
+//
+// 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 AllJobStatusResponseDto
+void main() {
+  // final instance = AllJobStatusResponseDto();
+
+  group('test AllJobStatusResponseDto', () {
+    // bool isThumbnailGenerationActive
+    test('to test the property `isThumbnailGenerationActive`', () async {
+      // TODO
+    });
+
+    // Object thumbnailGenerationQueueCount
+    test('to test the property `thumbnailGenerationQueueCount`', () async {
+      // TODO
+    });
+
+    // bool isMetadataExtractionActive
+    test('to test the property `isMetadataExtractionActive`', () async {
+      // TODO
+    });
+
+    // Object metadataExtractionQueueCount
+    test('to test the property `metadataExtractionQueueCount`', () async {
+      // TODO
+    });
+
+    // bool isVideoConversionActive
+    test('to test the property `isVideoConversionActive`', () async {
+      // TODO
+    });
+
+    // Object videoConversionQueueCount
+    test('to test the property `videoConversionQueueCount`', () async {
+      // TODO
+    });
+
+
+  });
+
+}
diff --git a/mobile/openapi/test/create_job_dto_test.dart b/mobile/openapi/test/create_job_dto_test.dart
new file mode 100644
index 0000000000..5ae779231e
--- /dev/null
+++ b/mobile/openapi/test/create_job_dto_test.dart
@@ -0,0 +1,27 @@
+//
+// 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 CreateJobDto
+void main() {
+  // final instance = CreateJobDto();
+
+  group('test CreateJobDto', () {
+    // JobType jobType
+    test('to test the property `jobType`', () async {
+      // TODO
+    });
+
+
+  });
+
+}
diff --git a/mobile/openapi/test/job_api_test.dart b/mobile/openapi/test/job_api_test.dart
new file mode 100644
index 0000000000..2b8d82393c
--- /dev/null
+++ b/mobile/openapi/test/job_api_test.dart
@@ -0,0 +1,41 @@
+//
+// 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 JobApi
+void main() {
+  // final instance = JobApi();
+
+  group('tests for JobApi', () {
+    //Future<Object> create(CreateJobDto createJobDto) async
+    test('test create', () async {
+      // TODO
+    });
+
+    //Future<AllJobStatusResponseDto> getAllJobsStatus() async
+    test('test getAllJobsStatus', () async {
+      // TODO
+    });
+
+    //Future<JobStatusResponseDto> getJobStatus(JobType jobType) async
+    test('test getJobStatus', () async {
+      // TODO
+    });
+
+    //Future<JobStatusResponseDto> stopJob(JobType jobType) async
+    test('test stopJob', () async {
+      // TODO
+    });
+
+  });
+}
diff --git a/mobile/openapi/test/job_command_dto_test.dart b/mobile/openapi/test/job_command_dto_test.dart
new file mode 100644
index 0000000000..fc31170277
--- /dev/null
+++ b/mobile/openapi/test/job_command_dto_test.dart
@@ -0,0 +1,27 @@
+//
+// 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 JobCommandDto
+void main() {
+  // final instance = JobCommandDto();
+
+  group('test JobCommandDto', () {
+    // JobCommand command
+    test('to test the property `command`', () async {
+      // TODO
+    });
+
+
+  });
+
+}
diff --git a/mobile/openapi/test/job_command_test.dart b/mobile/openapi/test/job_command_test.dart
new file mode 100644
index 0000000000..df6822c9d4
--- /dev/null
+++ b/mobile/openapi/test/job_command_test.dart
@@ -0,0 +1,21 @@
+//
+// 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 JobCommand
+void main() {
+
+  group('test JobCommand', () {
+
+  });
+
+}
diff --git a/mobile/openapi/test/job_counts_test.dart b/mobile/openapi/test/job_counts_test.dart
new file mode 100644
index 0000000000..09fb4fc62f
--- /dev/null
+++ b/mobile/openapi/test/job_counts_test.dart
@@ -0,0 +1,47 @@
+//
+// 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 JobCounts
+void main() {
+  // final instance = JobCounts();
+
+  group('test JobCounts', () {
+    // num active
+    test('to test the property `active`', () async {
+      // TODO
+    });
+
+    // num completed
+    test('to test the property `completed`', () async {
+      // TODO
+    });
+
+    // num failed
+    test('to test the property `failed`', () async {
+      // TODO
+    });
+
+    // num delayed
+    test('to test the property `delayed`', () async {
+      // TODO
+    });
+
+    // num waiting
+    test('to test the property `waiting`', () async {
+      // TODO
+    });
+
+
+  });
+
+}
diff --git a/mobile/openapi/test/job_id_test.dart b/mobile/openapi/test/job_id_test.dart
new file mode 100644
index 0000000000..66b6b7656c
--- /dev/null
+++ b/mobile/openapi/test/job_id_test.dart
@@ -0,0 +1,21 @@
+//
+// 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 JobId
+void main() {
+
+  group('test JobId', () {
+
+  });
+
+}
diff --git a/mobile/openapi/test/job_status_response_dto_test.dart b/mobile/openapi/test/job_status_response_dto_test.dart
new file mode 100644
index 0000000000..09ea08df58
--- /dev/null
+++ b/mobile/openapi/test/job_status_response_dto_test.dart
@@ -0,0 +1,32 @@
+//
+// 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 JobStatusResponseDto
+void main() {
+  // final instance = JobStatusResponseDto();
+
+  group('test JobStatusResponseDto', () {
+    // bool isActive
+    test('to test the property `isActive`', () async {
+      // TODO
+    });
+
+    // Object queueCount
+    test('to test the property `queueCount`', () async {
+      // TODO
+    });
+
+
+  });
+
+}
diff --git a/mobile/openapi/test/job_type_test.dart b/mobile/openapi/test/job_type_test.dart
new file mode 100644
index 0000000000..d611a65570
--- /dev/null
+++ b/mobile/openapi/test/job_type_test.dart
@@ -0,0 +1,21 @@
+//
+// 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 JobType
+void main() {
+
+  group('test JobType', () {
+
+  });
+
+}
diff --git a/server/.dockerignore b/server/.dockerignore
index 834ab88b61..a66e51e358 100644
--- a/server/.dockerignore
+++ b/server/.dockerignore
@@ -1,4 +1,4 @@
 node_modules/
 upload/
 dist/
-
+.reverse-geocoding-dump
diff --git a/server/apps/immich/src/api-v1/album/album.service.spec.ts b/server/apps/immich/src/api-v1/album/album.service.spec.ts
index 672d39f9af..2ef7e5530f 100644
--- a/server/apps/immich/src/api-v1/album/album.service.spec.ts
+++ b/server/apps/immich/src/api-v1/album/album.service.spec.ts
@@ -134,6 +134,9 @@ describe('Album service', () => {
       getAssetByTimeBucket: jest.fn(),
       getAssetByChecksum: jest.fn(),
       getAssetCountByUserId: jest.fn(),
+      getAssetWithNoEXIF: jest.fn(),
+      getAssetWithNoThumbnail: jest.fn(),
+      getAssetWithNoSmartInfo: jest.fn(),
     };
 
     sut = new AlbumService(albumRepositoryMock, assetRepositoryMock);
diff --git a/server/apps/immich/src/api-v1/asset/asset-repository.ts b/server/apps/immich/src/api-v1/asset/asset-repository.ts
index 3c88633231..089819af37 100644
--- a/server/apps/immich/src/api-v1/asset/asset-repository.ts
+++ b/server/apps/immich/src/api-v1/asset/asset-repository.ts
@@ -29,6 +29,9 @@ export interface IAssetRepository {
   getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
   getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
   getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
+  getAssetWithNoThumbnail(): Promise<AssetEntity[]>;
+  getAssetWithNoEXIF(): Promise<AssetEntity[]>;
+  getAssetWithNoSmartInfo(): Promise<AssetEntity[]>;
 }
 
 export const ASSET_REPOSITORY = 'ASSET_REPOSITORY';
@@ -40,6 +43,33 @@ export class AssetRepository implements IAssetRepository {
     private assetRepository: Repository<AssetEntity>,
   ) {}
 
+  async getAssetWithNoSmartInfo(): Promise<AssetEntity[]> {
+    return await this.assetRepository
+      .createQueryBuilder('asset')
+      .leftJoinAndSelect('asset.smartInfo', 'si')
+      .where('asset.resizePath IS NOT NULL')
+      .andWhere('si.id IS NULL')
+      .getMany();
+  }
+
+  async getAssetWithNoThumbnail(): Promise<AssetEntity[]> {
+    return await this.assetRepository
+      .createQueryBuilder('asset')
+      .where('asset.resizePath IS NULL')
+      .orWhere('asset.resizePath = :resizePath', { resizePath: '' })
+      .orWhere('asset.webpPath IS NULL')
+      .orWhere('asset.webpPath = :webpPath', { webpPath: '' })
+      .getMany();
+  }
+
+  async getAssetWithNoEXIF(): Promise<AssetEntity[]> {
+    return await this.assetRepository
+      .createQueryBuilder('asset')
+      .leftJoinAndSelect('asset.exifInfo', 'ei')
+      .where('ei."assetId" IS NULL')
+      .getMany();
+  }
+
   async getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto> {
     // Get asset count by AssetType
     const res = await this.assetRepository
diff --git a/server/apps/immich/src/api-v1/asset/asset.controller.ts b/server/apps/immich/src/api-v1/asset/asset.controller.ts
index 387671df3a..a045bdce45 100644
--- a/server/apps/immich/src/api-v1/asset/asset.controller.ts
+++ b/server/apps/immich/src/api-v1/asset/asset.controller.ts
@@ -30,7 +30,7 @@ import { CommunicationGateway } from '../communication/communication.gateway';
 import { InjectQueue } from '@nestjs/bull';
 import { Queue } from 'bull';
 import { IAssetUploadedJob } from '@app/job/index';
-import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
+import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
 import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
 import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
@@ -59,7 +59,7 @@ export class AssetController {
     private assetService: AssetService,
     private backgroundTaskService: BackgroundTaskService,
 
-    @InjectQueue(assetUploadedQueueName)
+    @InjectQueue(QueueNameEnum.ASSET_UPLOADED)
     private assetUploadedQueue: Queue<IAssetUploadedJob>,
   ) {}
 
diff --git a/server/apps/immich/src/api-v1/asset/asset.module.ts b/server/apps/immich/src/api-v1/asset/asset.module.ts
index 13df9997f9..adc0705078 100644
--- a/server/apps/immich/src/api-v1/asset/asset.module.ts
+++ b/server/apps/immich/src/api-v1/asset/asset.module.ts
@@ -7,7 +7,7 @@ import { BullModule } from '@nestjs/bull';
 import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
 import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
 import { CommunicationModule } from '../communication/communication.module';
-import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
+import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
 import { AssetRepository, ASSET_REPOSITORY } from './asset-repository';
 
 @Module({
@@ -16,7 +16,7 @@ import { AssetRepository, ASSET_REPOSITORY } from './asset-repository';
     BackgroundTaskModule,
     TypeOrmModule.forFeature([AssetEntity]),
     BullModule.registerQueue({
-      name: assetUploadedQueueName,
+      name: QueueNameEnum.ASSET_UPLOADED,
       defaultJobOptions: {
         attempts: 3,
         removeOnComplete: true,
diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts
index 3b07d4f74f..89305dcd4b 100644
--- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts
+++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts
@@ -107,6 +107,9 @@ describe('AssetService', () => {
       getAssetByTimeBucket: jest.fn(),
       getAssetByChecksum: jest.fn(),
       getAssetCountByUserId: jest.fn(),
+      getAssetWithNoEXIF: jest.fn(),
+      getAssetWithNoThumbnail: jest.fn(),
+      getAssetWithNoSmartInfo: jest.fn(),
     };
 
     sui = new AssetService(assetRepositoryMock, a);
diff --git a/server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts b/server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts
index c43c55b4eb..ff86716eca 100644
--- a/server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts
+++ b/server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts
@@ -1,12 +1,16 @@
 import { ExifEntity } from '@app/database/entities/exif.entity';
+import { ApiProperty } from '@nestjs/swagger';
 
 export class ExifResponseDto {
-  id?: string | null = null;
+  @ApiProperty({ type: 'integer', format: 'int64' })
+  id?: number | null = null;
   make?: string | null = null;
   model?: string | null = null;
   imageName?: string | null = null;
   exifImageWidth?: number | null = null;
   exifImageHeight?: number | null = null;
+
+  @ApiProperty({ type: 'integer', format: 'int64' })
   fileSizeInByte?: number | null = null;
   orientation?: string | null = null;
   dateTimeOriginal?: Date | null = null;
@@ -25,13 +29,13 @@ export class ExifResponseDto {
 
 export function mapExif(entity: ExifEntity): ExifResponseDto {
   return {
-    id: entity.id,
+    id: parseInt(entity.id),
     make: entity.make,
     model: entity.model,
     imageName: entity.imageName,
     exifImageWidth: entity.exifImageWidth,
     exifImageHeight: entity.exifImageHeight,
-    fileSizeInByte: entity.fileSizeInByte,
+    fileSizeInByte: entity.fileSizeInByte ? parseInt(entity.fileSizeInByte.toString()) : null,
     orientation: entity.orientation,
     dateTimeOriginal: entity.dateTimeOriginal,
     modifyDate: entity.modifyDate,
diff --git a/server/apps/immich/src/api-v1/job/dto/get-job.dto.ts b/server/apps/immich/src/api-v1/job/dto/get-job.dto.ts
new file mode 100644
index 0000000000..38289ed134
--- /dev/null
+++ b/server/apps/immich/src/api-v1/job/dto/get-job.dto.ts
@@ -0,0 +1,21 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsEnum, IsNotEmpty } from 'class-validator';
+
+export enum JobId {
+  THUMBNAIL_GENERATION = 'thumbnail-generation',
+  METADATA_EXTRACTION = 'metadata-extraction',
+  VIDEO_CONVERSION = 'video-conversion',
+  MACHINE_LEARNING = 'machine-learning',
+}
+
+export class GetJobDto {
+  @IsNotEmpty()
+  @IsEnum(JobId, {
+    message: `params must be one of ${Object.values(JobId).join()}`,
+  })
+  @ApiProperty({
+    enum: JobId,
+    enumName: 'JobId',
+  })
+  jobId!: string;
+}
diff --git a/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts b/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts
new file mode 100644
index 0000000000..f63f0fa517
--- /dev/null
+++ b/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts
@@ -0,0 +1,12 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsIn, IsNotEmpty } from 'class-validator';
+
+export class JobCommandDto {
+  @IsNotEmpty()
+  @IsIn(['start', 'stop'])
+  @ApiProperty({
+    enum: ['start', 'stop'],
+    enumName: 'JobCommand',
+  })
+  command!: string;
+}
diff --git a/server/apps/immich/src/api-v1/job/job.controller.ts b/server/apps/immich/src/api-v1/job/job.controller.ts
new file mode 100644
index 0000000000..2fbccb7fd8
--- /dev/null
+++ b/server/apps/immich/src/api-v1/job/job.controller.ts
@@ -0,0 +1,43 @@
+import { Controller, Get, Body, UseGuards, ValidationPipe, Put, Param } from '@nestjs/common';
+import { JobService } from './job.service';
+import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
+import { AdminRolesGuard } from '../../middlewares/admin-role-guard.middleware';
+import { AllJobStatusResponseDto } from './response-dto/all-job-status-response.dto';
+import { GetJobDto } from './dto/get-job.dto';
+import { JobStatusResponseDto } from './response-dto/job-status-response.dto';
+
+import { JobCommandDto } from './dto/job-command.dto';
+
+@UseGuards(JwtAuthGuard)
+@UseGuards(AdminRolesGuard)
+@ApiTags('Job')
+@ApiBearerAuth()
+@Controller('jobs')
+export class JobController {
+  constructor(private readonly jobService: JobService) {}
+
+  @Get()
+  getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
+    return this.jobService.getAllJobsStatus();
+  }
+
+  @Get('/:jobId')
+  getJobStatus(@Param(ValidationPipe) params: GetJobDto): Promise<JobStatusResponseDto> {
+    return this.jobService.getJobStatus(params);
+  }
+
+  @Put('/:jobId')
+  async sendJobCommand(
+    @Param(ValidationPipe) params: GetJobDto,
+    @Body(ValidationPipe) body: JobCommandDto,
+  ): Promise<number> {
+    if (body.command === 'start') {
+      return await this.jobService.startJob(params);
+    }
+    if (body.command === 'stop') {
+      return await this.jobService.stopJob(params);
+    }
+    return 0;
+  }
+}
diff --git a/server/apps/immich/src/api-v1/job/job.module.ts b/server/apps/immich/src/api-v1/job/job.module.ts
new file mode 100644
index 0000000000..2cb5beb7bf
--- /dev/null
+++ b/server/apps/immich/src/api-v1/job/job.module.ts
@@ -0,0 +1,82 @@
+import { Module } from '@nestjs/common';
+import { JobService } from './job.service';
+import { JobController } from './job.controller';
+import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
+import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
+import { JwtModule } from '@nestjs/jwt';
+import { jwtConfig } from '../../config/jwt.config';
+import { UserEntity } from '@app/database/entities/user.entity';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { BullModule } from '@nestjs/bull';
+import { QueueNameEnum } from '@app/job';
+import { AssetEntity } from '@app/database/entities/asset.entity';
+import { ExifEntity } from '@app/database/entities/exif.entity';
+import { AssetRepository, ASSET_REPOSITORY } from '../asset/asset-repository';
+
+@Module({
+  imports: [
+    TypeOrmModule.forFeature([UserEntity, AssetEntity, ExifEntity]),
+    ImmichJwtModule,
+    JwtModule.register(jwtConfig),
+    BullModule.registerQueue(
+      {
+        name: QueueNameEnum.THUMBNAIL_GENERATION,
+        defaultJobOptions: {
+          attempts: 3,
+          removeOnComplete: true,
+          removeOnFail: false,
+        },
+      },
+      {
+        name: QueueNameEnum.ASSET_UPLOADED,
+        defaultJobOptions: {
+          attempts: 3,
+          removeOnComplete: true,
+          removeOnFail: false,
+        },
+      },
+      {
+        name: QueueNameEnum.METADATA_EXTRACTION,
+        defaultJobOptions: {
+          attempts: 3,
+          removeOnComplete: true,
+          removeOnFail: false,
+        },
+      },
+      {
+        name: QueueNameEnum.VIDEO_CONVERSION,
+        defaultJobOptions: {
+          attempts: 3,
+          removeOnComplete: true,
+          removeOnFail: false,
+        },
+      },
+      {
+        name: QueueNameEnum.CHECKSUM_GENERATION,
+        defaultJobOptions: {
+          attempts: 3,
+          removeOnComplete: true,
+          removeOnFail: false,
+        },
+      },
+      {
+        name: QueueNameEnum.MACHINE_LEARNING,
+        defaultJobOptions: {
+          attempts: 3,
+          removeOnComplete: true,
+          removeOnFail: false,
+        },
+      },
+    ),
+  ],
+  controllers: [JobController],
+  providers: [
+    JobService,
+    ImmichJwtService,
+    {
+      provide: ASSET_REPOSITORY,
+      useClass: AssetRepository,
+    },
+  ],
+})
+export class JobModule {}
diff --git a/server/apps/immich/src/api-v1/job/job.service.ts b/server/apps/immich/src/api-v1/job/job.service.ts
new file mode 100644
index 0000000000..761a70906f
--- /dev/null
+++ b/server/apps/immich/src/api-v1/job/job.service.ts
@@ -0,0 +1,180 @@
+import {
+  exifExtractionProcessorName,
+  generateJPEGThumbnailProcessorName,
+  IMetadataExtractionJob,
+  IThumbnailGenerationJob,
+  IVideoTranscodeJob,
+  MachineLearningJobNameEnum,
+  QueueNameEnum,
+  videoMetadataExtractionProcessorName,
+} from '@app/job';
+import { InjectQueue } from '@nestjs/bull';
+import { Queue } from 'bull';
+import { BadRequestException, Inject, Injectable } from '@nestjs/common';
+import { AllJobStatusResponseDto } from './response-dto/all-job-status-response.dto';
+import { randomUUID } from 'crypto';
+import { ASSET_REPOSITORY, IAssetRepository } from '../asset/asset-repository';
+import { AssetType } from '@app/database/entities/asset.entity';
+import { GetJobDto, JobId } from './dto/get-job.dto';
+import { JobStatusResponseDto } from './response-dto/job-status-response.dto';
+import { IMachineLearningJob } from '@app/job/interfaces/machine-learning.interface';
+
+@Injectable()
+export class JobService {
+  constructor(
+    @InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
+    private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>,
+
+    @InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
+    private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
+
+    @InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
+    private videoConversionQueue: Queue<IVideoTranscodeJob>,
+
+    @InjectQueue(QueueNameEnum.MACHINE_LEARNING)
+    private machineLearningQueue: Queue<IMachineLearningJob>,
+
+    @Inject(ASSET_REPOSITORY)
+    private _assetRepository: IAssetRepository,
+  ) {
+    this.thumbnailGeneratorQueue.empty();
+    this.metadataExtractionQueue.empty();
+    this.videoConversionQueue.empty();
+  }
+
+  async startJob(jobDto: GetJobDto): Promise<number> {
+    switch (jobDto.jobId) {
+      case JobId.THUMBNAIL_GENERATION:
+        return this.runThumbnailGenerationJob();
+      case JobId.METADATA_EXTRACTION:
+        return this.runMetadataExtractionJob();
+      case JobId.VIDEO_CONVERSION:
+        return 0;
+      case JobId.MACHINE_LEARNING:
+        return this.runMachineLearningPipeline();
+      default:
+        throw new BadRequestException('Invalid job id');
+    }
+  }
+
+  async getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
+    const thumbnailGeneratorJobCount = await this.thumbnailGeneratorQueue.getJobCounts();
+    const metadataExtractionJobCount = await this.metadataExtractionQueue.getJobCounts();
+    const videoConversionJobCount = await this.videoConversionQueue.getJobCounts();
+    const machineLearningJobCount = await this.machineLearningQueue.getJobCounts();
+
+    const response = new AllJobStatusResponseDto();
+    response.isThumbnailGenerationActive = Boolean(thumbnailGeneratorJobCount.waiting);
+    response.thumbnailGenerationQueueCount = thumbnailGeneratorJobCount;
+    response.isMetadataExtractionActive = Boolean(metadataExtractionJobCount.waiting);
+    response.metadataExtractionQueueCount = metadataExtractionJobCount;
+    response.isVideoConversionActive = Boolean(videoConversionJobCount.waiting);
+    response.videoConversionQueueCount = videoConversionJobCount;
+    response.isMachineLearningActive = Boolean(machineLearningJobCount.waiting);
+    response.machineLearningQueueCount = machineLearningJobCount;
+
+    return response;
+  }
+
+  async getJobStatus(query: GetJobDto): Promise<JobStatusResponseDto> {
+    const response = new JobStatusResponseDto();
+    if (query.jobId === JobId.THUMBNAIL_GENERATION) {
+      response.isActive = Boolean((await this.thumbnailGeneratorQueue.getJobCounts()).waiting);
+      response.queueCount = await this.thumbnailGeneratorQueue.getJobCounts();
+    }
+
+    if (query.jobId === JobId.METADATA_EXTRACTION) {
+      response.isActive = Boolean((await this.metadataExtractionQueue.getJobCounts()).waiting);
+      response.queueCount = await this.metadataExtractionQueue.getJobCounts();
+    }
+
+    if (query.jobId === JobId.VIDEO_CONVERSION) {
+      response.isActive = Boolean((await this.videoConversionQueue.getJobCounts()).waiting);
+      response.queueCount = await this.videoConversionQueue.getJobCounts();
+    }
+
+    return response;
+  }
+
+  async stopJob(query: GetJobDto): Promise<number> {
+    switch (query.jobId) {
+      case JobId.THUMBNAIL_GENERATION:
+        this.thumbnailGeneratorQueue.empty();
+        return 0;
+      case JobId.METADATA_EXTRACTION:
+        this.metadataExtractionQueue.empty();
+        return 0;
+      case JobId.VIDEO_CONVERSION:
+        this.videoConversionQueue.empty();
+        return 0;
+      case JobId.MACHINE_LEARNING:
+        this.machineLearningQueue.empty();
+        return 0;
+      default:
+        throw new BadRequestException('Invalid job id');
+    }
+  }
+
+  private async runThumbnailGenerationJob(): Promise<number> {
+    const jobCount = await this.thumbnailGeneratorQueue.getJobCounts();
+
+    if (jobCount.waiting > 0) {
+      throw new BadRequestException('Thumbnail generation job is already running');
+    }
+
+    const assetsWithNoThumbnail = await this._assetRepository.getAssetWithNoThumbnail();
+
+    for (const asset of assetsWithNoThumbnail) {
+      await this.thumbnailGeneratorQueue.add(generateJPEGThumbnailProcessorName, { asset }, { jobId: randomUUID() });
+    }
+
+    return assetsWithNoThumbnail.length;
+  }
+
+  private async runMetadataExtractionJob(): Promise<number> {
+    const jobCount = await this.metadataExtractionQueue.getJobCounts();
+
+    if (jobCount.waiting > 0) {
+      throw new BadRequestException('Metadata extraction job is already running');
+    }
+
+    const assetsWithNoExif = await this._assetRepository.getAssetWithNoEXIF();
+    for (const asset of assetsWithNoExif) {
+      if (asset.type === AssetType.VIDEO) {
+        await this.metadataExtractionQueue.add(
+          videoMetadataExtractionProcessorName,
+          { asset, fileName: asset.id },
+          { jobId: randomUUID() },
+        );
+      } else {
+        await this.metadataExtractionQueue.add(
+          exifExtractionProcessorName,
+          { asset, fileName: asset.id },
+          { jobId: randomUUID() },
+        );
+      }
+    }
+    return assetsWithNoExif.length;
+  }
+
+  private async runMachineLearningPipeline(): Promise<number> {
+    const jobCount = await this.machineLearningQueue.getJobCounts();
+
+    if (jobCount.waiting > 0) {
+      throw new BadRequestException('Metadata extraction job is already running');
+    }
+
+    const assetWithNoSmartInfo = await this._assetRepository.getAssetWithNoSmartInfo();
+
+    for (const asset of assetWithNoSmartInfo) {
+      await this.machineLearningQueue.add(MachineLearningJobNameEnum.IMAGE_TAGGING, { asset }, { jobId: randomUUID() });
+      await this.machineLearningQueue.add(
+        MachineLearningJobNameEnum.OBJECT_DETECTION,
+        { asset },
+        { jobId: randomUUID() },
+      );
+    }
+
+    return assetWithNoSmartInfo.length;
+  }
+}
diff --git a/server/apps/immich/src/api-v1/job/response-dto/all-job-status-response.dto.ts b/server/apps/immich/src/api-v1/job/response-dto/all-job-status-response.dto.ts
new file mode 100644
index 0000000000..aeb558acd5
--- /dev/null
+++ b/server/apps/immich/src/api-v1/job/response-dto/all-job-status-response.dto.ts
@@ -0,0 +1,35 @@
+import { ApiProperty } from '@nestjs/swagger';
+
+export class JobCounts {
+  active!: number;
+  completed!: number;
+  failed!: number;
+  delayed!: number;
+  waiting!: number;
+}
+export class AllJobStatusResponseDto {
+  isThumbnailGenerationActive!: boolean;
+  isMetadataExtractionActive!: boolean;
+  isVideoConversionActive!: boolean;
+  isMachineLearningActive!: boolean;
+
+  @ApiProperty({
+    type: JobCounts,
+  })
+  thumbnailGenerationQueueCount!: JobCounts;
+
+  @ApiProperty({
+    type: JobCounts,
+  })
+  metadataExtractionQueueCount!: JobCounts;
+
+  @ApiProperty({
+    type: JobCounts,
+  })
+  videoConversionQueueCount!: JobCounts;
+
+  @ApiProperty({
+    type: JobCounts,
+  })
+  machineLearningQueueCount!: JobCounts;
+}
diff --git a/server/apps/immich/src/api-v1/job/response-dto/job-status-response.dto.ts b/server/apps/immich/src/api-v1/job/response-dto/job-status-response.dto.ts
new file mode 100644
index 0000000000..fe411fa2ef
--- /dev/null
+++ b/server/apps/immich/src/api-v1/job/response-dto/job-status-response.dto.ts
@@ -0,0 +1,6 @@
+import Bull from 'bull';
+
+export class JobStatusResponseDto {
+  isActive!: boolean;
+  queueCount!: Bull.JobCounts;
+}
diff --git a/server/apps/immich/src/api-v1/server-info/response-dto/server-info-response.dto.ts b/server/apps/immich/src/api-v1/server-info/response-dto/server-info-response.dto.ts
index 444292091b..e844da6899 100644
--- a/server/apps/immich/src/api-v1/server-info/response-dto/server-info-response.dto.ts
+++ b/server/apps/immich/src/api-v1/server-info/response-dto/server-info-response.dto.ts
@@ -5,13 +5,13 @@ export class ServerInfoResponseDto {
   diskUse!: string;
   diskAvailable!: string;
 
-  @ApiProperty({ type: 'integer' })
+  @ApiProperty({ type: 'integer', format: 'int64' })
   diskSizeRaw!: number;
 
-  @ApiProperty({ type: 'integer' })
+  @ApiProperty({ type: 'integer', format: 'int64' })
   diskUseRaw!: number;
 
-  @ApiProperty({ type: 'integer' })
+  @ApiProperty({ type: 'integer', format: 'int64' })
   diskAvailableRaw!: number;
 
   @ApiProperty({ type: 'number', format: 'float' })
diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts
index 16f644c030..3aef3d4b4d 100644
--- a/server/apps/immich/src/app.module.ts
+++ b/server/apps/immich/src/app.module.ts
@@ -15,6 +15,7 @@ import { AppController } from './app.controller';
 import { ScheduleModule } from '@nestjs/schedule';
 import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
 import { DatabaseModule } from '@app/database';
+import { JobModule } from './api-v1/job/job.module';
 
 @Module({
   imports: [
@@ -55,6 +56,8 @@ import { DatabaseModule } from '@app/database';
     ScheduleModule.forRoot(),
 
     ScheduleTasksModule,
+
+    JobModule,
   ],
   controllers: [AppController],
   providers: [],
diff --git a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.module.ts b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.module.ts
index 51f70fe8f5..e932a67f97 100644
--- a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.module.ts
+++ b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.module.ts
@@ -3,18 +3,14 @@ import { Module } from '@nestjs/common';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { AssetEntity } from '@app/database/entities/asset.entity';
 import { ScheduleTasksService } from './schedule-tasks.service';
-import {
-  metadataExtractionQueueName,
-  thumbnailGeneratorQueueName,
-  videoConversionQueueName,
-} from '@app/job/constants/queue-name.constant';
+import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
 import { ExifEntity } from '@app/database/entities/exif.entity';
 
 @Module({
   imports: [
     TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
     BullModule.registerQueue({
-      name: videoConversionQueueName,
+      name: QueueNameEnum.VIDEO_CONVERSION,
       defaultJobOptions: {
         attempts: 3,
         removeOnComplete: true,
@@ -22,7 +18,7 @@ import { ExifEntity } from '@app/database/entities/exif.entity';
       },
     }),
     BullModule.registerQueue({
-      name: thumbnailGeneratorQueueName,
+      name: QueueNameEnum.THUMBNAIL_GENERATION,
       defaultJobOptions: {
         attempts: 3,
         removeOnComplete: true,
@@ -31,7 +27,7 @@ import { ExifEntity } from '@app/database/entities/exif.entity';
     }),
 
     BullModule.registerQueue({
-      name: metadataExtractionQueueName,
+      name: QueueNameEnum.METADATA_EXTRACTION,
       defaultJobOptions: {
         attempts: 3,
         removeOnComplete: true,
diff --git a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts
index cbdf3d8b16..d814d8b307 100644
--- a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts
+++ b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts
@@ -12,11 +12,9 @@ import {
   generateWEBPThumbnailProcessorName,
   IMetadataExtractionJob,
   IVideoTranscodeJob,
-  metadataExtractionQueueName,
   mp4ConversionProcessorName,
+  QueueNameEnum,
   reverseGeocodingProcessorName,
-  thumbnailGeneratorQueueName,
-  videoConversionQueueName,
   videoMetadataExtractionProcessorName,
 } from '@app/job';
 import { ConfigService } from '@nestjs/config';
@@ -30,13 +28,13 @@ export class ScheduleTasksService {
     @InjectRepository(ExifEntity)
     private exifRepository: Repository<ExifEntity>,
 
-    @InjectQueue(thumbnailGeneratorQueueName)
+    @InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
     private thumbnailGeneratorQueue: Queue,
 
-    @InjectQueue(videoConversionQueueName)
+    @InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
     private videoConversionQueue: Queue<IVideoTranscodeJob>,
 
-    @InjectQueue(metadataExtractionQueueName)
+    @InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
     private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
 
     private configService: ConfigService,
@@ -108,11 +106,11 @@ export class ScheduleTasksService {
 
   @Cron(CronExpression.EVERY_DAY_AT_3AM)
   async extractExif() {
-    const exifAssets = await this.assetRepository.find({
-      where: {
-        exifInfo: IsNull(),
-      },
-    });
+    const exifAssets = await this.assetRepository
+      .createQueryBuilder('asset')
+      .leftJoinAndSelect('asset.exifInfo', 'ei')
+      .where('ei."assetId" IS NULL')
+      .getMany();
 
     for (const asset of exifAssets) {
       if (asset.type === AssetType.VIDEO) {
diff --git a/server/apps/microservices/src/microservices.module.ts b/server/apps/microservices/src/microservices.module.ts
index 46b3b6afe0..0353bb08a9 100644
--- a/server/apps/microservices/src/microservices.module.ts
+++ b/server/apps/microservices/src/microservices.module.ts
@@ -4,13 +4,7 @@ import { AssetEntity } from '@app/database/entities/asset.entity';
 import { ExifEntity } from '@app/database/entities/exif.entity';
 import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
 import { UserEntity } from '@app/database/entities/user.entity';
-import {
-  assetUploadedQueueName,
-  generateChecksumQueueName,
-  metadataExtractionQueueName,
-  thumbnailGeneratorQueueName,
-  videoConversionQueueName,
-} from '@app/job/constants/queue-name.constant';
+import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
 import { BullModule } from '@nestjs/bull';
 import { Module } from '@nestjs/common';
 import { ConfigModule, ConfigService } from '@nestjs/config';
@@ -19,6 +13,7 @@ import { CommunicationModule } from '../../immich/src/api-v1/communication/commu
 import { MicroservicesService } from './microservices.service';
 import { AssetUploadedProcessor } from './processors/asset-uploaded.processor';
 import { GenerateChecksumProcessor } from './processors/generate-checksum.processor';
+import { MachineLearningProcessor } from './processors/machine-learning.processor';
 import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
 import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
 import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
@@ -42,7 +37,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
     }),
     BullModule.registerQueue(
       {
-        name: thumbnailGeneratorQueueName,
+        name: QueueNameEnum.THUMBNAIL_GENERATION,
         defaultJobOptions: {
           attempts: 3,
           removeOnComplete: true,
@@ -50,7 +45,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
         },
       },
       {
-        name: assetUploadedQueueName,
+        name: QueueNameEnum.ASSET_UPLOADED,
         defaultJobOptions: {
           attempts: 3,
           removeOnComplete: true,
@@ -58,7 +53,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
         },
       },
       {
-        name: metadataExtractionQueueName,
+        name: QueueNameEnum.METADATA_EXTRACTION,
         defaultJobOptions: {
           attempts: 3,
           removeOnComplete: true,
@@ -66,7 +61,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
         },
       },
       {
-        name: videoConversionQueueName,
+        name: QueueNameEnum.VIDEO_CONVERSION,
         defaultJobOptions: {
           attempts: 3,
           removeOnComplete: true,
@@ -74,7 +69,15 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
         },
       },
       {
-        name: generateChecksumQueueName,
+        name: QueueNameEnum.CHECKSUM_GENERATION,
+        defaultJobOptions: {
+          attempts: 3,
+          removeOnComplete: true,
+          removeOnFail: false,
+        },
+      },
+      {
+        name: QueueNameEnum.MACHINE_LEARNING,
         defaultJobOptions: {
           attempts: 3,
           removeOnComplete: true,
@@ -92,6 +95,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
     MetadataExtractionProcessor,
     VideoTranscodeProcessor,
     GenerateChecksumProcessor,
+    MachineLearningProcessor,
     ConfigService,
   ],
   exports: [],
diff --git a/server/apps/microservices/src/microservices.service.ts b/server/apps/microservices/src/microservices.service.ts
index e10fe46b83..5a03220f97 100644
--- a/server/apps/microservices/src/microservices.service.ts
+++ b/server/apps/microservices/src/microservices.service.ts
@@ -1,4 +1,4 @@
-import { generateChecksumQueueName } from '@app/job';
+import { QueueNameEnum } from '@app/job';
 import { InjectQueue } from '@nestjs/bull';
 import { Injectable, OnModuleInit } from '@nestjs/common';
 import { Queue } from 'bull';
@@ -6,14 +6,18 @@ import { randomUUID } from 'node:crypto';
 
 @Injectable()
 export class MicroservicesService implements OnModuleInit {
-  constructor (
-    @InjectQueue(generateChecksumQueueName)
+  constructor(
+    @InjectQueue(QueueNameEnum.CHECKSUM_GENERATION)
     private generateChecksumQueue: Queue,
   ) {}
 
   async onModuleInit() {
-    await this.generateChecksumQueue.add({}, {
-      jobId: randomUUID(), delay: 10000 // wait for migration
-    });
+    await this.generateChecksumQueue.add(
+      {},
+      {
+        jobId: randomUUID(),
+        delay: 10000, // wait for migration
+      },
+    );
   }
 }
diff --git a/server/apps/microservices/src/processors/asset-uploaded.processor.ts b/server/apps/microservices/src/processors/asset-uploaded.processor.ts
index 7b70c07482..340d22a06d 100644
--- a/server/apps/microservices/src/processors/asset-uploaded.processor.ts
+++ b/server/apps/microservices/src/processors/asset-uploaded.processor.ts
@@ -4,30 +4,27 @@ import {
   IMetadataExtractionJob,
   IThumbnailGenerationJob,
   IVideoTranscodeJob,
-  assetUploadedQueueName,
-  metadataExtractionQueueName,
-  thumbnailGeneratorQueueName,
-  videoConversionQueueName,
   assetUploadedProcessorName,
   exifExtractionProcessorName,
   generateJPEGThumbnailProcessorName,
   mp4ConversionProcessorName,
   videoMetadataExtractionProcessorName,
+  QueueNameEnum,
 } from '@app/job';
 import { InjectQueue, Process, Processor } from '@nestjs/bull';
 import { Job, Queue } from 'bull';
 import { randomUUID } from 'crypto';
 
-@Processor(assetUploadedQueueName)
+@Processor(QueueNameEnum.ASSET_UPLOADED)
 export class AssetUploadedProcessor {
   constructor(
-    @InjectQueue(thumbnailGeneratorQueueName)
+    @InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
     private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>,
 
-    @InjectQueue(metadataExtractionQueueName)
+    @InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
     private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
 
-    @InjectQueue(videoConversionQueueName)
+    @InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
     private videoConversionQueue: Queue<IVideoTranscodeJob>,
   ) {}
 
diff --git a/server/apps/microservices/src/processors/generate-checksum.processor.ts b/server/apps/microservices/src/processors/generate-checksum.processor.ts
index 2dcd1c2bd4..bbf20cccd4 100644
--- a/server/apps/microservices/src/processors/generate-checksum.processor.ts
+++ b/server/apps/microservices/src/processors/generate-checksum.processor.ts
@@ -1,5 +1,5 @@
 import { AssetEntity } from '@app/database/entities/asset.entity';
-import { generateChecksumQueueName } from '@app/job';
+import { QueueNameEnum } from '@app/job';
 import { Process, Processor } from '@nestjs/bull';
 import { Logger } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
@@ -8,7 +8,7 @@ import fs from 'node:fs';
 import { FindOptionsWhere, IsNull, MoreThan, QueryFailedError, Repository } from 'typeorm';
 
 // TODO: just temporary task to generate previous uploaded assets.
-@Processor(generateChecksumQueueName)
+@Processor(QueueNameEnum.CHECKSUM_GENERATION)
 export class GenerateChecksumProcessor {
   constructor(
     @InjectRepository(AssetEntity)
@@ -33,7 +33,7 @@ export class GenerateChecksumProcessor {
       const assets = await this.assetRepository.find({
         where: whereStat,
         take: pageSize,
-        order: { id: 'ASC' }
+        order: { id: 'ASC' },
       });
 
       if (!assets?.length) {
diff --git a/server/apps/microservices/src/processors/machine-learning.processor.ts b/server/apps/microservices/src/processors/machine-learning.processor.ts
new file mode 100644
index 0000000000..39c92fd9f0
--- /dev/null
+++ b/server/apps/microservices/src/processors/machine-learning.processor.ts
@@ -0,0 +1,60 @@
+import { AssetEntity } from '@app/database/entities/asset.entity';
+import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
+import { MachineLearningJobNameEnum, QueueNameEnum } from '@app/job';
+import { IMachineLearningJob } from '@app/job/interfaces/machine-learning.interface';
+import { Process, Processor } from '@nestjs/bull';
+import { Logger } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import axios from 'axios';
+import { Job } from 'bull';
+import { Repository } from 'typeorm';
+
+@Processor(QueueNameEnum.MACHINE_LEARNING)
+export class MachineLearningProcessor {
+  constructor(
+    @InjectRepository(SmartInfoEntity)
+    private smartInfoRepository: Repository<SmartInfoEntity>,
+  ) {}
+
+  @Process({ name: MachineLearningJobNameEnum.IMAGE_TAGGING, concurrency: 2 })
+  async tagImage(job: Job<IMachineLearningJob>) {
+    const { asset } = job.data;
+
+    const res = await axios.post('http://immich-machine-learning:3003/image-classifier/tag-image', {
+      thumbnailPath: asset.resizePath,
+    });
+
+    if (res.status == 201 && res.data.length > 0) {
+      const smartInfo = new SmartInfoEntity();
+      smartInfo.assetId = asset.id;
+      smartInfo.tags = [...res.data];
+
+      await this.smartInfoRepository.upsert(smartInfo, {
+        conflictPaths: ['assetId'],
+      });
+    }
+  }
+
+  @Process({ name: MachineLearningJobNameEnum.OBJECT_DETECTION, concurrency: 2 })
+  async detectObject(job: Job<IMachineLearningJob>) {
+    try {
+      const { asset }: { asset: AssetEntity } = job.data;
+
+      const res = await axios.post('http://immich-machine-learning:3003/object-detection/detect-object', {
+        thumbnailPath: asset.resizePath,
+      });
+
+      if (res.status == 201 && res.data.length > 0) {
+        const smartInfo = new SmartInfoEntity();
+        smartInfo.assetId = asset.id;
+        smartInfo.objects = [...res.data];
+
+        await this.smartInfoRepository.upsert(smartInfo, {
+          conflictPaths: ['assetId'],
+        });
+      }
+    } catch (error) {
+      Logger.error(`Failed to trigger object detection pipe line ${String(error)}`);
+    }
+  }
+}
diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts
index a06972e042..27f2688d74 100644
--- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts
+++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts
@@ -1,23 +1,19 @@
 import { ImmichLogLevel } from '@app/common/constants/log-level.constant';
 import { AssetEntity } from '@app/database/entities/asset.entity';
 import { ExifEntity } from '@app/database/entities/exif.entity';
-import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
 import {
   IExifExtractionProcessor,
   IVideoLengthExtractionProcessor,
   exifExtractionProcessorName,
-  imageTaggingProcessorName,
-  objectDetectionProcessorName,
   videoMetadataExtractionProcessorName,
-  metadataExtractionQueueName,
   reverseGeocodingProcessorName,
   IReverseGeocodingProcessor,
+  QueueNameEnum,
 } from '@app/job';
 import { Process, Processor } from '@nestjs/bull';
 import { Logger } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import { InjectRepository } from '@nestjs/typeorm';
-import axios from 'axios';
 import { Job } from 'bull';
 import exifr from 'exifr';
 import ffmpeg from 'fluent-ffmpeg';
@@ -79,7 +75,7 @@ export interface GeoData {
   distance: number;
 }
 
-@Processor(metadataExtractionQueueName)
+@Processor(QueueNameEnum.METADATA_EXTRACTION)
 export class MetadataExtractionProcessor {
   private isGeocodeInitialized = false;
   private logLevel: ImmichLogLevel;
@@ -91,9 +87,6 @@ export class MetadataExtractionProcessor {
     @InjectRepository(ExifEntity)
     private exifRepository: Repository<ExifEntity>,
 
-    @InjectRepository(SmartInfoEntity)
-    private smartInfoRepository: Repository<SmartInfoEntity>,
-
     private configService: ConfigService,
   ) {
     if (!configService.get('DISABLE_REVERSE_GEOCODING')) {
@@ -109,7 +102,8 @@ export class MetadataExtractionProcessor {
           alternateNames: false,
         },
         countries: [],
-        dumpDirectory: configService.get('REVERSE_GEOCODING_DUMP_DIRECTORY') || (process.cwd() + '/.reverse-geocoding-dump/'),
+        dumpDirectory:
+          configService.get('REVERSE_GEOCODING_DUMP_DIRECTORY') || process.cwd() + '/.reverse-geocoding-dump/',
       }).then(() => {
         this.isGeocodeInitialized = true;
         Logger.log('Reverse Geocoding Initialised');
@@ -273,48 +267,6 @@ export class MetadataExtractionProcessor {
     }
   }
 
-  @Process({ name: imageTaggingProcessorName, concurrency: 2 })
-  async tagImage(job: Job) {
-    const { asset }: { asset: AssetEntity } = job.data;
-
-    const res = await axios.post('http://immich-machine-learning:3003/image-classifier/tag-image', {
-      thumbnailPath: asset.resizePath,
-    });
-
-    if (res.status == 201 && res.data.length > 0) {
-      const smartInfo = new SmartInfoEntity();
-      smartInfo.assetId = asset.id;
-      smartInfo.tags = [...res.data];
-
-      await this.smartInfoRepository.upsert(smartInfo, {
-        conflictPaths: ['assetId'],
-      });
-    }
-  }
-
-  @Process({ name: objectDetectionProcessorName, concurrency: 2 })
-  async detectObject(job: Job) {
-    try {
-      const { asset }: { asset: AssetEntity } = job.data;
-
-      const res = await axios.post('http://immich-machine-learning:3003/object-detection/detect-object', {
-        thumbnailPath: asset.resizePath,
-      });
-
-      if (res.status == 201 && res.data.length > 0) {
-        const smartInfo = new SmartInfoEntity();
-        smartInfo.assetId = asset.id;
-        smartInfo.objects = [...res.data];
-
-        await this.smartInfoRepository.upsert(smartInfo, {
-          conflictPaths: ['assetId'],
-        });
-      }
-    } catch (error) {
-      Logger.error(`Failed to trigger object detection pipe line ${String(error)}`);
-    }
-  }
-
   @Process({ name: videoMetadataExtractionProcessorName, concurrency: 2 })
   async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
     const { asset, fileName } = job.data;
diff --git a/server/apps/microservices/src/processors/thumbnail.processor.ts b/server/apps/microservices/src/processors/thumbnail.processor.ts
index 130aa6c1ad..211da0537c 100644
--- a/server/apps/microservices/src/processors/thumbnail.processor.ts
+++ b/server/apps/microservices/src/processors/thumbnail.processor.ts
@@ -5,11 +5,9 @@ import {
   WebpGeneratorProcessor,
   generateJPEGThumbnailProcessorName,
   generateWEBPThumbnailProcessorName,
-  imageTaggingProcessorName,
-  objectDetectionProcessorName,
-  metadataExtractionQueueName,
-  thumbnailGeneratorQueueName,
   JpegGeneratorProcessor,
+  QueueNameEnum,
+  MachineLearningJobNameEnum,
 } from '@app/job';
 import { InjectQueue, Process, Processor } from '@nestjs/bull';
 import { Logger } from '@nestjs/common';
@@ -25,8 +23,9 @@ import sharp from 'sharp';
 import { Repository } from 'typeorm/repository/Repository';
 import { join } from 'path';
 import { CommunicationGateway } from 'apps/immich/src/api-v1/communication/communication.gateway';
+import { IMachineLearningJob } from '@app/job/interfaces/machine-learning.interface';
 
-@Processor(thumbnailGeneratorQueueName)
+@Processor(QueueNameEnum.THUMBNAIL_GENERATION)
 export class ThumbnailGeneratorProcessor {
   private logLevel: ImmichLogLevel;
 
@@ -34,13 +33,13 @@ export class ThumbnailGeneratorProcessor {
     @InjectRepository(AssetEntity)
     private assetRepository: Repository<AssetEntity>,
 
-    @InjectQueue(thumbnailGeneratorQueueName)
+    @InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
     private thumbnailGeneratorQueue: Queue,
 
     private wsCommunicationGateway: CommunicationGateway,
 
-    @InjectQueue(metadataExtractionQueueName)
-    private metadataExtractionQueue: Queue,
+    @InjectQueue(QueueNameEnum.MACHINE_LEARNING)
+    private machineLearningQueue: Queue<IMachineLearningJob>,
 
     private configService: ConfigService,
   ) {
@@ -80,8 +79,12 @@ export class ThumbnailGeneratorProcessor {
       asset.resizePath = jpegThumbnailPath;
 
       await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
-      await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
-      await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
+      await this.machineLearningQueue.add(MachineLearningJobNameEnum.IMAGE_TAGGING, { asset }, { jobId: randomUUID() });
+      await this.machineLearningQueue.add(
+        MachineLearningJobNameEnum.OBJECT_DETECTION,
+        { asset },
+        { jobId: randomUUID() },
+      );
       this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
     }
 
@@ -110,8 +113,12 @@ export class ThumbnailGeneratorProcessor {
       asset.resizePath = jpegThumbnailPath;
 
       await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
-      await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
-      await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
+      await this.machineLearningQueue.add(MachineLearningJobNameEnum.IMAGE_TAGGING, { asset }, { jobId: randomUUID() });
+      await this.machineLearningQueue.add(
+        MachineLearningJobNameEnum.OBJECT_DETECTION,
+        { asset },
+        { jobId: randomUUID() },
+      );
 
       this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
     }
diff --git a/server/apps/microservices/src/processors/video-transcode.processor.ts b/server/apps/microservices/src/processors/video-transcode.processor.ts
index 45ea17ab09..2e04a75e0a 100644
--- a/server/apps/microservices/src/processors/video-transcode.processor.ts
+++ b/server/apps/microservices/src/processors/video-transcode.processor.ts
@@ -1,7 +1,7 @@
 import { APP_UPLOAD_LOCATION } from '@app/common/constants';
 import { AssetEntity } from '@app/database/entities/asset.entity';
+import { QueueNameEnum } from '@app/job';
 import { mp4ConversionProcessorName } from '@app/job/constants/job-name.constant';
-import { videoConversionQueueName } from '@app/job/constants/queue-name.constant';
 import { IMp4ConversionProcessor } from '@app/job/interfaces/video-transcode.interface';
 import { Process, Processor } from '@nestjs/bull';
 import { Logger } from '@nestjs/common';
@@ -11,7 +11,7 @@ import ffmpeg from 'fluent-ffmpeg';
 import { existsSync, mkdirSync } from 'fs';
 import { Repository } from 'typeorm';
 
-@Processor(videoConversionQueueName)
+@Processor(QueueNameEnum.VIDEO_CONVERSION)
 export class VideoTranscodeProcessor {
   constructor(
     @InjectRepository(AssetEntity)
diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json
index e78036840f..955bfe7c88 100644
--- a/server/immich-openapi-specs.json
+++ b/server/immich-openapi-specs.json
@@ -1 +1 @@
-{"openapi":"3.0.0","paths":{"/user":{"get":{"operationId":"getAllUsers","parameters":[{"name":"isAll","required":true,"in":"query","schema":{"type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}}}}}},"tags":["User"],"security":[{"bearer":[]}]},"post":{"operationId":"createUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]},"put":{"operationId":"updateUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/info/{userId}":{"get":{"operationId":"getUserById","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"]}},"/user/me":{"get":{"operationId":"getMyUserInfo","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/count":{"get":{"operationId":"getUserCount","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCountResponseDto"}}}}},"tags":["User"]}},"/user/profile-image":{"post":{"operationId":"createProfileImage","parameters":[],"requestBody":{"required":true,"description":"A new avatar for the user","content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/CreateProfileImageDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProfileImageResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/profile-image/{userId}":{"get":{"operationId":"getProfileImage","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["User"]}},"/asset/upload":{"post":{"operationId":"uploadFile","parameters":[],"requestBody":{"required":true,"description":"Asset Upload Information","content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/AssetFileUploadDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetFileUploadResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/download":{"get":{"operationId":"downloadFile","parameters":[{"name":"aid","required":true,"in":"query","schema":{"title":"Device Asset ID","type":"string"}},{"name":"did","required":true,"in":"query","schema":{"title":"Device ID","type":"string"}},{"name":"isThumb","required":false,"in":"query","schema":{"title":"Is serve thumbnail (resize) file","type":"boolean"}},{"name":"isWeb","required":false,"in":"query","schema":{"title":"Is request made from web","type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/file":{"get":{"operationId":"serveFile","parameters":[{"name":"aid","required":true,"in":"query","schema":{"title":"Device Asset ID","type":"string"}},{"name":"did","required":true,"in":"query","schema":{"title":"Device ID","type":"string"}},{"name":"isThumb","required":false,"in":"query","schema":{"title":"Is serve thumbnail (resize) file","type":"boolean"}},{"name":"isWeb","required":false,"in":"query","schema":{"title":"Is request made from web","type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/thumbnail/{assetId}":{"get":{"operationId":"getAssetThumbnail","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}},{"name":"format","required":false,"in":"query","schema":{"$ref":"#/components/schemas/ThumbnailFormat"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/curated-objects":{"get":{"operationId":"getCuratedObjects","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CuratedObjectsResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/curated-locations":{"get":{"operationId":"getCuratedLocations","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CuratedLocationsResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/search-terms":{"get":{"operationId":"getAssetSearchTerms","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/search":{"post":{"operationId":"searchAsset","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchAssetDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/count-by-time-bucket":{"post":{"operationId":"getAssetCountByTimeBucket","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAssetCountByTimeBucketDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetCountByTimeBucketResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/count-by-user-id":{"get":{"operationId":"getAssetCountByUserId","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetCountByUserIdResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset":{"get":{"operationId":"getAllAssets","summary":"","description":"Get all AssetEntity belong to the user","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]},"delete":{"operationId":"deleteAsset","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DeleteAssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/time-bucket":{"post":{"operationId":"getAssetByTimeBucket","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAssetByTimeBucketDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/{deviceId}":{"get":{"operationId":"getUserAssetsByDeviceId","summary":"","description":"Get all asset of a device that are in the database, ID only.","parameters":[{"name":"deviceId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/assetById/{assetId}":{"get":{"operationId":"getAssetById","summary":"","description":"Get a single asset's information","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/check":{"post":{"operationId":"checkDuplicateAsset","summary":"","description":"Check duplicated asset before uploading - for Web upload used","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckDuplicateAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckDuplicateAssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/auth/login":{"post":{"operationId":"login","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginCredentialDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponseDto"}}}}},"tags":["Authentication"]}},"/auth/admin-sign-up":{"post":{"operationId":"adminSignUp","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignUpDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSignupResponseDto"}}}},"400":{"description":"The server already has an admin"}},"tags":["Authentication"]}},"/auth/validateToken":{"post":{"operationId":"validateAccessToken","parameters":[],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateAccessTokenResponseDto"}}}}},"tags":["Authentication"],"security":[{"bearer":[]}]}},"/auth/logout":{"post":{"operationId":"logout","parameters":[],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogoutResponseDto"}}}}},"tags":["Authentication"]}},"/device-info":{"post":{"operationId":"createDeviceInfo","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDeviceInfoDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceInfoResponseDto"}}}}},"tags":["Device Info"],"security":[{"bearer":[]}]},"patch":{"operationId":"updateDeviceInfo","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDeviceInfoDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceInfoResponseDto"}}}}},"tags":["Device Info"],"security":[{"bearer":[]}]}},"/server-info":{"get":{"operationId":"getServerInfo","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerInfoResponseDto"}}}}},"tags":["Server Info"]}},"/server-info/ping":{"get":{"operationId":"pingServer","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerPingResponse"}}}}},"tags":["Server Info"]}},"/server-info/version":{"get":{"operationId":"getServerVersion","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerVersionReponseDto"}}}}},"tags":["Server Info"]}},"/album/count-by-user-id":{"get":{"operationId":"getAlbumCountByUserId","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumCountResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album":{"post":{"operationId":"createAlbum","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAlbumDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"get":{"operationId":"getAllAlbums","parameters":[{"name":"shared","required":false,"in":"query","schema":{"type":"boolean"}},{"name":"assetId","required":false,"in":"query","description":"Only returns albums that contain the asset\nIgnores the shared parameter\nundefined: get all albums","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/users":{"put":{"operationId":"addUsersToAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddUsersDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/assets":{"put":{"operationId":"addAssetsToAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"delete":{"operationId":"removeAssetFromAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}":{"get":{"operationId":"getAlbumInfo","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"delete":{"operationId":"deleteAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Album"],"security":[{"bearer":[]}]},"patch":{"operationId":"updateAlbumInfo","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAlbumDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/user/{userId}":{"delete":{"operationId":"removeUserFromAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}},{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Album"],"security":[{"bearer":[]}]}}},"info":{"title":"Immich","description":"Immich API","version":"1.17.0","contact":{}},"tags":[],"servers":[{"url":"/api"}],"components":{"securitySchemes":{"bearer":{"scheme":"Bearer","bearerFormat":"JWT","type":"http","name":"JWT","description":"Enter JWT token","in":"header"}},"schemas":{"UserResponseDto":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"createdAt":{"type":"string"},"profileImagePath":{"type":"string"},"shouldChangePassword":{"type":"boolean"},"isAdmin":{"type":"boolean"}},"required":["id","email","firstName","lastName","createdAt","profileImagePath","shouldChangePassword","isAdmin"]},"CreateUserDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"},"firstName":{"type":"string","example":"John"},"lastName":{"type":"string","example":"Doe"}},"required":["email","password","firstName","lastName"]},"UserCountResponseDto":{"type":"object","properties":{"userCount":{"type":"integer"}},"required":["userCount"]},"UpdateUserDto":{"type":"object","properties":{"id":{"type":"string"},"password":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"isAdmin":{"type":"boolean"},"shouldChangePassword":{"type":"boolean"},"profileImagePath":{"type":"string"}},"required":["id"]},"CreateProfileImageDto":{"type":"object","properties":{"file":{"type":"string","format":"binary"}},"required":["file"]},"CreateProfileImageResponseDto":{"type":"object","properties":{"userId":{"type":"string"},"profileImagePath":{"type":"string"}},"required":["userId","profileImagePath"]},"AssetFileUploadDto":{"type":"object","properties":{"assetData":{"type":"string","format":"binary"}},"required":["assetData"]},"AssetFileUploadResponseDto":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},"ThumbnailFormat":{"type":"string","enum":["JPEG","WEBP"]},"CuratedObjectsResponseDto":{"type":"object","properties":{"id":{"type":"string"},"object":{"type":"string"},"resizePath":{"type":"string"},"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["id","object","resizePath","deviceAssetId","deviceId"]},"CuratedLocationsResponseDto":{"type":"object","properties":{"id":{"type":"string"},"city":{"type":"string"},"resizePath":{"type":"string"},"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["id","city","resizePath","deviceAssetId","deviceId"]},"SearchAssetDto":{"type":"object","properties":{"searchTerm":{"type":"string"}},"required":["searchTerm"]},"AssetTypeEnum":{"type":"string","enum":["IMAGE","VIDEO","AUDIO","OTHER"]},"ExifResponseDto":{"type":"object","properties":{"id":{"type":"string","nullable":true,"default":null},"make":{"type":"string","nullable":true,"default":null},"model":{"type":"string","nullable":true,"default":null},"imageName":{"type":"string","nullable":true,"default":null},"exifImageWidth":{"type":"number","nullable":true,"default":null},"exifImageHeight":{"type":"number","nullable":true,"default":null},"fileSizeInByte":{"type":"number","nullable":true,"default":null},"orientation":{"type":"string","nullable":true,"default":null},"dateTimeOriginal":{"format":"date-time","type":"string","nullable":true,"default":null},"modifyDate":{"format":"date-time","type":"string","nullable":true,"default":null},"lensModel":{"type":"string","nullable":true,"default":null},"fNumber":{"type":"number","nullable":true,"default":null},"focalLength":{"type":"number","nullable":true,"default":null},"iso":{"type":"number","nullable":true,"default":null},"exposureTime":{"type":"number","nullable":true,"default":null},"latitude":{"type":"number","nullable":true,"default":null},"longitude":{"type":"number","nullable":true,"default":null},"city":{"type":"string","nullable":true,"default":null},"state":{"type":"string","nullable":true,"default":null},"country":{"type":"string","nullable":true,"default":null}}},"SmartInfoResponseDto":{"type":"object","properties":{"id":{"type":"string"},"tags":{"nullable":true,"type":"array","items":{"type":"string"}},"objects":{"nullable":true,"type":"array","items":{"type":"string"}}}},"AssetResponseDto":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/AssetTypeEnum"},"id":{"type":"string"},"deviceAssetId":{"type":"string"},"ownerId":{"type":"string"},"deviceId":{"type":"string"},"originalPath":{"type":"string"},"resizePath":{"type":"string","nullable":true},"createdAt":{"type":"string"},"modifiedAt":{"type":"string"},"isFavorite":{"type":"boolean"},"mimeType":{"type":"string","nullable":true},"duration":{"type":"string"},"webpPath":{"type":"string","nullable":true},"encodedVideoPath":{"type":"string","nullable":true},"exifInfo":{"$ref":"#/components/schemas/ExifResponseDto"},"smartInfo":{"$ref":"#/components/schemas/SmartInfoResponseDto"}},"required":["type","id","deviceAssetId","ownerId","deviceId","originalPath","resizePath","createdAt","modifiedAt","isFavorite","mimeType","duration","webpPath","encodedVideoPath"]},"TimeGroupEnum":{"type":"string","enum":["day","month"]},"GetAssetCountByTimeBucketDto":{"type":"object","properties":{"timeGroup":{"$ref":"#/components/schemas/TimeGroupEnum"}},"required":["timeGroup"]},"AssetCountByTimeBucket":{"type":"object","properties":{"timeBucket":{"type":"string"},"count":{"type":"integer"}},"required":["timeBucket","count"]},"AssetCountByTimeBucketResponseDto":{"type":"object","properties":{"totalCount":{"type":"integer"},"buckets":{"type":"array","items":{"$ref":"#/components/schemas/AssetCountByTimeBucket"}}},"required":["totalCount","buckets"]},"AssetCountByUserIdResponseDto":{"type":"object","properties":{"photos":{"type":"integer"},"videos":{"type":"integer"}},"required":["photos","videos"]},"GetAssetByTimeBucketDto":{"type":"object","properties":{"timeBucket":{"title":"Array of date time buckets","example":["2015-06-01T00:00:00.000Z","2016-02-01T00:00:00.000Z","2016-03-01T00:00:00.000Z"],"type":"array","items":{"type":"string"}}},"required":["timeBucket"]},"DeleteAssetDto":{"type":"object","properties":{"ids":{"title":"Array of asset IDs to delete","example":["bf973405-3f2a-48d2-a687-2ed4167164be","dd41870b-5d00-46d2-924e-1d8489a0aa0f","fad77c3f-deef-4e7e-9608-14c1aa4e559a"],"type":"array","items":{"type":"string"}}},"required":["ids"]},"DeleteAssetStatus":{"type":"string","enum":["SUCCESS","FAILED"]},"DeleteAssetResponseDto":{"type":"object","properties":{"status":{"$ref":"#/components/schemas/DeleteAssetStatus"},"id":{"type":"string"}},"required":["status","id"]},"CheckDuplicateAssetDto":{"type":"object","properties":{"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["deviceAssetId","deviceId"]},"CheckDuplicateAssetResponseDto":{"type":"object","properties":{"isExist":{"type":"boolean"},"id":{"type":"string"}},"required":["isExist"]},"LoginCredentialDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"}},"required":["email","password"]},"LoginResponseDto":{"type":"object","properties":{"accessToken":{"type":"string","readOnly":true},"userId":{"type":"string","readOnly":true},"userEmail":{"type":"string","readOnly":true},"firstName":{"type":"string","readOnly":true},"lastName":{"type":"string","readOnly":true},"profileImagePath":{"type":"string","readOnly":true},"isAdmin":{"type":"boolean","readOnly":true},"shouldChangePassword":{"type":"boolean","readOnly":true}},"required":["accessToken","userId","userEmail","firstName","lastName","profileImagePath","isAdmin","shouldChangePassword"]},"SignUpDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"},"firstName":{"type":"string","example":"Admin"},"lastName":{"type":"string","example":"Doe"}},"required":["email","password","firstName","lastName"]},"AdminSignupResponseDto":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"createdAt":{"type":"string"}},"required":["id","email","firstName","lastName","createdAt"]},"ValidateAccessTokenResponseDto":{"type":"object","properties":{"authStatus":{"type":"boolean"}},"required":["authStatus"]},"LogoutResponseDto":{"type":"object","properties":{"successful":{"type":"boolean","readOnly":true}},"required":["successful"]},"DeviceTypeEnum":{"type":"string","enum":["IOS","ANDROID","WEB"]},"CreateDeviceInfoDto":{"type":"object","properties":{"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"deviceId":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["deviceType","deviceId"]},"DeviceInfoResponseDto":{"type":"object","properties":{"id":{"type":"integer"},"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"userId":{"type":"string"},"deviceId":{"type":"string"},"createdAt":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["id","deviceType","userId","deviceId","createdAt","isAutoBackup"]},"UpdateDeviceInfoDto":{"type":"object","properties":{"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"deviceId":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["deviceType","deviceId"]},"ServerInfoResponseDto":{"type":"object","properties":{"diskSizeRaw":{"type":"integer"},"diskUseRaw":{"type":"integer"},"diskAvailableRaw":{"type":"integer"},"diskUsagePercentage":{"type":"number","format":"float"},"diskSize":{"type":"string"},"diskUse":{"type":"string"},"diskAvailable":{"type":"string"}},"required":["diskSizeRaw","diskUseRaw","diskAvailableRaw","diskUsagePercentage","diskSize","diskUse","diskAvailable"]},"ServerPingResponse":{"type":"object","properties":{"res":{"type":"string","readOnly":true,"example":"pong"}},"required":["res"]},"ServerVersionReponseDto":{"type":"object","properties":{"major":{"type":"integer"},"minor":{"type":"integer"},"patch":{"type":"integer"},"build":{"type":"integer"}},"required":["major","minor","patch","build"]},"AlbumCountResponseDto":{"type":"object","properties":{"owned":{"type":"integer"},"shared":{"type":"integer"},"sharing":{"type":"integer"}},"required":["owned","shared","sharing"]},"CreateAlbumDto":{"type":"object","properties":{"albumName":{"type":"string"},"sharedWithUserIds":{"type":"array","items":{"type":"string"}},"assetIds":{"type":"array","items":{"type":"string"}}},"required":["albumName"]},"AlbumResponseDto":{"type":"object","properties":{"assetCount":{"type":"integer"},"id":{"type":"string"},"ownerId":{"type":"string"},"albumName":{"type":"string"},"createdAt":{"type":"string"},"albumThumbnailAssetId":{"type":"string","nullable":true},"shared":{"type":"boolean"},"sharedUsers":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}},"assets":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}},"required":["assetCount","id","ownerId","albumName","createdAt","albumThumbnailAssetId","shared","sharedUsers","assets"]},"AddUsersDto":{"type":"object","properties":{"sharedUserIds":{"type":"array","items":{"type":"string"}}},"required":["sharedUserIds"]},"AddAssetsDto":{"type":"object","properties":{"assetIds":{"type":"array","items":{"type":"string"}}},"required":["assetIds"]},"RemoveAssetsDto":{"type":"object","properties":{"assetIds":{"type":"array","items":{"type":"string"}}},"required":["assetIds"]},"UpdateAlbumDto":{"type":"object","properties":{"albumName":{"type":"string"},"albumThumbnailAssetId":{"type":"string"}}}}}}
\ No newline at end of file
+{"openapi":"3.0.0","paths":{"/user":{"get":{"operationId":"getAllUsers","parameters":[{"name":"isAll","required":true,"in":"query","schema":{"type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}}}}}},"tags":["User"],"security":[{"bearer":[]}]},"post":{"operationId":"createUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]},"put":{"operationId":"updateUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/info/{userId}":{"get":{"operationId":"getUserById","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"]}},"/user/me":{"get":{"operationId":"getMyUserInfo","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/count":{"get":{"operationId":"getUserCount","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCountResponseDto"}}}}},"tags":["User"]}},"/user/profile-image":{"post":{"operationId":"createProfileImage","parameters":[],"requestBody":{"required":true,"description":"A new avatar for the user","content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/CreateProfileImageDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProfileImageResponseDto"}}}}},"tags":["User"],"security":[{"bearer":[]}]}},"/user/profile-image/{userId}":{"get":{"operationId":"getProfileImage","parameters":[{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["User"]}},"/asset/upload":{"post":{"operationId":"uploadFile","parameters":[],"requestBody":{"required":true,"description":"Asset Upload Information","content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/AssetFileUploadDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetFileUploadResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/download":{"get":{"operationId":"downloadFile","parameters":[{"name":"aid","required":true,"in":"query","schema":{"title":"Device Asset ID","type":"string"}},{"name":"did","required":true,"in":"query","schema":{"title":"Device ID","type":"string"}},{"name":"isThumb","required":false,"in":"query","schema":{"title":"Is serve thumbnail (resize) file","type":"boolean"}},{"name":"isWeb","required":false,"in":"query","schema":{"title":"Is request made from web","type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/file":{"get":{"operationId":"serveFile","parameters":[{"name":"aid","required":true,"in":"query","schema":{"title":"Device Asset ID","type":"string"}},{"name":"did","required":true,"in":"query","schema":{"title":"Device ID","type":"string"}},{"name":"isThumb","required":false,"in":"query","schema":{"title":"Is serve thumbnail (resize) file","type":"boolean"}},{"name":"isWeb","required":false,"in":"query","schema":{"title":"Is request made from web","type":"boolean"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/thumbnail/{assetId}":{"get":{"operationId":"getAssetThumbnail","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}},{"name":"format","required":false,"in":"query","schema":{"$ref":"#/components/schemas/ThumbnailFormat"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/curated-objects":{"get":{"operationId":"getCuratedObjects","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CuratedObjectsResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/curated-locations":{"get":{"operationId":"getCuratedLocations","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CuratedLocationsResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/search-terms":{"get":{"operationId":"getAssetSearchTerms","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/search":{"post":{"operationId":"searchAsset","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchAssetDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/count-by-time-bucket":{"post":{"operationId":"getAssetCountByTimeBucket","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAssetCountByTimeBucketDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetCountByTimeBucketResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/count-by-user-id":{"get":{"operationId":"getAssetCountByUserId","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetCountByUserIdResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset":{"get":{"operationId":"getAllAssets","summary":"","description":"Get all AssetEntity belong to the user","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]},"delete":{"operationId":"deleteAsset","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DeleteAssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/time-bucket":{"post":{"operationId":"getAssetByTimeBucket","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAssetByTimeBucketDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/{deviceId}":{"get":{"operationId":"getUserAssetsByDeviceId","summary":"","description":"Get all asset of a device that are in the database, ID only.","parameters":[{"name":"deviceId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/assetById/{assetId}":{"get":{"operationId":"getAssetById","summary":"","description":"Get a single asset's information","parameters":[{"name":"assetId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/asset/check":{"post":{"operationId":"checkDuplicateAsset","summary":"","description":"Check duplicated asset before uploading - for Web upload used","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckDuplicateAssetDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckDuplicateAssetResponseDto"}}}}},"tags":["Asset"],"security":[{"bearer":[]}]}},"/auth/login":{"post":{"operationId":"login","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginCredentialDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponseDto"}}}}},"tags":["Authentication"]}},"/auth/admin-sign-up":{"post":{"operationId":"adminSignUp","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignUpDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSignupResponseDto"}}}},"400":{"description":"The server already has an admin"}},"tags":["Authentication"]}},"/auth/validateToken":{"post":{"operationId":"validateAccessToken","parameters":[],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateAccessTokenResponseDto"}}}}},"tags":["Authentication"],"security":[{"bearer":[]}]}},"/auth/logout":{"post":{"operationId":"logout","parameters":[],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogoutResponseDto"}}}}},"tags":["Authentication"]}},"/device-info":{"post":{"operationId":"createDeviceInfo","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDeviceInfoDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceInfoResponseDto"}}}}},"tags":["Device Info"],"security":[{"bearer":[]}]},"patch":{"operationId":"updateDeviceInfo","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDeviceInfoDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceInfoResponseDto"}}}}},"tags":["Device Info"],"security":[{"bearer":[]}]}},"/server-info":{"get":{"operationId":"getServerInfo","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerInfoResponseDto"}}}}},"tags":["Server Info"]}},"/server-info/ping":{"get":{"operationId":"pingServer","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerPingResponse"}}}}},"tags":["Server Info"]}},"/server-info/version":{"get":{"operationId":"getServerVersion","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerVersionReponseDto"}}}}},"tags":["Server Info"]}},"/album/count-by-user-id":{"get":{"operationId":"getAlbumCountByUserId","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumCountResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album":{"post":{"operationId":"createAlbum","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAlbumDto"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"get":{"operationId":"getAllAlbums","parameters":[{"name":"shared","required":false,"in":"query","schema":{"type":"boolean"}},{"name":"assetId","required":false,"in":"query","description":"Only returns albums that contain the asset\nIgnores the shared parameter\nundefined: get all albums","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/users":{"put":{"operationId":"addUsersToAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddUsersDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/assets":{"put":{"operationId":"addAssetsToAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"delete":{"operationId":"removeAssetFromAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveAssetsDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}":{"get":{"operationId":"getAlbumInfo","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]},"delete":{"operationId":"deleteAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Album"],"security":[{"bearer":[]}]},"patch":{"operationId":"updateAlbumInfo","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAlbumDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlbumResponseDto"}}}}},"tags":["Album"],"security":[{"bearer":[]}]}},"/album/{albumId}/user/{userId}":{"delete":{"operationId":"removeUserFromAlbum","parameters":[{"name":"albumId","required":true,"in":"path","schema":{"type":"string"}},{"name":"userId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Album"],"security":[{"bearer":[]}]}},"/jobs":{"get":{"operationId":"getAllJobsStatus","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllJobStatusResponseDto"}}}}},"tags":["Job"],"security":[{"bearer":[]}]}},"/jobs/{jobId}":{"get":{"operationId":"getJobStatus","parameters":[{"name":"jobId","required":true,"in":"path","schema":{"$ref":"#/components/schemas/JobId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobStatusResponseDto"}}}}},"tags":["Job"],"security":[{"bearer":[]}]},"put":{"operationId":"sendJobCommand","parameters":[{"name":"jobId","required":true,"in":"path","schema":{"$ref":"#/components/schemas/JobId"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobCommandDto"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"number"}}}}},"tags":["Job"],"security":[{"bearer":[]}]}}},"info":{"title":"Immich","description":"Immich API","version":"1.17.0","contact":{}},"tags":[],"servers":[{"url":"/api"}],"components":{"securitySchemes":{"bearer":{"scheme":"Bearer","bearerFormat":"JWT","type":"http","name":"JWT","description":"Enter JWT token","in":"header"}},"schemas":{"UserResponseDto":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"createdAt":{"type":"string"},"profileImagePath":{"type":"string"},"shouldChangePassword":{"type":"boolean"},"isAdmin":{"type":"boolean"}},"required":["id","email","firstName","lastName","createdAt","profileImagePath","shouldChangePassword","isAdmin"]},"CreateUserDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"},"firstName":{"type":"string","example":"John"},"lastName":{"type":"string","example":"Doe"}},"required":["email","password","firstName","lastName"]},"UserCountResponseDto":{"type":"object","properties":{"userCount":{"type":"integer"}},"required":["userCount"]},"UpdateUserDto":{"type":"object","properties":{"id":{"type":"string"},"password":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"isAdmin":{"type":"boolean"},"shouldChangePassword":{"type":"boolean"},"profileImagePath":{"type":"string"}},"required":["id"]},"CreateProfileImageDto":{"type":"object","properties":{"file":{"type":"string","format":"binary"}},"required":["file"]},"CreateProfileImageResponseDto":{"type":"object","properties":{"userId":{"type":"string"},"profileImagePath":{"type":"string"}},"required":["userId","profileImagePath"]},"AssetFileUploadDto":{"type":"object","properties":{"assetData":{"type":"string","format":"binary"}},"required":["assetData"]},"AssetFileUploadResponseDto":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},"ThumbnailFormat":{"type":"string","enum":["JPEG","WEBP"]},"CuratedObjectsResponseDto":{"type":"object","properties":{"id":{"type":"string"},"object":{"type":"string"},"resizePath":{"type":"string"},"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["id","object","resizePath","deviceAssetId","deviceId"]},"CuratedLocationsResponseDto":{"type":"object","properties":{"id":{"type":"string"},"city":{"type":"string"},"resizePath":{"type":"string"},"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["id","city","resizePath","deviceAssetId","deviceId"]},"SearchAssetDto":{"type":"object","properties":{"searchTerm":{"type":"string"}},"required":["searchTerm"]},"AssetTypeEnum":{"type":"string","enum":["IMAGE","VIDEO","AUDIO","OTHER"]},"ExifResponseDto":{"type":"object","properties":{"id":{"type":"integer","nullable":true,"default":null,"format":"int64"},"fileSizeInByte":{"type":"integer","nullable":true,"default":null,"format":"int64"},"make":{"type":"string","nullable":true,"default":null},"model":{"type":"string","nullable":true,"default":null},"imageName":{"type":"string","nullable":true,"default":null},"exifImageWidth":{"type":"number","nullable":true,"default":null},"exifImageHeight":{"type":"number","nullable":true,"default":null},"orientation":{"type":"string","nullable":true,"default":null},"dateTimeOriginal":{"format":"date-time","type":"string","nullable":true,"default":null},"modifyDate":{"format":"date-time","type":"string","nullable":true,"default":null},"lensModel":{"type":"string","nullable":true,"default":null},"fNumber":{"type":"number","nullable":true,"default":null},"focalLength":{"type":"number","nullable":true,"default":null},"iso":{"type":"number","nullable":true,"default":null},"exposureTime":{"type":"number","nullable":true,"default":null},"latitude":{"type":"number","nullable":true,"default":null},"longitude":{"type":"number","nullable":true,"default":null},"city":{"type":"string","nullable":true,"default":null},"state":{"type":"string","nullable":true,"default":null},"country":{"type":"string","nullable":true,"default":null}}},"SmartInfoResponseDto":{"type":"object","properties":{"id":{"type":"string"},"tags":{"nullable":true,"type":"array","items":{"type":"string"}},"objects":{"nullable":true,"type":"array","items":{"type":"string"}}}},"AssetResponseDto":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/AssetTypeEnum"},"id":{"type":"string"},"deviceAssetId":{"type":"string"},"ownerId":{"type":"string"},"deviceId":{"type":"string"},"originalPath":{"type":"string"},"resizePath":{"type":"string","nullable":true},"createdAt":{"type":"string"},"modifiedAt":{"type":"string"},"isFavorite":{"type":"boolean"},"mimeType":{"type":"string","nullable":true},"duration":{"type":"string"},"webpPath":{"type":"string","nullable":true},"encodedVideoPath":{"type":"string","nullable":true},"exifInfo":{"$ref":"#/components/schemas/ExifResponseDto"},"smartInfo":{"$ref":"#/components/schemas/SmartInfoResponseDto"}},"required":["type","id","deviceAssetId","ownerId","deviceId","originalPath","resizePath","createdAt","modifiedAt","isFavorite","mimeType","duration","webpPath","encodedVideoPath"]},"TimeGroupEnum":{"type":"string","enum":["day","month"]},"GetAssetCountByTimeBucketDto":{"type":"object","properties":{"timeGroup":{"$ref":"#/components/schemas/TimeGroupEnum"}},"required":["timeGroup"]},"AssetCountByTimeBucket":{"type":"object","properties":{"timeBucket":{"type":"string"},"count":{"type":"integer"}},"required":["timeBucket","count"]},"AssetCountByTimeBucketResponseDto":{"type":"object","properties":{"totalCount":{"type":"integer"},"buckets":{"type":"array","items":{"$ref":"#/components/schemas/AssetCountByTimeBucket"}}},"required":["totalCount","buckets"]},"AssetCountByUserIdResponseDto":{"type":"object","properties":{"photos":{"type":"integer"},"videos":{"type":"integer"}},"required":["photos","videos"]},"GetAssetByTimeBucketDto":{"type":"object","properties":{"timeBucket":{"title":"Array of date time buckets","example":["2015-06-01T00:00:00.000Z","2016-02-01T00:00:00.000Z","2016-03-01T00:00:00.000Z"],"type":"array","items":{"type":"string"}}},"required":["timeBucket"]},"DeleteAssetDto":{"type":"object","properties":{"ids":{"title":"Array of asset IDs to delete","example":["bf973405-3f2a-48d2-a687-2ed4167164be","dd41870b-5d00-46d2-924e-1d8489a0aa0f","fad77c3f-deef-4e7e-9608-14c1aa4e559a"],"type":"array","items":{"type":"string"}}},"required":["ids"]},"DeleteAssetStatus":{"type":"string","enum":["SUCCESS","FAILED"]},"DeleteAssetResponseDto":{"type":"object","properties":{"status":{"$ref":"#/components/schemas/DeleteAssetStatus"},"id":{"type":"string"}},"required":["status","id"]},"CheckDuplicateAssetDto":{"type":"object","properties":{"deviceAssetId":{"type":"string"},"deviceId":{"type":"string"}},"required":["deviceAssetId","deviceId"]},"CheckDuplicateAssetResponseDto":{"type":"object","properties":{"isExist":{"type":"boolean"},"id":{"type":"string"}},"required":["isExist"]},"LoginCredentialDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"}},"required":["email","password"]},"LoginResponseDto":{"type":"object","properties":{"accessToken":{"type":"string","readOnly":true},"userId":{"type":"string","readOnly":true},"userEmail":{"type":"string","readOnly":true},"firstName":{"type":"string","readOnly":true},"lastName":{"type":"string","readOnly":true},"profileImagePath":{"type":"string","readOnly":true},"isAdmin":{"type":"boolean","readOnly":true},"shouldChangePassword":{"type":"boolean","readOnly":true}},"required":["accessToken","userId","userEmail","firstName","lastName","profileImagePath","isAdmin","shouldChangePassword"]},"SignUpDto":{"type":"object","properties":{"email":{"type":"string","example":"testuser@email.com"},"password":{"type":"string","example":"password"},"firstName":{"type":"string","example":"Admin"},"lastName":{"type":"string","example":"Doe"}},"required":["email","password","firstName","lastName"]},"AdminSignupResponseDto":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"createdAt":{"type":"string"}},"required":["id","email","firstName","lastName","createdAt"]},"ValidateAccessTokenResponseDto":{"type":"object","properties":{"authStatus":{"type":"boolean"}},"required":["authStatus"]},"LogoutResponseDto":{"type":"object","properties":{"successful":{"type":"boolean","readOnly":true}},"required":["successful"]},"DeviceTypeEnum":{"type":"string","enum":["IOS","ANDROID","WEB"]},"CreateDeviceInfoDto":{"type":"object","properties":{"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"deviceId":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["deviceType","deviceId"]},"DeviceInfoResponseDto":{"type":"object","properties":{"id":{"type":"integer"},"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"userId":{"type":"string"},"deviceId":{"type":"string"},"createdAt":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["id","deviceType","userId","deviceId","createdAt","isAutoBackup"]},"UpdateDeviceInfoDto":{"type":"object","properties":{"deviceType":{"$ref":"#/components/schemas/DeviceTypeEnum"},"deviceId":{"type":"string"},"isAutoBackup":{"type":"boolean"}},"required":["deviceType","deviceId"]},"ServerInfoResponseDto":{"type":"object","properties":{"diskSizeRaw":{"type":"integer","format":"int64"},"diskUseRaw":{"type":"integer","format":"int64"},"diskAvailableRaw":{"type":"integer","format":"int64"},"diskUsagePercentage":{"type":"number","format":"float"},"diskSize":{"type":"string"},"diskUse":{"type":"string"},"diskAvailable":{"type":"string"}},"required":["diskSizeRaw","diskUseRaw","diskAvailableRaw","diskUsagePercentage","diskSize","diskUse","diskAvailable"]},"ServerPingResponse":{"type":"object","properties":{"res":{"type":"string","readOnly":true,"example":"pong"}},"required":["res"]},"ServerVersionReponseDto":{"type":"object","properties":{"major":{"type":"integer"},"minor":{"type":"integer"},"patch":{"type":"integer"},"build":{"type":"integer"}},"required":["major","minor","patch","build"]},"AlbumCountResponseDto":{"type":"object","properties":{"owned":{"type":"integer"},"shared":{"type":"integer"},"sharing":{"type":"integer"}},"required":["owned","shared","sharing"]},"CreateAlbumDto":{"type":"object","properties":{"albumName":{"type":"string"},"sharedWithUserIds":{"type":"array","items":{"type":"string"}},"assetIds":{"type":"array","items":{"type":"string"}}},"required":["albumName"]},"AlbumResponseDto":{"type":"object","properties":{"assetCount":{"type":"integer"},"id":{"type":"string"},"ownerId":{"type":"string"},"albumName":{"type":"string"},"createdAt":{"type":"string"},"albumThumbnailAssetId":{"type":"string","nullable":true},"shared":{"type":"boolean"},"sharedUsers":{"type":"array","items":{"$ref":"#/components/schemas/UserResponseDto"}},"assets":{"type":"array","items":{"$ref":"#/components/schemas/AssetResponseDto"}}},"required":["assetCount","id","ownerId","albumName","createdAt","albumThumbnailAssetId","shared","sharedUsers","assets"]},"AddUsersDto":{"type":"object","properties":{"sharedUserIds":{"type":"array","items":{"type":"string"}}},"required":["sharedUserIds"]},"AddAssetsDto":{"type":"object","properties":{"assetIds":{"type":"array","items":{"type":"string"}}},"required":["assetIds"]},"RemoveAssetsDto":{"type":"object","properties":{"assetIds":{"type":"array","items":{"type":"string"}}},"required":["assetIds"]},"UpdateAlbumDto":{"type":"object","properties":{"albumName":{"type":"string"},"albumThumbnailAssetId":{"type":"string"}}},"JobCounts":{"type":"object","properties":{"active":{"type":"number"},"completed":{"type":"number"},"failed":{"type":"number"},"delayed":{"type":"number"},"waiting":{"type":"number"}},"required":["active","completed","failed","delayed","waiting"]},"AllJobStatusResponseDto":{"type":"object","properties":{"thumbnailGenerationQueueCount":{"$ref":"#/components/schemas/JobCounts"},"metadataExtractionQueueCount":{"$ref":"#/components/schemas/JobCounts"},"videoConversionQueueCount":{"$ref":"#/components/schemas/JobCounts"},"machineLearningQueueCount":{"$ref":"#/components/schemas/JobCounts"},"isThumbnailGenerationActive":{"type":"boolean"},"isMetadataExtractionActive":{"type":"boolean"},"isVideoConversionActive":{"type":"boolean"},"isMachineLearningActive":{"type":"boolean"}},"required":["thumbnailGenerationQueueCount","metadataExtractionQueueCount","videoConversionQueueCount","machineLearningQueueCount","isThumbnailGenerationActive","isMetadataExtractionActive","isVideoConversionActive","isMachineLearningActive"]},"JobId":{"type":"string","enum":["thumbnail-generation","metadata-extraction","video-conversion","machine-learning"]},"JobStatusResponseDto":{"type":"object","properties":{"isActive":{"type":"boolean"},"queueCount":{"type":"object"}},"required":["isActive","queueCount"]},"JobCommand":{"type":"string","enum":["start","stop"]},"JobCommandDto":{"type":"object","properties":{"command":{"$ref":"#/components/schemas/JobCommand"}},"required":["command"]}}}}
\ No newline at end of file
diff --git a/server/libs/job/src/constants/job-name.constant.ts b/server/libs/job/src/constants/job-name.constant.ts
index 002dd7f5a7..7b32c69028 100644
--- a/server/libs/job/src/constants/job-name.constant.ts
+++ b/server/libs/job/src/constants/job-name.constant.ts
@@ -20,5 +20,12 @@ export const generateWEBPThumbnailProcessorName = 'generate-webp-thumbnail';
 export const exifExtractionProcessorName = 'exif-extraction';
 export const videoMetadataExtractionProcessorName = 'extract-video-metadata';
 export const reverseGeocodingProcessorName = 'reverse-geocoding';
-export const objectDetectionProcessorName = 'detect-object';
-export const imageTaggingProcessorName = 'tag-image';
+
+/**
+ * Machine learning Queue Jobs
+ */
+
+export enum MachineLearningJobNameEnum {
+  OBJECT_DETECTION = 'detect-object',
+  IMAGE_TAGGING = 'tag-image',
+}
diff --git a/server/libs/job/src/constants/queue-name.constant.ts b/server/libs/job/src/constants/queue-name.constant.ts
index 15b7d7a6cc..f0f4c4e053 100644
--- a/server/libs/job/src/constants/queue-name.constant.ts
+++ b/server/libs/job/src/constants/queue-name.constant.ts
@@ -1,5 +1,8 @@
-export const thumbnailGeneratorQueueName = 'thumbnail-generator-queue';
-export const assetUploadedQueueName = 'asset-uploaded-queue';
-export const metadataExtractionQueueName = 'metadata-extraction-queue';
-export const videoConversionQueueName = 'video-conversion-queue';
-export const generateChecksumQueueName = 'generate-checksum-queue';
+export enum QueueNameEnum {
+  THUMBNAIL_GENERATION = 'thumbnail-generation-queue',
+  METADATA_EXTRACTION = 'metadata-extraction-queue',
+  VIDEO_CONVERSION = 'video-conversion-queue',
+  CHECKSUM_GENERATION = 'generate-checksum-queue',
+  ASSET_UPLOADED = 'asset-uploaded-queue',
+  MACHINE_LEARNING = 'machine-learning-queue',
+}
diff --git a/server/libs/job/src/interfaces/machine-learning.interface.ts b/server/libs/job/src/interfaces/machine-learning.interface.ts
new file mode 100644
index 0000000000..13bf5e19d5
--- /dev/null
+++ b/server/libs/job/src/interfaces/machine-learning.interface.ts
@@ -0,0 +1,8 @@
+import { AssetEntity } from '@app/database/entities/asset.entity';
+
+export interface IMachineLearningJob {
+  /**
+   * The Asset entity that was saved in the database
+   */
+  asset: AssetEntity;
+}
diff --git a/server/package-lock.json b/server/package-lock.json
index 39730ad635..62ebb7fd08 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -59,7 +59,7 @@
         "@nestjs/testing": "^8.4.7",
         "@openapitools/openapi-generator-cli": "2.5.1",
         "@types/bcrypt": "^5.0.0",
-        "@types/bull": "^3.15.7",
+        "@types/bull": "^3.15.9",
         "@types/cookie-parser": "^1.4.3",
         "@types/cron": "^2.0.0",
         "@types/express": "^4.17.13",
@@ -2339,9 +2339,9 @@
       }
     },
     "node_modules/@types/bull": {
-      "version": "3.15.7",
-      "resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.7.tgz",
-      "integrity": "sha512-7NC7XN5NoS0A+leJ/dR69ZfKaegOlCZaii/xGgKnCyh1UYisRncibImb7VMwrc3OdJcbDJt6+4om70TeNl3J7g==",
+      "version": "3.15.9",
+      "resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.9.tgz",
+      "integrity": "sha512-MPUcyPPQauAmynoO3ezHAmCOhbB0pWmYyijr/5ctaCqhbKWsjW0YCod38ZcLzUBprosfZ9dPqfYIcfdKjk7RNQ==",
       "dev": true,
       "dependencies": {
         "@types/ioredis": "*",
@@ -3764,6 +3764,27 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/cache-manager": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.0.0.tgz",
+      "integrity": "sha512-1qKdoeoJKmrf95Zvhr3NpBVAgBESt4TuZomBzn4N2gCFZvHjuUXBK1H8EDVsJdba6/grIgi6WGYb/ncJj+wjtg==",
+      "optional": true,
+      "peer": true,
+      "dependencies": {
+        "lodash.clonedeep": "^4.5.0",
+        "lru-cache": "^7.14.0"
+      }
+    },
+    "node_modules/cache-manager/node_modules/lru-cache": {
+      "version": "7.14.0",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.14.0.tgz",
+      "integrity": "sha512-EIRtP1GrSJny0dqb50QXRUNBxHJhcpxHC++M5tD7RYbvLLn5KVWKsbyswSSqDuU15UFi3bgTQIY8nhDMeF6aDQ==",
+      "optional": true,
+      "peer": true,
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/call-bind": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@@ -7674,6 +7695,13 @@
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
       "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
     },
+    "node_modules/lodash.clonedeep": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+      "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+      "optional": true,
+      "peer": true
+    },
     "node_modules/lodash.defaults": {
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -12900,9 +12928,9 @@
       }
     },
     "@types/bull": {
-      "version": "3.15.7",
-      "resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.7.tgz",
-      "integrity": "sha512-7NC7XN5NoS0A+leJ/dR69ZfKaegOlCZaii/xGgKnCyh1UYisRncibImb7VMwrc3OdJcbDJt6+4om70TeNl3J7g==",
+      "version": "3.15.9",
+      "resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.9.tgz",
+      "integrity": "sha512-MPUcyPPQauAmynoO3ezHAmCOhbB0pWmYyijr/5ctaCqhbKWsjW0YCod38ZcLzUBprosfZ9dPqfYIcfdKjk7RNQ==",
       "dev": true,
       "requires": {
         "@types/ioredis": "*",
@@ -14073,6 +14101,26 @@
       "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
       "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="
     },
+    "cache-manager": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.0.0.tgz",
+      "integrity": "sha512-1qKdoeoJKmrf95Zvhr3NpBVAgBESt4TuZomBzn4N2gCFZvHjuUXBK1H8EDVsJdba6/grIgi6WGYb/ncJj+wjtg==",
+      "optional": true,
+      "peer": true,
+      "requires": {
+        "lodash.clonedeep": "^4.5.0",
+        "lru-cache": "^7.14.0"
+      },
+      "dependencies": {
+        "lru-cache": {
+          "version": "7.14.0",
+          "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.14.0.tgz",
+          "integrity": "sha512-EIRtP1GrSJny0dqb50QXRUNBxHJhcpxHC++M5tD7RYbvLLn5KVWKsbyswSSqDuU15UFi3bgTQIY8nhDMeF6aDQ==",
+          "optional": true,
+          "peer": true
+        }
+      }
+    },
     "call-bind": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@@ -17088,6 +17136,13 @@
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
       "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
     },
+    "lodash.clonedeep": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+      "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+      "optional": true,
+      "peer": true
+    },
     "lodash.defaults": {
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
diff --git a/server/package.json b/server/package.json
index 368765adc1..62c113b7db 100644
--- a/server/package.json
+++ b/server/package.json
@@ -78,7 +78,7 @@
     "@nestjs/testing": "^8.4.7",
     "@openapitools/openapi-generator-cli": "2.5.1",
     "@types/bcrypt": "^5.0.0",
-    "@types/bull": "^3.15.7",
+    "@types/bull": "^3.15.9",
     "@types/cookie-parser": "^1.4.3",
     "@types/cron": "^2.0.0",
     "@types/express": "^4.17.13",
diff --git a/web/src/api/api.ts b/web/src/api/api.ts
index 5e2b8f356d..b621c649f2 100644
--- a/web/src/api/api.ts
+++ b/web/src/api/api.ts
@@ -4,6 +4,7 @@ import {
 	AuthenticationApi,
 	Configuration,
 	DeviceInfoApi,
+	JobApi,
 	ServerInfoApi,
 	UserApi
 } from './open-api';
@@ -15,6 +16,8 @@ class ImmichApi {
 	public authenticationApi: AuthenticationApi;
 	public deviceInfoApi: DeviceInfoApi;
 	public serverInfoApi: ServerInfoApi;
+	public jobApi: JobApi;
+
 	private config = new Configuration({ basePath: '/api' });
 
 	constructor() {
@@ -24,6 +27,7 @@ class ImmichApi {
 		this.authenticationApi = new AuthenticationApi(this.config);
 		this.deviceInfoApi = new DeviceInfoApi(this.config);
 		this.serverInfoApi = new ServerInfoApi(this.config);
+		this.jobApi = new JobApi(this.config);
 	}
 
 	public setAccessToken(accessToken: string) {
diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts
index 27ff36ab88..6654d9a328 100644
--- a/web/src/api/open-api/api.ts
+++ b/web/src/api/open-api/api.ts
@@ -170,6 +170,61 @@ export interface AlbumResponseDto {
      */
     'assets': Array<AssetResponseDto>;
 }
+/**
+ * 
+ * @export
+ * @interface AllJobStatusResponseDto
+ */
+export interface AllJobStatusResponseDto {
+    /**
+     * 
+     * @type {JobCounts}
+     * @memberof AllJobStatusResponseDto
+     */
+    'thumbnailGenerationQueueCount': JobCounts;
+    /**
+     * 
+     * @type {JobCounts}
+     * @memberof AllJobStatusResponseDto
+     */
+    'metadataExtractionQueueCount': JobCounts;
+    /**
+     * 
+     * @type {JobCounts}
+     * @memberof AllJobStatusResponseDto
+     */
+    'videoConversionQueueCount': JobCounts;
+    /**
+     * 
+     * @type {JobCounts}
+     * @memberof AllJobStatusResponseDto
+     */
+    'machineLearningQueueCount': JobCounts;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AllJobStatusResponseDto
+     */
+    'isThumbnailGenerationActive': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AllJobStatusResponseDto
+     */
+    'isMetadataExtractionActive': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AllJobStatusResponseDto
+     */
+    'isVideoConversionActive': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AllJobStatusResponseDto
+     */
+    'isMachineLearningActive': boolean;
+}
 /**
  * 
  * @export
@@ -683,10 +738,16 @@ export type DeviceTypeEnum = typeof DeviceTypeEnum[keyof typeof DeviceTypeEnum];
 export interface ExifResponseDto {
     /**
      * 
-     * @type {string}
+     * @type {number}
      * @memberof ExifResponseDto
      */
-    'id'?: string | null;
+    'id'?: number | null;
+    /**
+     * 
+     * @type {number}
+     * @memberof ExifResponseDto
+     */
+    'fileSizeInByte'?: number | null;
     /**
      * 
      * @type {string}
@@ -717,12 +778,6 @@ export interface ExifResponseDto {
      * @memberof ExifResponseDto
      */
     'exifImageHeight'?: number | null;
-    /**
-     * 
-     * @type {number}
-     * @memberof ExifResponseDto
-     */
-    'fileSizeInByte'?: number | null;
     /**
      * 
      * @type {string}
@@ -828,6 +883,105 @@ export interface GetAssetCountByTimeBucketDto {
      */
     'timeGroup': TimeGroupEnum;
 }
+/**
+ * 
+ * @export
+ * @enum {string}
+ */
+
+export const JobCommand = {
+    Start: 'start',
+    Stop: 'stop'
+} as const;
+
+export type JobCommand = typeof JobCommand[keyof typeof JobCommand];
+
+
+/**
+ * 
+ * @export
+ * @interface JobCommandDto
+ */
+export interface JobCommandDto {
+    /**
+     * 
+     * @type {JobCommand}
+     * @memberof JobCommandDto
+     */
+    'command': JobCommand;
+}
+/**
+ * 
+ * @export
+ * @interface JobCounts
+ */
+export interface JobCounts {
+    /**
+     * 
+     * @type {number}
+     * @memberof JobCounts
+     */
+    'active': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof JobCounts
+     */
+    'completed': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof JobCounts
+     */
+    'failed': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof JobCounts
+     */
+    'delayed': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof JobCounts
+     */
+    'waiting': number;
+}
+/**
+ * 
+ * @export
+ * @enum {string}
+ */
+
+export const JobId = {
+    ThumbnailGeneration: 'thumbnail-generation',
+    MetadataExtraction: 'metadata-extraction',
+    VideoConversion: 'video-conversion',
+    MachineLearning: 'machine-learning'
+} as const;
+
+export type JobId = typeof JobId[keyof typeof JobId];
+
+
+/**
+ * 
+ * @export
+ * @interface JobStatusResponseDto
+ */
+export interface JobStatusResponseDto {
+    /**
+     * 
+     * @type {boolean}
+     * @memberof JobStatusResponseDto
+     */
+    'isActive': boolean;
+    /**
+     * 
+     * @type {object}
+     * @memberof JobStatusResponseDto
+     */
+    'queueCount': object;
+}
 /**
  * 
  * @export
@@ -3682,6 +3836,247 @@ export class DeviceInfoApi extends BaseAPI {
 }
 
 
+/**
+ * JobApi - axios parameter creator
+ * @export
+ */
+export const JobApiAxiosParamCreator = function (configuration?: Configuration) {
+    return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getAllJobsStatus: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/jobs`;
+            // 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: 'GET', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // 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 {JobId} jobId 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getJobStatus: async (jobId: JobId, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'jobId' is not null or undefined
+            assertParamExists('getJobStatus', 'jobId', jobId)
+            const localVarPath = `/jobs/{jobId}`
+                .replace(`{${"jobId"}}`, encodeURIComponent(String(jobId)));
+            // 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: 'GET', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // 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 {JobId} jobId 
+         * @param {JobCommandDto} jobCommandDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        sendJobCommand: async (jobId: JobId, jobCommandDto: JobCommandDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'jobId' is not null or undefined
+            assertParamExists('sendJobCommand', 'jobId', jobId)
+            // verify required parameter 'jobCommandDto' is not null or undefined
+            assertParamExists('sendJobCommand', 'jobCommandDto', jobCommandDto)
+            const localVarPath = `/jobs/{jobId}`
+                .replace(`{${"jobId"}}`, encodeURIComponent(String(jobId)));
+            // 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: 'PUT', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // 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(jobCommandDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+    }
+};
+
+/**
+ * JobApi - functional programming interface
+ * @export
+ */
+export const JobApiFp = function(configuration?: Configuration) {
+    const localVarAxiosParamCreator = JobApiAxiosParamCreator(configuration)
+    return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async getAllJobsStatus(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AllJobStatusResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getAllJobsStatus(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+        /**
+         * 
+         * @param {JobId} jobId 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async getJobStatus(jobId: JobId, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<JobStatusResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getJobStatus(jobId, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+        /**
+         * 
+         * @param {JobId} jobId 
+         * @param {JobCommandDto} jobCommandDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<number>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.sendJobCommand(jobId, jobCommandDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+    }
+};
+
+/**
+ * JobApi - factory interface
+ * @export
+ */
+export const JobApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
+    const localVarFp = JobApiFp(configuration)
+    return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getAllJobsStatus(options?: any): AxiosPromise<AllJobStatusResponseDto> {
+            return localVarFp.getAllJobsStatus(options).then((request) => request(axios, basePath));
+        },
+        /**
+         * 
+         * @param {JobId} jobId 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getJobStatus(jobId: JobId, options?: any): AxiosPromise<JobStatusResponseDto> {
+            return localVarFp.getJobStatus(jobId, options).then((request) => request(axios, basePath));
+        },
+        /**
+         * 
+         * @param {JobId} jobId 
+         * @param {JobCommandDto} jobCommandDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: any): AxiosPromise<number> {
+            return localVarFp.sendJobCommand(jobId, jobCommandDto, options).then((request) => request(axios, basePath));
+        },
+    };
+};
+
+/**
+ * JobApi - object-oriented interface
+ * @export
+ * @class JobApi
+ * @extends {BaseAPI}
+ */
+export class JobApi extends BaseAPI {
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof JobApi
+     */
+    public getAllJobsStatus(options?: AxiosRequestConfig) {
+        return JobApiFp(this.configuration).getAllJobsStatus(options).then((request) => request(this.axios, this.basePath));
+    }
+
+    /**
+     * 
+     * @param {JobId} jobId 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof JobApi
+     */
+    public getJobStatus(jobId: JobId, options?: AxiosRequestConfig) {
+        return JobApiFp(this.configuration).getJobStatus(jobId, options).then((request) => request(this.axios, this.basePath));
+    }
+
+    /**
+     * 
+     * @param {JobId} jobId 
+     * @param {JobCommandDto} jobCommandDto 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof JobApi
+     */
+    public sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig) {
+        return JobApiFp(this.configuration).sendJobCommand(jobId, jobCommandDto, options).then((request) => request(this.axios, this.basePath));
+    }
+}
+
+
 /**
  * ServerInfoApi - axios parameter creator
  * @export
diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte
new file mode 100644
index 0000000000..5d09434f86
--- /dev/null
+++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte
@@ -0,0 +1,52 @@
+<script lang="ts">
+	import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
+	import { createEventDispatcher } from 'svelte';
+
+	export let title: string;
+	export let subtitle: string;
+	export let buttonTitle = 'Run';
+	export let jobStatus: boolean;
+	export let waitingJobCount: number;
+	export let activeJobCount: number;
+	const dispatch = createEventDispatcher();
+</script>
+
+<div class="flex border p-6 rounded-2xl bg-white">
+	<div class="w-[70%]">
+		<h1 class="font-medium text-immich-primary">{title}</h1>
+		<p class="text-sm mt-1 font-medium">{subtitle}</p>
+		<p class="text-sm">
+			<slot />
+		</p>
+		<table class="text-left w-full mt-4">
+			<!-- table header -->
+			<thead class="border rounded-md mb-2 bg-gray-50 flex text-immich-primary w-full h-12">
+				<tr class="flex w-full place-items-center">
+					<th class="text-center w-1/3 font-medium text-sm">Status</th>
+					<th class="text-center w-1/3 font-medium text-sm">Active</th>
+					<th class="text-center w-1/3 font-medium text-sm">Waiting</th>
+				</tr>
+			</thead>
+			<tbody class="overflow-y-auto rounded-md w-full max-h-[320px] block border">
+				<tr class="text-center flex place-items-center w-full h-[40px]">
+					<td class="text-sm px-2 w-1/3 text-ellipsis">{jobStatus ? 'Active' : 'Idle'}</td>
+					<td class="text-sm px-2 w-1/3 text-ellipsis">{activeJobCount}</td>
+					<td class="text-sm px-2 w-1/3 text-ellipsis">{waitingJobCount}</td>
+				</tr>
+			</tbody>
+		</table>
+	</div>
+	<div class="w-[30%] flex place-items-center place-content-end">
+		<button
+			on:click={() => dispatch('click')}
+			class="border px-6 py-3 text-sm bg-gray-50 font-medium rounded-2xl hover:bg-immich-primary/10 transition-all hover:cursor-pointer disabled:cursor-not-allowed"
+			disabled={jobStatus}
+		>
+			{#if jobStatus}
+				<LoadingSpinner />
+			{:else}
+				{buttonTitle}
+			{/if}
+		</button>
+	</div>
+</div>
diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte
new file mode 100644
index 0000000000..54b3a4fc8d
--- /dev/null
+++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte
@@ -0,0 +1,138 @@
+<script lang="ts">
+	import {
+		notificationController,
+		NotificationType
+	} from '$lib/components/shared-components/notification/notification';
+	import { AllJobStatusResponseDto, api, JobCommand, JobId } from '@api';
+	import { onDestroy, onMount } from 'svelte';
+	import JobTile from './job-tile.svelte';
+
+	let allJobsStatus: AllJobStatusResponseDto;
+	let setIntervalHandler: NodeJS.Timer;
+	onMount(async () => {
+		const { data } = await api.jobApi.getAllJobsStatus();
+		allJobsStatus = data;
+
+		setIntervalHandler = setInterval(async () => {
+			const { data } = await api.jobApi.getAllJobsStatus();
+			allJobsStatus = data;
+		}, 1000);
+	});
+	1;
+
+	onDestroy(() => {
+		clearInterval(setIntervalHandler);
+	});
+
+	const runThumbnailGeneration = async () => {
+		try {
+			const { data } = await api.jobApi.sendJobCommand(JobId.ThumbnailGeneration, {
+				command: JobCommand.Start
+			});
+
+			if (data) {
+				notificationController.show({
+					message: `Thumbnail generation job started for ${data} asset`,
+					type: NotificationType.Info
+				});
+			} else {
+				notificationController.show({
+					message: `No missing thumbnails found`,
+					type: NotificationType.Info
+				});
+			}
+		} catch (e) {
+			console.log('[ERROR] runThumbnailGeneration', e);
+
+			notificationController.show({
+				message: `Error running thumbnail generation job, check console for more detail`,
+				type: NotificationType.Error
+			});
+		}
+	};
+
+	const runExtractEXIF = async () => {
+		try {
+			const { data } = await api.jobApi.sendJobCommand(JobId.MetadataExtraction, {
+				command: JobCommand.Start
+			});
+
+			if (data) {
+				notificationController.show({
+					message: `Extract EXIF job started for ${data} asset`,
+					type: NotificationType.Info
+				});
+			} else {
+				notificationController.show({
+					message: `No missing EXIF found`,
+					type: NotificationType.Info
+				});
+			}
+		} catch (e) {
+			console.log('[ERROR] runExtractEXIF', e);
+
+			notificationController.show({
+				message: `Error running extract EXIF job, check console for more detail`,
+				type: NotificationType.Error
+			});
+		}
+	};
+
+	const runMachineLearning = async () => {
+		try {
+			const { data } = await api.jobApi.sendJobCommand(JobId.MachineLearning, {
+				command: JobCommand.Start
+			});
+
+			if (data) {
+				notificationController.show({
+					message: `Object detection job started for ${data} asset`,
+					type: NotificationType.Info
+				});
+			} else {
+				notificationController.show({
+					message: `No missing object detection found`,
+					type: NotificationType.Info
+				});
+			}
+		} catch (e) {
+			console.log('[ERROR] runMachineLearning', e);
+
+			notificationController.show({
+				message: `Error running machine learning job, check console for more detail`,
+				type: NotificationType.Error
+			});
+		}
+	};
+</script>
+
+<div class="flex flex-col gap-6">
+	<JobTile
+		title={'Generate thumbnails'}
+		subtitle={'Regenerate missing thumbnail (JPEG, WEBP)'}
+		on:click={runThumbnailGeneration}
+		jobStatus={allJobsStatus?.isThumbnailGenerationActive}
+		waitingJobCount={allJobsStatus?.thumbnailGenerationQueueCount.waiting}
+		activeJobCount={allJobsStatus?.thumbnailGenerationQueueCount.active}
+	/>
+
+	<JobTile
+		title={'Extract EXIF'}
+		subtitle={'Extract missing EXIF information'}
+		on:click={runExtractEXIF}
+		jobStatus={allJobsStatus?.isMetadataExtractionActive}
+		waitingJobCount={allJobsStatus?.metadataExtractionQueueCount.waiting}
+		activeJobCount={allJobsStatus?.metadataExtractionQueueCount.active}
+	/>
+
+	<JobTile
+		title={'Detect objects'}
+		subtitle={'Run machine learning process to detect and classify objects'}
+		on:click={runMachineLearning}
+		jobStatus={allJobsStatus?.isMachineLearningActive}
+		waitingJobCount={allJobsStatus?.machineLearningQueueCount.waiting}
+		activeJobCount={allJobsStatus?.machineLearningQueueCount.active}
+	>
+		Note that some asset does not have any object detected, this is normal.
+	</JobTile>
+</div>
diff --git a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte
index b4daac2d5f..6ba5621b1e 100644
--- a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte
+++ b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte
@@ -94,7 +94,7 @@
 
 <div
 	id="immich-scrubbable-scrollbar"
-	class="fixed right-0 bg-immich-bg z-10 hover:cursor-row-resize select-none"
+	class="fixed right-0 bg-immich-bg z-[999] hover:cursor-row-resize select-none "
 	style:width={isDragging ? '100vw' : '60px'}
 	style:background-color={isDragging ? 'transparent' : 'transparent'}
 	on:mouseenter={() => (isHover = true)}
diff --git a/web/src/lib/models/admin-sidebar-selection.ts b/web/src/lib/models/admin-sidebar-selection.ts
index a11914c96b..6ffe6ef4e1 100644
--- a/web/src/lib/models/admin-sidebar-selection.ts
+++ b/web/src/lib/models/admin-sidebar-selection.ts
@@ -1,5 +1,7 @@
 export enum AdminSideBarSelection {
-	USER_MANAGEMENT = 'User management'
+	USER_MANAGEMENT = 'User management',
+	JOBS = 'Jobs',
+	SETTINGS = 'Settings'
 }
 
 export enum AppSideBarSelection {
diff --git a/web/src/routes/admin/+layout.svelte b/web/src/routes/admin/+layout.svelte
new file mode 100644
index 0000000000..7270e1de67
--- /dev/null
+++ b/web/src/routes/admin/+layout.svelte
@@ -0,0 +1,3 @@
+<main>
+	<slot />
+</main>
diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte
index 8d18519c2d..5bde9cadfa 100644
--- a/web/src/routes/admin/+page.svelte
+++ b/web/src/routes/admin/+page.svelte
@@ -4,6 +4,7 @@
 	import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection';
 	import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
 	import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
+	import Cog from 'svelte-material-icons/Cog.svelte';
 	import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
 	import UserManagement from '$lib/components/admin-page/user-management.svelte';
 	import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
@@ -12,6 +13,7 @@
 	import StatusBox from '$lib/components/shared-components/status-box.svelte';
 	import type { PageData } from './$types';
 	import { api, UserResponseDto } from '@api';
+	import JobsPanel from '$lib/components/admin-page/jobs/jobs-panel.svelte';
 
 	let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
 
@@ -104,14 +106,21 @@
 {/if}
 
 <section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen">
-	<section id="admin-sidebar" class="pt-8 pr-6 flex flex-col">
+	<section id="admin-sidebar" class="pt-8 pr-6 flex flex-col gap-1">
 		<SideBarButton
-			title="User"
+			title="Users"
 			logo={AccountMultipleOutline}
 			actionType={AdminSideBarSelection.USER_MANAGEMENT}
 			isSelected={selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
 			on:selected={onButtonClicked}
 		/>
+		<SideBarButton
+			title="Jobs"
+			logo={Cog}
+			actionType={AdminSideBarSelection.JOBS}
+			isSelected={selectedAction === AdminSideBarSelection.JOBS}
+			on:selected={onButtonClicked}
+		/>
 
 		<div class="mb-6 mt-auto">
 			<StatusBox />
@@ -132,6 +141,9 @@
 						on:edit-user={editUserHandler}
 					/>
 				{/if}
+				{#if selectedAction === AdminSideBarSelection.JOBS}
+					<JobsPanel />
+				{/if}
 			</section>
 		</section>
 	</section>