Explorar o código

refactor(server): jobs (#2023)

* refactor: job to domain

* chore: regenerate open api

* chore: tests

* fix: missing breaks

* fix: get asset with missing exif data

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Jason Rasmussen %!s(int64=2) %!d(string=hai) anos
pai
achega
386eef046d
Modificáronse 68 ficheiros con 1351 adicións e 903 borrados
  1. 6 6
      mobile/openapi/.openapi-generator/FILES
  2. 2 2
      mobile/openapi/README.md
  3. 8 5
      mobile/openapi/doc/AllJobStatusResponseDto.md
  4. 6 7
      mobile/openapi/doc/JobApi.md
  5. 1 1
      mobile/openapi/doc/JobCommandDto.md
  6. 1 1
      mobile/openapi/doc/JobCountsDto.md
  7. 1 1
      mobile/openapi/doc/JobName.md
  8. 2 2
      mobile/openapi/lib/api.dart
  9. 4 12
      mobile/openapi/lib/api/job_api.dart
  10. 4 4
      mobile/openapi/lib/api_client.dart
  11. 2 2
      mobile/openapi/lib/api_helper.dart
  12. 60 36
      mobile/openapi/lib/model/all_job_status_response_dto.dart
  13. 6 3
      mobile/openapi/lib/model/job_command.dart
  14. 8 8
      mobile/openapi/lib/model/job_command_dto.dart
  15. 20 20
      mobile/openapi/lib/model/job_counts_dto.dart
  16. 0 94
      mobile/openapi/lib/model/job_id.dart
  17. 103 0
      mobile/openapi/lib/model/job_name.dart
  18. 25 10
      mobile/openapi/test/all_job_status_response_dto_test.dart
  19. 1 1
      mobile/openapi/test/job_api_test.dart
  20. 2 2
      mobile/openapi/test/job_command_dto_test.dart
  21. 3 3
      mobile/openapi/test/job_counts_dto_test.dart
  22. 2 2
      mobile/openapi/test/job_name_test.dart
  23. 0 43
      server/apps/immich/src/api-v1/asset/asset-repository.ts
  24. 0 4
      server/apps/immich/src/api-v1/asset/asset.service.spec.ts
  25. 1 1
      server/apps/immich/src/api-v1/asset/asset.service.ts
  26. 0 23
      server/apps/immich/src/api-v1/job/dto/get-job.dto.ts
  27. 0 16
      server/apps/immich/src/api-v1/job/dto/job-command.dto.ts
  28. 0 33
      server/apps/immich/src/api-v1/job/job.controller.ts
  29. 0 11
      server/apps/immich/src/api-v1/job/job.module.ts
  30. 0 142
      server/apps/immich/src/api-v1/job/job.service.ts
  31. 0 32
      server/apps/immich/src/api-v1/job/response-dto/all-job-status-response.dto.ts
  32. 2 3
      server/apps/immich/src/app.module.ts
  33. 1 0
      server/apps/immich/src/controllers/index.ts
  34. 21 0
      server/apps/immich/src/controllers/job.controller.ts
  35. 4 2
      server/apps/microservices/src/microservices.module.ts
  36. 28 7
      server/apps/microservices/src/processors.ts
  37. 23 2
      server/apps/microservices/src/processors/metadata-extraction.processor.ts
  38. 28 3
      server/apps/microservices/src/processors/video-transcode.processor.ts
  39. 174 165
      server/immich-openapi-specs.json
  40. 8 0
      server/libs/common/src/constants/index.ts
  41. 10 0
      server/libs/domain/src/asset/asset.repository.ts
  42. 2 0
      server/libs/domain/src/domain.module.ts
  43. 1 0
      server/libs/domain/src/index.ts
  44. 2 0
      server/libs/domain/src/job/dto/index.ts
  45. 14 0
      server/libs/domain/src/job/dto/job-command.dto.ts
  46. 10 0
      server/libs/domain/src/job/dto/job-id.dto.ts
  47. 3 0
      server/libs/domain/src/job/index.ts
  48. 36 5
      server/libs/domain/src/job/job.constants.ts
  49. 11 7
      server/libs/domain/src/job/job.interface.ts
  50. 30 4
      server/libs/domain/src/job/job.repository.ts
  51. 170 0
      server/libs/domain/src/job/job.service.spec.ts
  52. 68 0
      server/libs/domain/src/job/job.service.ts
  53. 41 0
      server/libs/domain/src/job/response-dto/all-job-status-response.dto.ts
  54. 1 0
      server/libs/domain/src/job/response-dto/index.ts
  55. 22 6
      server/libs/domain/src/media/media.service.ts
  56. 1 1
      server/libs/domain/src/smart-info/machine-learning.interface.ts
  57. 97 13
      server/libs/domain/src/smart-info/smart-info.service.spec.ts
  58. 42 11
      server/libs/domain/src/smart-info/smart-info.service.ts
  59. 0 0
      server/libs/domain/src/util.ts
  60. 1 0
      server/libs/domain/test/asset.repository.mock.ts
  61. 1 0
      server/libs/domain/test/job.repository.mock.ts
  62. 1 1
      server/libs/domain/test/machine-learning.repository.mock.ts
  63. 71 2
      server/libs/infra/src/db/repository/asset.repository.ts
  64. 44 30
      server/libs/infra/src/job/job.repository.ts
  65. 1 1
      server/libs/infra/src/machine-learning/machine-learning.repository.ts
  66. 56 34
      web/src/api/open-api/api.ts
  67. 4 4
      web/src/lib/components/admin-page/jobs/job-tile.svelte
  68. 54 75
      web/src/lib/components/admin-page/jobs/jobs-panel.svelte

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

@@ -51,8 +51,8 @@ doc/GetAssetCountByTimeBucketDto.md
 doc/JobApi.md
 doc/JobApi.md
 doc/JobCommand.md
 doc/JobCommand.md
 doc/JobCommandDto.md
 doc/JobCommandDto.md
-doc/JobCounts.md
-doc/JobId.md
+doc/JobCountsDto.md
+doc/JobName.md
 doc/LoginCredentialDto.md
 doc/LoginCredentialDto.md
 doc/LoginResponseDto.md
 doc/LoginResponseDto.md
 doc/LogoutResponseDto.md
 doc/LogoutResponseDto.md
@@ -168,8 +168,8 @@ lib/model/get_asset_by_time_bucket_dto.dart
 lib/model/get_asset_count_by_time_bucket_dto.dart
 lib/model/get_asset_count_by_time_bucket_dto.dart
 lib/model/job_command.dart
 lib/model/job_command.dart
 lib/model/job_command_dto.dart
 lib/model/job_command_dto.dart
-lib/model/job_counts.dart
-lib/model/job_id.dart
+lib/model/job_counts_dto.dart
+lib/model/job_name.dart
 lib/model/login_credential_dto.dart
 lib/model/login_credential_dto.dart
 lib/model/login_response_dto.dart
 lib/model/login_response_dto.dart
 lib/model/logout_response_dto.dart
 lib/model/logout_response_dto.dart
@@ -262,8 +262,8 @@ test/get_asset_count_by_time_bucket_dto_test.dart
 test/job_api_test.dart
 test/job_api_test.dart
 test/job_command_dto_test.dart
 test/job_command_dto_test.dart
 test/job_command_test.dart
 test/job_command_test.dart
-test/job_counts_test.dart
-test/job_id_test.dart
+test/job_counts_dto_test.dart
+test/job_name_test.dart
 test/login_credential_dto_test.dart
 test/login_credential_dto_test.dart
 test/login_response_dto_test.dart
 test/login_response_dto_test.dart
 test/logout_response_dto_test.dart
 test/logout_response_dto_test.dart

+ 2 - 2
mobile/openapi/README.md

@@ -198,8 +198,8 @@ Class | Method | HTTP request | Description
  - [GetAssetCountByTimeBucketDto](doc//GetAssetCountByTimeBucketDto.md)
  - [GetAssetCountByTimeBucketDto](doc//GetAssetCountByTimeBucketDto.md)
  - [JobCommand](doc//JobCommand.md)
  - [JobCommand](doc//JobCommand.md)
  - [JobCommandDto](doc//JobCommandDto.md)
  - [JobCommandDto](doc//JobCommandDto.md)
- - [JobCounts](doc//JobCounts.md)
- - [JobId](doc//JobId.md)
+ - [JobCountsDto](doc//JobCountsDto.md)
+ - [JobName](doc//JobName.md)
  - [LoginCredentialDto](doc//LoginCredentialDto.md)
  - [LoginCredentialDto](doc//LoginCredentialDto.md)
  - [LoginResponseDto](doc//LoginResponseDto.md)
  - [LoginResponseDto](doc//LoginResponseDto.md)
  - [LogoutResponseDto](doc//LogoutResponseDto.md)
  - [LogoutResponseDto](doc//LogoutResponseDto.md)

+ 8 - 5
mobile/openapi/doc/AllJobStatusResponseDto.md

@@ -8,11 +8,14 @@ import 'package:openapi/api.dart';
 ## Properties
 ## Properties
 Name | Type | Description | Notes
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 ------------ | ------------- | ------------- | -------------
-**thumbnailGeneration** | [**JobCounts**](JobCounts.md) |  | 
-**metadataExtraction** | [**JobCounts**](JobCounts.md) |  | 
-**videoConversion** | [**JobCounts**](JobCounts.md) |  | 
-**machineLearning** | [**JobCounts**](JobCounts.md) |  | 
-**storageTemplateMigration** | [**JobCounts**](JobCounts.md) |  | 
+**thumbnailGenerationQueue** | [**JobCountsDto**](JobCountsDto.md) |  | 
+**metadataExtractionQueue** | [**JobCountsDto**](JobCountsDto.md) |  | 
+**videoConversionQueue** | [**JobCountsDto**](JobCountsDto.md) |  | 
+**objectTaggingQueue** | [**JobCountsDto**](JobCountsDto.md) |  | 
+**clipEncodingQueue** | [**JobCountsDto**](JobCountsDto.md) |  | 
+**storageTemplateMigrationQueue** | [**JobCountsDto**](JobCountsDto.md) |  | 
+**backgroundTaskQueue** | [**JobCountsDto**](JobCountsDto.md) |  | 
+**searchQueue** | [**JobCountsDto**](JobCountsDto.md) |  | 
 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 
 

+ 6 - 7
mobile/openapi/doc/JobApi.md

@@ -63,7 +63,7 @@ This endpoint does not need any parameter.
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
 # **sendJobCommand**
 # **sendJobCommand**
-> num sendJobCommand(jobId, jobCommandDto)
+> sendJobCommand(jobId, jobCommandDto)
 
 
 
 
 
 
@@ -84,12 +84,11 @@ import 'package:openapi/api.dart';
 //defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
 //defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
 
 
 final api_instance = JobApi();
 final api_instance = JobApi();
-final jobId = ; // JobId | 
+final jobId = ; // JobName | 
 final jobCommandDto = JobCommandDto(); // JobCommandDto | 
 final jobCommandDto = JobCommandDto(); // JobCommandDto | 
 
 
 try {
 try {
-    final result = api_instance.sendJobCommand(jobId, jobCommandDto);
-    print(result);
+    api_instance.sendJobCommand(jobId, jobCommandDto);
 } catch (e) {
 } catch (e) {
     print('Exception when calling JobApi->sendJobCommand: $e\n');
     print('Exception when calling JobApi->sendJobCommand: $e\n');
 }
 }
@@ -99,12 +98,12 @@ try {
 
 
 Name | Type | Description  | Notes
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
 ------------- | ------------- | ------------- | -------------
- **jobId** | [**JobId**](.md)|  | 
+ **jobId** | [**JobName**](.md)|  | 
  **jobCommandDto** | [**JobCommandDto**](JobCommandDto.md)|  | 
  **jobCommandDto** | [**JobCommandDto**](JobCommandDto.md)|  | 
 
 
 ### Return type
 ### Return type
 
 
-**num**
+void (empty response body)
 
 
 ### Authorization
 ### Authorization
 
 
@@ -113,7 +112,7 @@ Name | Type | Description  | Notes
 ### HTTP request headers
 ### HTTP request headers
 
 
  - **Content-Type**: application/json
  - **Content-Type**: application/json
- - **Accept**: application/json
+ - **Accept**: Not defined
 
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 

+ 1 - 1
mobile/openapi/doc/JobCommandDto.md

@@ -9,7 +9,7 @@ import 'package:openapi/api.dart';
 Name | Type | Description | Notes
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 ------------ | ------------- | ------------- | -------------
 **command** | [**JobCommand**](JobCommand.md) |  | 
 **command** | [**JobCommand**](JobCommand.md) |  | 
-**includeAllAssets** | **bool** |  | 
+**force** | **bool** |  | 
 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 
 

+ 1 - 1
mobile/openapi/doc/JobCounts.md → mobile/openapi/doc/JobCountsDto.md

@@ -1,4 +1,4 @@
-# openapi.model.JobCounts
+# openapi.model.JobCountsDto
 
 
 ## Load the model package
 ## Load the model package
 ```dart
 ```dart

+ 1 - 1
mobile/openapi/doc/JobId.md → mobile/openapi/doc/JobName.md

@@ -1,4 +1,4 @@
-# openapi.model.JobId
+# openapi.model.JobName
 
 
 ## Load the model package
 ## Load the model package
 ```dart
 ```dart

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

@@ -84,8 +84,8 @@ part 'model/get_asset_by_time_bucket_dto.dart';
 part 'model/get_asset_count_by_time_bucket_dto.dart';
 part 'model/get_asset_count_by_time_bucket_dto.dart';
 part 'model/job_command.dart';
 part 'model/job_command.dart';
 part 'model/job_command_dto.dart';
 part 'model/job_command_dto.dart';
-part 'model/job_counts.dart';
-part 'model/job_id.dart';
+part 'model/job_counts_dto.dart';
+part 'model/job_name.dart';
 part 'model/login_credential_dto.dart';
 part 'model/login_credential_dto.dart';
 part 'model/login_response_dto.dart';
 part 'model/login_response_dto.dart';
 part 'model/logout_response_dto.dart';
 part 'model/logout_response_dto.dart';

+ 4 - 12
mobile/openapi/lib/api/job_api.dart

@@ -66,10 +66,10 @@ class JobApi {
   ///
   ///
   /// Parameters:
   /// Parameters:
   ///
   ///
-  /// * [JobId] jobId (required):
+  /// * [JobName] jobId (required):
   ///
   ///
   /// * [JobCommandDto] jobCommandDto (required):
   /// * [JobCommandDto] jobCommandDto (required):
-  Future<Response> sendJobCommandWithHttpInfo(JobId jobId, JobCommandDto jobCommandDto,) async {
+  Future<Response> sendJobCommandWithHttpInfo(JobName jobId, JobCommandDto jobCommandDto,) async {
     // ignore: prefer_const_declarations
     // ignore: prefer_const_declarations
     final path = r'/jobs/{jobId}'
     final path = r'/jobs/{jobId}'
       .replaceAll('{jobId}', jobId.toString());
       .replaceAll('{jobId}', jobId.toString());
@@ -99,21 +99,13 @@ class JobApi {
   ///
   ///
   /// Parameters:
   /// Parameters:
   ///
   ///
-  /// * [JobId] jobId (required):
+  /// * [JobName] jobId (required):
   ///
   ///
   /// * [JobCommandDto] jobCommandDto (required):
   /// * [JobCommandDto] jobCommandDto (required):
-  Future<num?> sendJobCommand(JobId jobId, JobCommandDto jobCommandDto,) async {
+  Future<void> sendJobCommand(JobName jobId, JobCommandDto jobCommandDto,) async {
     final response = await sendJobCommandWithHttpInfo(jobId, jobCommandDto,);
     final response = await sendJobCommandWithHttpInfo(jobId, jobCommandDto,);
     if (response.statusCode >= HttpStatus.badRequest) {
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
     }
-    // When a remote server returns no body with a status of 204, we shall not decode it.
-    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
-    // FormatException when trying to decode an empty string.
-    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
-      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'num',) as num;
-    
-    }
-    return null;
   }
   }
 }
 }

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

@@ -276,10 +276,10 @@ class ApiClient {
           return JobCommandTypeTransformer().decode(value);
           return JobCommandTypeTransformer().decode(value);
         case 'JobCommandDto':
         case 'JobCommandDto':
           return JobCommandDto.fromJson(value);
           return JobCommandDto.fromJson(value);
-        case 'JobCounts':
-          return JobCounts.fromJson(value);
-        case 'JobId':
-          return JobIdTypeTransformer().decode(value);
+        case 'JobCountsDto':
+          return JobCountsDto.fromJson(value);
+        case 'JobName':
+          return JobNameTypeTransformer().decode(value);
         case 'LoginCredentialDto':
         case 'LoginCredentialDto':
           return LoginCredentialDto.fromJson(value);
           return LoginCredentialDto.fromJson(value);
         case 'LoginResponseDto':
         case 'LoginResponseDto':

+ 2 - 2
mobile/openapi/lib/api_helper.dart

@@ -67,8 +67,8 @@ String parameterToString(dynamic value) {
   if (value is JobCommand) {
   if (value is JobCommand) {
     return JobCommandTypeTransformer().encode(value).toString();
     return JobCommandTypeTransformer().encode(value).toString();
   }
   }
-  if (value is JobId) {
-    return JobIdTypeTransformer().encode(value).toString();
+  if (value is JobName) {
+    return JobNameTypeTransformer().encode(value).toString();
   }
   }
   if (value is SharedLinkType) {
   if (value is SharedLinkType) {
     return SharedLinkTypeTypeTransformer().encode(value).toString();
     return SharedLinkTypeTypeTransformer().encode(value).toString();

+ 60 - 36
mobile/openapi/lib/model/all_job_status_response_dto.dart

@@ -13,50 +13,68 @@ part of openapi.api;
 class AllJobStatusResponseDto {
 class AllJobStatusResponseDto {
   /// Returns a new [AllJobStatusResponseDto] instance.
   /// Returns a new [AllJobStatusResponseDto] instance.
   AllJobStatusResponseDto({
   AllJobStatusResponseDto({
-    required this.thumbnailGeneration,
-    required this.metadataExtraction,
-    required this.videoConversion,
-    required this.machineLearning,
-    required this.storageTemplateMigration,
+    required this.thumbnailGenerationQueue,
+    required this.metadataExtractionQueue,
+    required this.videoConversionQueue,
+    required this.objectTaggingQueue,
+    required this.clipEncodingQueue,
+    required this.storageTemplateMigrationQueue,
+    required this.backgroundTaskQueue,
+    required this.searchQueue,
   });
   });
 
 
-  JobCounts thumbnailGeneration;
+  JobCountsDto thumbnailGenerationQueue;
 
 
-  JobCounts metadataExtraction;
+  JobCountsDto metadataExtractionQueue;
 
 
-  JobCounts videoConversion;
+  JobCountsDto videoConversionQueue;
 
 
-  JobCounts machineLearning;
+  JobCountsDto objectTaggingQueue;
 
 
-  JobCounts storageTemplateMigration;
+  JobCountsDto clipEncodingQueue;
+
+  JobCountsDto storageTemplateMigrationQueue;
+
+  JobCountsDto backgroundTaskQueue;
+
+  JobCountsDto searchQueue;
 
 
   @override
   @override
   bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto &&
   bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto &&
-     other.thumbnailGeneration == thumbnailGeneration &&
-     other.metadataExtraction == metadataExtraction &&
-     other.videoConversion == videoConversion &&
-     other.machineLearning == machineLearning &&
-     other.storageTemplateMigration == storageTemplateMigration;
+     other.thumbnailGenerationQueue == thumbnailGenerationQueue &&
+     other.metadataExtractionQueue == metadataExtractionQueue &&
+     other.videoConversionQueue == videoConversionQueue &&
+     other.objectTaggingQueue == objectTaggingQueue &&
+     other.clipEncodingQueue == clipEncodingQueue &&
+     other.storageTemplateMigrationQueue == storageTemplateMigrationQueue &&
+     other.backgroundTaskQueue == backgroundTaskQueue &&
+     other.searchQueue == searchQueue;
 
 
   @override
   @override
   int get hashCode =>
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     // ignore: unnecessary_parenthesis
-    (thumbnailGeneration.hashCode) +
-    (metadataExtraction.hashCode) +
-    (videoConversion.hashCode) +
-    (machineLearning.hashCode) +
-    (storageTemplateMigration.hashCode);
+    (thumbnailGenerationQueue.hashCode) +
+    (metadataExtractionQueue.hashCode) +
+    (videoConversionQueue.hashCode) +
+    (objectTaggingQueue.hashCode) +
+    (clipEncodingQueue.hashCode) +
+    (storageTemplateMigrationQueue.hashCode) +
+    (backgroundTaskQueue.hashCode) +
+    (searchQueue.hashCode);
 
 
   @override
   @override
-  String toString() => 'AllJobStatusResponseDto[thumbnailGeneration=$thumbnailGeneration, metadataExtraction=$metadataExtraction, videoConversion=$videoConversion, machineLearning=$machineLearning, storageTemplateMigration=$storageTemplateMigration]';
+  String toString() => 'AllJobStatusResponseDto[thumbnailGenerationQueue=$thumbnailGenerationQueue, metadataExtractionQueue=$metadataExtractionQueue, videoConversionQueue=$videoConversionQueue, objectTaggingQueue=$objectTaggingQueue, clipEncodingQueue=$clipEncodingQueue, storageTemplateMigrationQueue=$storageTemplateMigrationQueue, backgroundTaskQueue=$backgroundTaskQueue, searchQueue=$searchQueue]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
-      json[r'thumbnail-generation'] = this.thumbnailGeneration;
-      json[r'metadata-extraction'] = this.metadataExtraction;
-      json[r'video-conversion'] = this.videoConversion;
-      json[r'machine-learning'] = this.machineLearning;
-      json[r'storage-template-migration'] = this.storageTemplateMigration;
+      json[r'thumbnail-generation-queue'] = this.thumbnailGenerationQueue;
+      json[r'metadata-extraction-queue'] = this.metadataExtractionQueue;
+      json[r'video-conversion-queue'] = this.videoConversionQueue;
+      json[r'object-tagging-queue'] = this.objectTaggingQueue;
+      json[r'clip-encoding-queue'] = this.clipEncodingQueue;
+      json[r'storage-template-migration-queue'] = this.storageTemplateMigrationQueue;
+      json[r'background-task-queue'] = this.backgroundTaskQueue;
+      json[r'search-queue'] = this.searchQueue;
     return json;
     return json;
   }
   }
 
 
@@ -79,11 +97,14 @@ class AllJobStatusResponseDto {
       }());
       }());
 
 
       return AllJobStatusResponseDto(
       return AllJobStatusResponseDto(
-        thumbnailGeneration: JobCounts.fromJson(json[r'thumbnail-generation'])!,
-        metadataExtraction: JobCounts.fromJson(json[r'metadata-extraction'])!,
-        videoConversion: JobCounts.fromJson(json[r'video-conversion'])!,
-        machineLearning: JobCounts.fromJson(json[r'machine-learning'])!,
-        storageTemplateMigration: JobCounts.fromJson(json[r'storage-template-migration'])!,
+        thumbnailGenerationQueue: JobCountsDto.fromJson(json[r'thumbnail-generation-queue'])!,
+        metadataExtractionQueue: JobCountsDto.fromJson(json[r'metadata-extraction-queue'])!,
+        videoConversionQueue: JobCountsDto.fromJson(json[r'video-conversion-queue'])!,
+        objectTaggingQueue: JobCountsDto.fromJson(json[r'object-tagging-queue'])!,
+        clipEncodingQueue: JobCountsDto.fromJson(json[r'clip-encoding-queue'])!,
+        storageTemplateMigrationQueue: JobCountsDto.fromJson(json[r'storage-template-migration-queue'])!,
+        backgroundTaskQueue: JobCountsDto.fromJson(json[r'background-task-queue'])!,
+        searchQueue: JobCountsDto.fromJson(json[r'search-queue'])!,
       );
       );
     }
     }
     return null;
     return null;
@@ -133,11 +154,14 @@ class AllJobStatusResponseDto {
 
 
   /// The list of required keys that must be present in a JSON.
   /// The list of required keys that must be present in a JSON.
   static const requiredKeys = <String>{
   static const requiredKeys = <String>{
-    'thumbnail-generation',
-    'metadata-extraction',
-    'video-conversion',
-    'machine-learning',
-    'storage-template-migration',
+    'thumbnail-generation-queue',
+    'metadata-extraction-queue',
+    'video-conversion-queue',
+    'object-tagging-queue',
+    'clip-encoding-queue',
+    'storage-template-migration-queue',
+    'background-task-queue',
+    'search-queue',
   };
   };
 }
 }
 
 

+ 6 - 3
mobile/openapi/lib/model/job_command.dart

@@ -24,12 +24,14 @@ class JobCommand {
   String toJson() => value;
   String toJson() => value;
 
 
   static const start = JobCommand._(r'start');
   static const start = JobCommand._(r'start');
-  static const stop = JobCommand._(r'stop');
+  static const pause = JobCommand._(r'pause');
+  static const empty = JobCommand._(r'empty');
 
 
   /// List of all possible values in this [enum][JobCommand].
   /// List of all possible values in this [enum][JobCommand].
   static const values = <JobCommand>[
   static const values = <JobCommand>[
     start,
     start,
-    stop,
+    pause,
+    empty,
   ];
   ];
 
 
   static JobCommand? fromJson(dynamic value) => JobCommandTypeTransformer().decode(value);
   static JobCommand? fromJson(dynamic value) => JobCommandTypeTransformer().decode(value);
@@ -69,7 +71,8 @@ class JobCommandTypeTransformer {
     if (data != null) {
     if (data != null) {
       switch (data.toString()) {
       switch (data.toString()) {
         case r'start': return JobCommand.start;
         case r'start': return JobCommand.start;
-        case r'stop': return JobCommand.stop;
+        case r'pause': return JobCommand.pause;
+        case r'empty': return JobCommand.empty;
         default:
         default:
           if (!allowNull) {
           if (!allowNull) {
             throw ArgumentError('Unknown enum value to decode: $data');
             throw ArgumentError('Unknown enum value to decode: $data');

+ 8 - 8
mobile/openapi/lib/model/job_command_dto.dart

@@ -14,31 +14,31 @@ class JobCommandDto {
   /// Returns a new [JobCommandDto] instance.
   /// Returns a new [JobCommandDto] instance.
   JobCommandDto({
   JobCommandDto({
     required this.command,
     required this.command,
-    required this.includeAllAssets,
+    required this.force,
   });
   });
 
 
   JobCommand command;
   JobCommand command;
 
 
-  bool includeAllAssets;
+  bool force;
 
 
   @override
   @override
   bool operator ==(Object other) => identical(this, other) || other is JobCommandDto &&
   bool operator ==(Object other) => identical(this, other) || other is JobCommandDto &&
      other.command == command &&
      other.command == command &&
-     other.includeAllAssets == includeAllAssets;
+     other.force == force;
 
 
   @override
   @override
   int get hashCode =>
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     // ignore: unnecessary_parenthesis
     (command.hashCode) +
     (command.hashCode) +
-    (includeAllAssets.hashCode);
+    (force.hashCode);
 
 
   @override
   @override
-  String toString() => 'JobCommandDto[command=$command, includeAllAssets=$includeAllAssets]';
+  String toString() => 'JobCommandDto[command=$command, force=$force]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
       json[r'command'] = this.command;
       json[r'command'] = this.command;
-      json[r'includeAllAssets'] = this.includeAllAssets;
+      json[r'force'] = this.force;
     return json;
     return json;
   }
   }
 
 
@@ -62,7 +62,7 @@ class JobCommandDto {
 
 
       return JobCommandDto(
       return JobCommandDto(
         command: JobCommand.fromJson(json[r'command'])!,
         command: JobCommand.fromJson(json[r'command'])!,
-        includeAllAssets: mapValueOfType<bool>(json, r'includeAllAssets')!,
+        force: mapValueOfType<bool>(json, r'force')!,
       );
       );
     }
     }
     return null;
     return null;
@@ -113,7 +113,7 @@ class JobCommandDto {
   /// The list of required keys that must be present in a JSON.
   /// The list of required keys that must be present in a JSON.
   static const requiredKeys = <String>{
   static const requiredKeys = <String>{
     'command',
     'command',
-    'includeAllAssets',
+    'force',
   };
   };
 }
 }
 
 

+ 20 - 20
mobile/openapi/lib/model/job_counts.dart → mobile/openapi/lib/model/job_counts_dto.dart

@@ -10,9 +10,9 @@
 
 
 part of openapi.api;
 part of openapi.api;
 
 
-class JobCounts {
-  /// Returns a new [JobCounts] instance.
-  JobCounts({
+class JobCountsDto {
+  /// Returns a new [JobCountsDto] instance.
+  JobCountsDto({
     required this.active,
     required this.active,
     required this.completed,
     required this.completed,
     required this.failed,
     required this.failed,
@@ -31,7 +31,7 @@ class JobCounts {
   int waiting;
   int waiting;
 
 
   @override
   @override
-  bool operator ==(Object other) => identical(this, other) || other is JobCounts &&
+  bool operator ==(Object other) => identical(this, other) || other is JobCountsDto &&
      other.active == active &&
      other.active == active &&
      other.completed == completed &&
      other.completed == completed &&
      other.failed == failed &&
      other.failed == failed &&
@@ -48,7 +48,7 @@ class JobCounts {
     (waiting.hashCode);
     (waiting.hashCode);
 
 
   @override
   @override
-  String toString() => 'JobCounts[active=$active, completed=$completed, failed=$failed, delayed=$delayed, waiting=$waiting]';
+  String toString() => 'JobCountsDto[active=$active, completed=$completed, failed=$failed, delayed=$delayed, waiting=$waiting]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
@@ -60,10 +60,10 @@ class JobCounts {
     return json;
     return json;
   }
   }
 
 
-  /// Returns a new [JobCounts] instance and imports its values from
+  /// Returns a new [JobCountsDto] instance and imports its values from
   /// [value] if it's a [Map], null otherwise.
   /// [value] if it's a [Map], null otherwise.
   // ignore: prefer_constructors_over_static_methods
   // ignore: prefer_constructors_over_static_methods
-  static JobCounts? fromJson(dynamic value) {
+  static JobCountsDto? fromJson(dynamic value) {
     if (value is Map) {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
       final json = value.cast<String, dynamic>();
 
 
@@ -72,13 +72,13 @@ class JobCounts {
       // Note 2: this code is stripped in release mode!
       // Note 2: this code is stripped in release mode!
       assert(() {
       assert(() {
         requiredKeys.forEach((key) {
         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.');
+          assert(json.containsKey(key), 'Required key "JobCountsDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "JobCountsDto[$key]" has a null value in JSON.');
         });
         });
         return true;
         return true;
       }());
       }());
 
 
-      return JobCounts(
+      return JobCountsDto(
         active: mapValueOfType<int>(json, r'active')!,
         active: mapValueOfType<int>(json, r'active')!,
         completed: mapValueOfType<int>(json, r'completed')!,
         completed: mapValueOfType<int>(json, r'completed')!,
         failed: mapValueOfType<int>(json, r'failed')!,
         failed: mapValueOfType<int>(json, r'failed')!,
@@ -89,11 +89,11 @@ class JobCounts {
     return null;
     return null;
   }
   }
 
 
-  static List<JobCounts>? listFromJson(dynamic json, {bool growable = false,}) {
-    final result = <JobCounts>[];
+  static List<JobCountsDto>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <JobCountsDto>[];
     if (json is List && json.isNotEmpty) {
     if (json is List && json.isNotEmpty) {
       for (final row in json) {
       for (final row in json) {
-        final value = JobCounts.fromJson(row);
+        final value = JobCountsDto.fromJson(row);
         if (value != null) {
         if (value != null) {
           result.add(value);
           result.add(value);
         }
         }
@@ -102,12 +102,12 @@ class JobCounts {
     return result.toList(growable: growable);
     return result.toList(growable: growable);
   }
   }
 
 
-  static Map<String, JobCounts> mapFromJson(dynamic json) {
-    final map = <String, JobCounts>{};
+  static Map<String, JobCountsDto> mapFromJson(dynamic json) {
+    final map = <String, JobCountsDto>{};
     if (json is Map && json.isNotEmpty) {
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       for (final entry in json.entries) {
       for (final entry in json.entries) {
-        final value = JobCounts.fromJson(entry.value);
+        final value = JobCountsDto.fromJson(entry.value);
         if (value != null) {
         if (value != null) {
           map[entry.key] = value;
           map[entry.key] = value;
         }
         }
@@ -116,13 +116,13 @@ class JobCounts {
     return map;
     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>>{};
+  // maps a json object with a list of JobCountsDto-objects as value to a dart map
+  static Map<String, List<JobCountsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<JobCountsDto>>{};
     if (json is Map && json.isNotEmpty) {
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       for (final entry in json.entries) {
       for (final entry in json.entries) {
-        final value = JobCounts.listFromJson(entry.value, growable: growable,);
+        final value = JobCountsDto.listFromJson(entry.value, growable: growable,);
         if (value != null) {
         if (value != null) {
           map[entry.key] = value;
           map[entry.key] = value;
         }
         }

+ 0 - 94
mobile/openapi/lib/model/job_id.dart

@@ -1,94 +0,0 @@
-//
-// AUTO-GENERATED FILE, DO NOT MODIFY!
-//
-// @dart=2.12
-
-// ignore_for_file: unused_element, unused_import
-// ignore_for_file: always_put_required_named_parameters_first
-// ignore_for_file: constant_identifier_names
-// ignore_for_file: lines_longer_than_80_chars
-
-part of openapi.api;
-
-
-class 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');
-  static const storageTemplateMigration = JobId._(r'storage-template-migration');
-
-  /// List of all possible values in this [enum][JobId].
-  static const values = <JobId>[
-    thumbnailGeneration,
-    metadataExtraction,
-    videoConversion,
-    machineLearning,
-    storageTemplateMigration,
-  ];
-
-  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;
-        case r'storage-template-migration': return JobId.storageTemplateMigration;
-        default:
-          if (!allowNull) {
-            throw ArgumentError('Unknown enum value to decode: $data');
-          }
-      }
-    }
-    return null;
-  }
-
-  /// Singleton [JobIdTypeTransformer] instance.
-  static JobIdTypeTransformer? _instance;
-}
-

+ 103 - 0
mobile/openapi/lib/model/job_name.dart

@@ -0,0 +1,103 @@
+//
+// 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 JobName {
+  /// Instantiate a new enum with the provided [value].
+  const JobName._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const thumbnailGenerationQueue = JobName._(r'thumbnail-generation-queue');
+  static const metadataExtractionQueue = JobName._(r'metadata-extraction-queue');
+  static const videoConversionQueue = JobName._(r'video-conversion-queue');
+  static const objectTaggingQueue = JobName._(r'object-tagging-queue');
+  static const clipEncodingQueue = JobName._(r'clip-encoding-queue');
+  static const backgroundTaskQueue = JobName._(r'background-task-queue');
+  static const storageTemplateMigrationQueue = JobName._(r'storage-template-migration-queue');
+  static const searchQueue = JobName._(r'search-queue');
+
+  /// List of all possible values in this [enum][JobName].
+  static const values = <JobName>[
+    thumbnailGenerationQueue,
+    metadataExtractionQueue,
+    videoConversionQueue,
+    objectTaggingQueue,
+    clipEncodingQueue,
+    backgroundTaskQueue,
+    storageTemplateMigrationQueue,
+    searchQueue,
+  ];
+
+  static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
+
+  static List<JobName>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <JobName>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = JobName.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [JobName] to String,
+/// and [decode] dynamic data back to [JobName].
+class JobNameTypeTransformer {
+  factory JobNameTypeTransformer() => _instance ??= const JobNameTypeTransformer._();
+
+  const JobNameTypeTransformer._();
+
+  String encode(JobName data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a JobName.
+  ///
+  /// 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.
+  JobName? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data.toString()) {
+        case r'thumbnail-generation-queue': return JobName.thumbnailGenerationQueue;
+        case r'metadata-extraction-queue': return JobName.metadataExtractionQueue;
+        case r'video-conversion-queue': return JobName.videoConversionQueue;
+        case r'object-tagging-queue': return JobName.objectTaggingQueue;
+        case r'clip-encoding-queue': return JobName.clipEncodingQueue;
+        case r'background-task-queue': return JobName.backgroundTaskQueue;
+        case r'storage-template-migration-queue': return JobName.storageTemplateMigrationQueue;
+        case r'search-queue': return JobName.searchQueue;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [JobNameTypeTransformer] instance.
+  static JobNameTypeTransformer? _instance;
+}
+

+ 25 - 10
mobile/openapi/test/all_job_status_response_dto_test.dart

@@ -16,28 +16,43 @@ void main() {
   // final instance = AllJobStatusResponseDto();
   // final instance = AllJobStatusResponseDto();
 
 
   group('test AllJobStatusResponseDto', () {
   group('test AllJobStatusResponseDto', () {
-    // JobCounts thumbnailGeneration
-    test('to test the property `thumbnailGeneration`', () async {
+    // JobCountsDto thumbnailGenerationQueue
+    test('to test the property `thumbnailGenerationQueue`', () async {
       // TODO
       // TODO
     });
     });
 
 
-    // JobCounts metadataExtraction
-    test('to test the property `metadataExtraction`', () async {
+    // JobCountsDto metadataExtractionQueue
+    test('to test the property `metadataExtractionQueue`', () async {
       // TODO
       // TODO
     });
     });
 
 
-    // JobCounts videoConversion
-    test('to test the property `videoConversion`', () async {
+    // JobCountsDto videoConversionQueue
+    test('to test the property `videoConversionQueue`', () async {
       // TODO
       // TODO
     });
     });
 
 
-    // JobCounts machineLearning
-    test('to test the property `machineLearning`', () async {
+    // JobCountsDto objectTaggingQueue
+    test('to test the property `objectTaggingQueue`', () async {
       // TODO
       // TODO
     });
     });
 
 
-    // JobCounts storageTemplateMigration
-    test('to test the property `storageTemplateMigration`', () async {
+    // JobCountsDto clipEncodingQueue
+    test('to test the property `clipEncodingQueue`', () async {
+      // TODO
+    });
+
+    // JobCountsDto storageTemplateMigrationQueue
+    test('to test the property `storageTemplateMigrationQueue`', () async {
+      // TODO
+    });
+
+    // JobCountsDto backgroundTaskQueue
+    test('to test the property `backgroundTaskQueue`', () async {
+      // TODO
+    });
+
+    // JobCountsDto searchQueue
+    test('to test the property `searchQueue`', () async {
       // TODO
       // TODO
     });
     });
 
 

+ 1 - 1
mobile/openapi/test/job_api_test.dart

@@ -26,7 +26,7 @@ void main() {
 
 
     // 
     // 
     //
     //
-    //Future<num> sendJobCommand(JobId jobId, JobCommandDto jobCommandDto) async
+    //Future sendJobCommand(JobName jobId, JobCommandDto jobCommandDto) async
     test('test sendJobCommand', () async {
     test('test sendJobCommand', () async {
       // TODO
       // TODO
     });
     });

+ 2 - 2
mobile/openapi/test/job_command_dto_test.dart

@@ -21,8 +21,8 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
-    // bool includeAllAssets
-    test('to test the property `includeAllAssets`', () async {
+    // bool force
+    test('to test the property `force`', () async {
       // TODO
       // TODO
     });
     });
 
 

+ 3 - 3
mobile/openapi/test/job_counts_test.dart → mobile/openapi/test/job_counts_dto_test.dart

@@ -11,11 +11,11 @@
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
 import 'package:test/test.dart';
 import 'package:test/test.dart';
 
 
-// tests for JobCounts
+// tests for JobCountsDto
 void main() {
 void main() {
-  // final instance = JobCounts();
+  // final instance = JobCountsDto();
 
 
-  group('test JobCounts', () {
+  group('test JobCountsDto', () {
     // int active
     // int active
     test('to test the property `active`', () async {
     test('to test the property `active`', () async {
       // TODO
       // TODO

+ 2 - 2
mobile/openapi/test/job_id_test.dart → mobile/openapi/test/job_name_test.dart

@@ -11,10 +11,10 @@
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
 import 'package:test/test.dart';
 import 'package:test/test.dart';
 
 
-// tests for JobId
+// tests for JobName
 void main() {
 void main() {
 
 
-  group('test JobId', () {
+  group('test JobName', () {
 
 
   });
   });
 
 

+ 0 - 43
server/apps/immich/src/api-v1/asset/asset-repository.ts

@@ -38,10 +38,6 @@ export interface IAssetRepository {
   getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
   getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
   getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
   getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
   getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
   getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
-  getAssetWithNoThumbnail(): Promise<AssetEntity[]>;
-  getAssetWithNoEncodedVideo(): Promise<AssetEntity[]>;
-  getAssetWithNoEXIF(): Promise<AssetEntity[]>;
-  getAssetWithNoSmartInfo(): Promise<AssetEntity[]>;
   getExistingAssets(
   getExistingAssets(
     userId: string,
     userId: string,
     checkDuplicateAssetDto: CheckExistingAssetsDto,
     checkDuplicateAssetDto: CheckExistingAssetsDto,
@@ -76,45 +72,6 @@ export class AssetRepository implements IAssetRepository {
     });
     });
   }
   }
 
 
-  async getAssetWithNoSmartInfo(): Promise<AssetEntity[]> {
-    return await this.assetRepository
-      .createQueryBuilder('asset')
-      .leftJoinAndSelect('asset.smartInfo', 'si')
-      .where('asset.resizePath IS NOT NULL')
-      .andWhere('si.assetId IS NULL')
-      .andWhere('asset.isVisible = true')
-      .getMany();
-  }
-
-  async getAssetWithNoThumbnail(): Promise<AssetEntity[]> {
-    return await this.assetRepository.find({
-      where: [
-        { resizePath: IsNull(), isVisible: true },
-        { resizePath: '', isVisible: true },
-        { webpPath: IsNull(), isVisible: true },
-        { webpPath: '', isVisible: true },
-      ],
-    });
-  }
-
-  async getAssetWithNoEncodedVideo(): Promise<AssetEntity[]> {
-    return await this.assetRepository.find({
-      where: [
-        { type: AssetType.VIDEO, encodedVideoPath: IsNull() },
-        { type: AssetType.VIDEO, encodedVideoPath: '' },
-      ],
-    });
-  }
-
-  async getAssetWithNoEXIF(): Promise<AssetEntity[]> {
-    return await this.assetRepository
-      .createQueryBuilder('asset')
-      .leftJoinAndSelect('asset.exifInfo', 'ei')
-      .where('ei."assetId" IS NULL')
-      .andWhere('asset.isVisible = true')
-      .getMany();
-  }
-
   async getAssetCountByUserId(ownerId: string): Promise<AssetCountByUserIdResponseDto> {
   async getAssetCountByUserId(ownerId: string): Promise<AssetCountByUserIdResponseDto> {
     // Get asset count by AssetType
     // Get asset count by AssetType
     const items = await this.assetRepository
     const items = await this.assetRepository

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

@@ -146,10 +146,6 @@ describe('AssetService', () => {
       getAssetByTimeBucket: jest.fn(),
       getAssetByTimeBucket: jest.fn(),
       getAssetByChecksum: jest.fn(),
       getAssetByChecksum: jest.fn(),
       getAssetCountByUserId: jest.fn(),
       getAssetCountByUserId: jest.fn(),
-      getAssetWithNoEXIF: jest.fn(),
-      getAssetWithNoThumbnail: jest.fn(),
-      getAssetWithNoSmartInfo: jest.fn(),
-      getAssetWithNoEncodedVideo: jest.fn(),
       getExistingAssets: jest.fn(),
       getExistingAssets: jest.fn(),
       countByIdAndUser: jest.fn(),
       countByIdAndUser: jest.fn(),
     };
     };

+ 1 - 1
server/apps/immich/src/api-v1/asset/asset.service.ts

@@ -63,7 +63,7 @@ import { AssetSearchDto } from './dto/asset-search.dto';
 import { AddAssetsDto } from '../album/dto/add-assets.dto';
 import { AddAssetsDto } from '../album/dto/add-assets.dto';
 import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
 import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
 import path from 'path';
 import path from 'path';
-import { getFileNameWithoutExtension } from '../../utils/file-name.util';
+import { getFileNameWithoutExtension } from '@app/domain';
 
 
 const fileInfo = promisify(stat);
 const fileInfo = promisify(stat);
 
 

+ 0 - 23
server/apps/immich/src/api-v1/job/dto/get-job.dto.ts

@@ -1,23 +0,0 @@
-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',
-  STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration',
-}
-
-export class GetJobDto {
-  @IsNotEmpty()
-  @IsEnum(JobId, {
-    message: `params must be one of ${Object.values(JobId).join()}`,
-  })
-  @ApiProperty({
-    type: String,
-    enum: JobId,
-    enumName: 'JobId',
-  })
-  jobId!: JobId;
-}

+ 0 - 16
server/apps/immich/src/api-v1/job/dto/job-command.dto.ts

@@ -1,16 +0,0 @@
-import { ApiProperty } from '@nestjs/swagger';
-import { IsBoolean, IsIn, IsNotEmpty, IsOptional } from 'class-validator';
-
-export class JobCommandDto {
-  @IsNotEmpty()
-  @IsIn(['start', 'stop'])
-  @ApiProperty({
-    enum: ['start', 'stop'],
-    enumName: 'JobCommand',
-  })
-  command!: string;
-
-  @IsOptional()
-  @IsBoolean()
-  includeAllAssets!: boolean;
-}

+ 0 - 33
server/apps/immich/src/api-v1/job/job.controller.ts

@@ -1,33 +0,0 @@
-import { Body, Controller, Get, Param, Put, ValidationPipe } from '@nestjs/common';
-import { ApiTags } from '@nestjs/swagger';
-import { Authenticated } from '../../decorators/authenticated.decorator';
-import { AllJobStatusResponseDto } from './response-dto/all-job-status-response.dto';
-import { GetJobDto } from './dto/get-job.dto';
-import { JobService } from './job.service';
-import { JobCommandDto } from './dto/job-command.dto';
-
-@Authenticated({ admin: true })
-@ApiTags('Job')
-@Controller('jobs')
-export class JobController {
-  constructor(private readonly jobService: JobService) {}
-
-  @Get()
-  getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
-    return this.jobService.getAllJobsStatus();
-  }
-
-  @Put('/:jobId')
-  async sendJobCommand(
-    @Param(ValidationPipe) params: GetJobDto,
-    @Body(ValidationPipe) dto: JobCommandDto,
-  ): Promise<number> {
-    if (dto.command === 'start') {
-      return await this.jobService.start(params.jobId, dto.includeAllAssets);
-    }
-    if (dto.command === 'stop') {
-      return await this.jobService.stop(params.jobId);
-    }
-    return 0;
-  }
-}

+ 0 - 11
server/apps/immich/src/api-v1/job/job.module.ts

@@ -1,11 +0,0 @@
-import { Module } from '@nestjs/common';
-import { JobService } from './job.service';
-import { JobController } from './job.controller';
-import { AssetModule } from '../asset/asset.module';
-
-@Module({
-  imports: [AssetModule],
-  controllers: [JobController],
-  providers: [JobService],
-})
-export class JobModule {}

+ 0 - 142
server/apps/immich/src/api-v1/job/job.service.ts

@@ -1,142 +0,0 @@
-import { JobName, IJobRepository, QueueName } from '@app/domain';
-import { BadRequestException, Inject, Injectable } from '@nestjs/common';
-import { AllJobStatusResponseDto } from './response-dto/all-job-status-response.dto';
-import { IAssetRepository } from '../asset/asset-repository';
-import { AssetType } from '@app/infra';
-import { JobId } from './dto/get-job.dto';
-import { MACHINE_LEARNING_ENABLED } from '@app/common';
-import { getFileNameWithoutExtension } from '../../utils/file-name.util';
-const jobIds = Object.values(JobId) as JobId[];
-
-@Injectable()
-export class JobService {
-  constructor(
-    @Inject(IAssetRepository) private _assetRepository: IAssetRepository,
-    @Inject(IJobRepository) private jobRepository: IJobRepository,
-  ) {
-    for (const jobId of jobIds) {
-      this.jobRepository.empty(this.asQueueName(jobId));
-    }
-  }
-
-  start(jobId: JobId, includeAllAssets: boolean): Promise<number> {
-    return this.run(this.asQueueName(jobId), includeAllAssets);
-  }
-
-  async stop(jobId: JobId): Promise<number> {
-    await this.jobRepository.empty(this.asQueueName(jobId));
-    return 0;
-  }
-
-  async getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
-    const response = new AllJobStatusResponseDto();
-    for (const jobId of jobIds) {
-      response[jobId] = await this.jobRepository.getJobCounts(this.asQueueName(jobId));
-    }
-    return response;
-  }
-
-  private async run(name: QueueName, includeAllAssets: boolean): Promise<number> {
-    const isActive = await this.jobRepository.isActive(name);
-    if (isActive) {
-      throw new BadRequestException(`Job is already running`);
-    }
-
-    switch (name) {
-      case QueueName.VIDEO_CONVERSION: {
-        const assets = includeAllAssets
-          ? await this._assetRepository.getAllVideos()
-          : await this._assetRepository.getAssetWithNoEncodedVideo();
-        for (const asset of assets) {
-          await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { asset } });
-        }
-
-        return assets.length;
-      }
-
-      case QueueName.STORAGE_TEMPLATE_MIGRATION:
-        await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION });
-        return 1;
-
-      case QueueName.MACHINE_LEARNING: {
-        if (!MACHINE_LEARNING_ENABLED) {
-          throw new BadRequestException('Machine learning is not enabled.');
-        }
-
-        const assets = includeAllAssets
-          ? await this._assetRepository.getAll()
-          : await this._assetRepository.getAssetWithNoSmartInfo();
-
-        for (const asset of assets) {
-          await this.jobRepository.queue({ name: JobName.IMAGE_TAGGING, data: { asset } });
-          await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, data: { asset } });
-          await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
-        }
-        return assets.length;
-      }
-
-      case QueueName.METADATA_EXTRACTION: {
-        const assets = includeAllAssets
-          ? await this._assetRepository.getAll()
-          : await this._assetRepository.getAssetWithNoEXIF();
-
-        for (const asset of assets) {
-          if (asset.type === AssetType.VIDEO) {
-            await this.jobRepository.queue({
-              name: JobName.EXTRACT_VIDEO_METADATA,
-              data: {
-                asset,
-                fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath),
-              },
-            });
-          } else {
-            await this.jobRepository.queue({
-              name: JobName.EXIF_EXTRACTION,
-              data: {
-                asset,
-                fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath),
-              },
-            });
-          }
-        }
-        return assets.length;
-      }
-
-      case QueueName.THUMBNAIL_GENERATION: {
-        const assets = includeAllAssets
-          ? await this._assetRepository.getAll()
-          : await this._assetRepository.getAssetWithNoThumbnail();
-
-        for (const asset of assets) {
-          await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } });
-        }
-        return assets.length;
-      }
-
-      default:
-        return 0;
-    }
-  }
-
-  private asQueueName(jobId: JobId) {
-    switch (jobId) {
-      case JobId.THUMBNAIL_GENERATION:
-        return QueueName.THUMBNAIL_GENERATION;
-
-      case JobId.METADATA_EXTRACTION:
-        return QueueName.METADATA_EXTRACTION;
-
-      case JobId.VIDEO_CONVERSION:
-        return QueueName.VIDEO_CONVERSION;
-
-      case JobId.STORAGE_TEMPLATE_MIGRATION:
-        return QueueName.STORAGE_TEMPLATE_MIGRATION;
-
-      case JobId.MACHINE_LEARNING:
-        return QueueName.MACHINE_LEARNING;
-
-      default:
-        throw new BadRequestException(`Invalid job id: ${jobId}`);
-    }
-  }
-}

+ 0 - 32
server/apps/immich/src/api-v1/job/response-dto/all-job-status-response.dto.ts

@@ -1,32 +0,0 @@
-import { ApiProperty } from '@nestjs/swagger';
-import { JobId } from '../dto/get-job.dto';
-
-export class JobCounts {
-  @ApiProperty({ type: 'integer' })
-  active!: number;
-  @ApiProperty({ type: 'integer' })
-  completed!: number;
-  @ApiProperty({ type: 'integer' })
-  failed!: number;
-  @ApiProperty({ type: 'integer' })
-  delayed!: number;
-  @ApiProperty({ type: 'integer' })
-  waiting!: number;
-}
-
-export class AllJobStatusResponseDto {
-  @ApiProperty({ type: JobCounts })
-  [JobId.THUMBNAIL_GENERATION]!: JobCounts;
-
-  @ApiProperty({ type: JobCounts })
-  [JobId.METADATA_EXTRACTION]!: JobCounts;
-
-  @ApiProperty({ type: JobCounts })
-  [JobId.VIDEO_CONVERSION]!: JobCounts;
-
-  @ApiProperty({ type: JobCounts })
-  [JobId.MACHINE_LEARNING]!: JobCounts;
-
-  @ApiProperty({ type: JobCounts })
-  [JobId.STORAGE_TEMPLATE_MIGRATION]!: JobCounts;
-}

+ 2 - 3
server/apps/immich/src/app.module.ts

@@ -7,7 +7,6 @@ import { AlbumModule } from './api-v1/album/album.module';
 import { AppController } from './app.controller';
 import { AppController } from './app.controller';
 import { ScheduleModule } from '@nestjs/schedule';
 import { ScheduleModule } from '@nestjs/schedule';
 import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
 import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
-import { JobModule } from './api-v1/job/job.module';
 import { TagModule } from './api-v1/tag/tag.module';
 import { TagModule } from './api-v1/tag/tag.module';
 import { DomainModule, SearchService } from '@app/domain';
 import { DomainModule, SearchService } from '@app/domain';
 import { InfraModule } from '@app/infra';
 import { InfraModule } from '@app/infra';
@@ -15,6 +14,7 @@ import {
   APIKeyController,
   APIKeyController,
   AuthController,
   AuthController,
   DeviceInfoController,
   DeviceInfoController,
+  JobController,
   OAuthController,
   OAuthController,
   SearchController,
   SearchController,
   ShareController,
   ShareController,
@@ -42,8 +42,6 @@ import { AuthGuard } from './middlewares/auth.guard';
 
 
     ScheduleTasksModule,
     ScheduleTasksModule,
 
 
-    JobModule,
-
     TagModule,
     TagModule,
   ],
   ],
   controllers: [
   controllers: [
@@ -51,6 +49,7 @@ import { AuthGuard } from './middlewares/auth.guard';
     APIKeyController,
     APIKeyController,
     AuthController,
     AuthController,
     DeviceInfoController,
     DeviceInfoController,
+    JobController,
     OAuthController,
     OAuthController,
     SearchController,
     SearchController,
     ShareController,
     ShareController,

+ 1 - 0
server/apps/immich/src/controllers/index.ts

@@ -1,6 +1,7 @@
 export * from './api-key.controller';
 export * from './api-key.controller';
 export * from './auth.controller';
 export * from './auth.controller';
 export * from './device-info.controller';
 export * from './device-info.controller';
+export * from './job.controller';
 export * from './oauth.controller';
 export * from './oauth.controller';
 export * from './search.controller';
 export * from './search.controller';
 export * from './share.controller';
 export * from './share.controller';

+ 21 - 0
server/apps/immich/src/controllers/job.controller.ts

@@ -0,0 +1,21 @@
+import { AllJobStatusResponseDto, JobCommandDto, JobIdDto, JobService } from '@app/domain';
+import { Body, Controller, Get, Param, Put, ValidationPipe } from '@nestjs/common';
+import { ApiTags } from '@nestjs/swagger';
+import { Authenticated } from '../decorators/authenticated.decorator';
+
+@Authenticated({ admin: true })
+@ApiTags('Job')
+@Controller('jobs')
+export class JobController {
+  constructor(private readonly jobService: JobService) {}
+
+  @Get()
+  getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
+    return this.jobService.getAllJobsStatus();
+  }
+
+  @Put('/:jobId')
+  sendJobCommand(@Param(ValidationPipe) { jobId }: JobIdDto, @Body(ValidationPipe) dto: JobCommandDto): Promise<void> {
+    return this.jobService.handleCommand(jobId, dto);
+  }
+}

+ 4 - 2
server/apps/microservices/src/microservices.module.ts

@@ -6,7 +6,8 @@ import { ConfigModule } from '@nestjs/config';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import {
 import {
   BackgroundTaskProcessor,
   BackgroundTaskProcessor,
-  MachineLearningProcessor,
+  ClipEncodingProcessor,
+  ObjectTaggingProcessor,
   SearchIndexProcessor,
   SearchIndexProcessor,
   StorageTemplateMigrationProcessor,
   StorageTemplateMigrationProcessor,
   ThumbnailGeneratorProcessor,
   ThumbnailGeneratorProcessor,
@@ -24,7 +25,8 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
     ThumbnailGeneratorProcessor,
     ThumbnailGeneratorProcessor,
     MetadataExtractionProcessor,
     MetadataExtractionProcessor,
     VideoTranscodeProcessor,
     VideoTranscodeProcessor,
-    MachineLearningProcessor,
+    ObjectTaggingProcessor,
+    ClipEncodingProcessor,
     StorageTemplateMigrationProcessor,
     StorageTemplateMigrationProcessor,
     BackgroundTaskProcessor,
     BackgroundTaskProcessor,
     SearchIndexProcessor,
     SearchIndexProcessor,

+ 28 - 7
server/apps/microservices/src/processors.ts

@@ -2,6 +2,7 @@ import {
   AssetService,
   AssetService,
   IAssetJob,
   IAssetJob,
   IAssetUploadedJob,
   IAssetUploadedJob,
+  IBaseJob,
   IBulkEntityJob,
   IBulkEntityJob,
   IDeleteFilesJob,
   IDeleteFilesJob,
   IUserDeletionJob,
   IUserDeletionJob,
@@ -48,20 +49,35 @@ export class BackgroundTaskProcessor {
   }
   }
 }
 }
 
 
-@Processor(QueueName.MACHINE_LEARNING)
-export class MachineLearningProcessor {
+@Processor(QueueName.OBJECT_TAGGING)
+export class ObjectTaggingProcessor {
   constructor(private smartInfoService: SmartInfoService) {}
   constructor(private smartInfoService: SmartInfoService) {}
 
 
-  @Process({ name: JobName.IMAGE_TAGGING, concurrency: 1 })
-  async onTagImage(job: Job<IAssetJob>) {
-    await this.smartInfoService.handleTagImage(job.data);
+  @Process({ name: JobName.QUEUE_OBJECT_TAGGING, concurrency: 1 })
+  async onQueueObjectTagging(job: Job<IBaseJob>) {
+    await this.smartInfoService.handleQueueObjectTagging(job.data);
   }
   }
 
 
-  @Process({ name: JobName.OBJECT_DETECTION, concurrency: 1 })
-  async onDetectObject(job: Job<IAssetJob>) {
+  @Process({ name: JobName.DETECT_OBJECTS, concurrency: 1 })
+  async onDetectObjects(job: Job<IAssetJob>) {
     await this.smartInfoService.handleDetectObjects(job.data);
     await this.smartInfoService.handleDetectObjects(job.data);
   }
   }
 
 
+  @Process({ name: JobName.CLASSIFY_IMAGE, concurrency: 1 })
+  async onClassifyImage(job: Job<IAssetJob>) {
+    await this.smartInfoService.handleClassifyImage(job.data);
+  }
+}
+
+@Processor(QueueName.CLIP_ENCODING)
+export class ClipEncodingProcessor {
+  constructor(private smartInfoService: SmartInfoService) {}
+
+  @Process({ name: JobName.QUEUE_ENCODE_CLIP, concurrency: 1 })
+  async onQueueClipEncoding(job: Job<IBaseJob>) {
+    await this.smartInfoService.handleQueueEncodeClip(job.data);
+  }
+
   @Process({ name: JobName.ENCODE_CLIP, concurrency: 1 })
   @Process({ name: JobName.ENCODE_CLIP, concurrency: 1 })
   async onEncodeClip(job: Job<IAssetJob>) {
   async onEncodeClip(job: Job<IAssetJob>) {
     await this.smartInfoService.handleEncodeClip(job.data);
     await this.smartInfoService.handleEncodeClip(job.data);
@@ -117,6 +133,11 @@ export class StorageTemplateMigrationProcessor {
 export class ThumbnailGeneratorProcessor {
 export class ThumbnailGeneratorProcessor {
   constructor(private mediaService: MediaService) {}
   constructor(private mediaService: MediaService) {}
 
 
+  @Process({ name: JobName.QUEUE_GENERATE_THUMBNAILS, concurrency: 1 })
+  async handleQueueGenerateThumbnails(job: Job<IBaseJob>) {
+    await this.mediaService.handleQueueGenerateThumbnails(job.data);
+  }
+
   @Process({ name: JobName.GENERATE_JPEG_THUMBNAIL, concurrency: 3 })
   @Process({ name: JobName.GENERATE_JPEG_THUMBNAIL, concurrency: 3 })
   async handleGenerateJpegThumbnail(job: Job<IAssetJob>) {
   async handleGenerateJpegThumbnail(job: Job<IAssetJob>) {
     await this.mediaService.handleGenerateJpegThumbnail(job.data);
     await this.mediaService.handleGenerateJpegThumbnail(job.data);

+ 23 - 2
server/apps/microservices/src/processors/metadata-extraction.processor.ts

@@ -1,11 +1,14 @@
 import {
 import {
   AssetCore,
   AssetCore,
+  getFileNameWithoutExtension,
   IAssetRepository,
   IAssetRepository,
   IAssetUploadedJob,
   IAssetUploadedJob,
+  IBaseJob,
   IJobRepository,
   IJobRepository,
   IReverseGeocodingJob,
   IReverseGeocodingJob,
   JobName,
   JobName,
   QueueName,
   QueueName,
+  WithoutProperty,
 } from '@app/domain';
 } from '@app/domain';
 import { AssetEntity, AssetType, ExifEntity } from '@app/infra';
 import { AssetEntity, AssetType, ExifEntity } from '@app/infra';
 import { Process, Processor } from '@nestjs/bull';
 import { Process, Processor } from '@nestjs/bull';
@@ -85,8 +88,8 @@ export class MetadataExtractionProcessor {
   private assetCore: AssetCore;
   private assetCore: AssetCore;
 
 
   constructor(
   constructor(
-    @Inject(IAssetRepository) assetRepository: IAssetRepository,
-    @Inject(IJobRepository) jobRepository: IJobRepository,
+    @Inject(IAssetRepository) private assetRepository: IAssetRepository,
+    @Inject(IJobRepository) private jobRepository: IJobRepository,
 
 
     @InjectRepository(ExifEntity)
     @InjectRepository(ExifEntity)
     private exifRepository: Repository<ExifEntity>,
     private exifRepository: Repository<ExifEntity>,
@@ -148,6 +151,24 @@ export class MetadataExtractionProcessor {
     return { country, state, city };
     return { country, state, city };
   }
   }
 
 
+  @Process(JobName.QUEUE_METADATA_EXTRACTION)
+  async handleQueueMetadataExtraction(job: Job<IBaseJob>) {
+    try {
+      const { force } = job.data;
+      const assets = force
+        ? await this.assetRepository.getAll()
+        : await this.assetRepository.getWithout(WithoutProperty.EXIF);
+
+      for (const asset of assets) {
+        const fileName = asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath);
+        const name = asset.type === AssetType.VIDEO ? JobName.EXTRACT_VIDEO_METADATA : JobName.EXIF_EXTRACTION;
+        await this.jobRepository.queue({ name, data: { asset, fileName } });
+      }
+    } catch (error: any) {
+      this.logger.error(`Unable to queue metadata extraction`, error?.stack);
+    }
+  }
+
   @Process(JobName.EXIF_EXTRACTION)
   @Process(JobName.EXIF_EXTRACTION)
   async extractExifInfo(job: Job<IAssetUploadedJob>) {
   async extractExifInfo(job: Job<IAssetUploadedJob>) {
     try {
     try {

+ 28 - 3
server/apps/microservices/src/processors/video-transcode.processor.ts

@@ -1,6 +1,15 @@
 import { APP_UPLOAD_LOCATION } from '@app/common/constants';
 import { APP_UPLOAD_LOCATION } from '@app/common/constants';
-import { AssetEntity } from '@app/infra';
-import { IAssetJob, IAssetRepository, JobName, QueueName, SystemConfigService } from '@app/domain';
+import { AssetEntity, AssetType } from '@app/infra';
+import {
+  IAssetJob,
+  IAssetRepository,
+  IBaseJob,
+  IJobRepository,
+  JobName,
+  QueueName,
+  SystemConfigService,
+  WithoutProperty,
+} from '@app/domain';
 import { Process, Processor } from '@nestjs/bull';
 import { Process, Processor } from '@nestjs/bull';
 import { Inject, Logger } from '@nestjs/common';
 import { Inject, Logger } from '@nestjs/common';
 import { Job } from 'bull';
 import { Job } from 'bull';
@@ -12,11 +21,27 @@ export class VideoTranscodeProcessor {
   readonly logger = new Logger(VideoTranscodeProcessor.name);
   readonly logger = new Logger(VideoTranscodeProcessor.name);
   constructor(
   constructor(
     @Inject(IAssetRepository) private assetRepository: IAssetRepository,
     @Inject(IAssetRepository) private assetRepository: IAssetRepository,
+    @Inject(IJobRepository) private jobRepository: IJobRepository,
     private systemConfigService: SystemConfigService,
     private systemConfigService: SystemConfigService,
   ) {}
   ) {}
 
 
+  @Process({ name: JobName.QUEUE_VIDEO_CONVERSION, concurrency: 1 })
+  async handleQueueVideoConversion(job: Job<IBaseJob>): Promise<void> {
+    try {
+      const { force } = job.data;
+      const assets = force
+        ? await this.assetRepository.getAll({ type: AssetType.VIDEO })
+        : await this.assetRepository.getWithout(WithoutProperty.ENCODED_VIDEO);
+      for (const asset of assets) {
+        await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { asset } });
+      }
+    } catch (error: any) {
+      this.logger.error('Failed to queue video conversions', error.stack);
+    }
+  }
+
   @Process({ name: JobName.VIDEO_CONVERSION, concurrency: 2 })
   @Process({ name: JobName.VIDEO_CONVERSION, concurrency: 2 })
-  async videoConversion(job: Job<IAssetJob>) {
+  async handleVideoConversion(job: Job<IAssetJob>) {
     const { asset } = job.data;
     const { asset } = job.data;
     const basePath = APP_UPLOAD_LOCATION;
     const basePath = APP_UPLOAD_LOCATION;
     const encodedVideoPath = `${basePath}/${asset.ownerId}/encoded-video`;
     const encodedVideoPath = `${basePath}/${asset.ownerId}/encoded-video`;

+ 174 - 165
server/immich-openapi-specs.json

@@ -395,6 +395,78 @@
         ]
         ]
       }
       }
     },
     },
+    "/jobs": {
+      "get": {
+        "operationId": "getAllJobsStatus",
+        "description": "",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/AllJobStatusResponseDto"
+                }
+              }
+            }
+          }
+        },
+        "tags": [
+          "Job"
+        ],
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          }
+        ]
+      }
+    },
+    "/jobs/{jobId}": {
+      "put": {
+        "operationId": "sendJobCommand",
+        "description": "",
+        "parameters": [
+          {
+            "name": "jobId",
+            "required": true,
+            "in": "path",
+            "schema": {
+              "$ref": "#/components/schemas/JobName"
+            }
+          }
+        ],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/JobCommandDto"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": ""
+          }
+        },
+        "tags": [
+          "Job"
+        ],
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          }
+        ]
+      }
+    },
     "/oauth/mobile-redirect": {
     "/oauth/mobile-redirect": {
       "get": {
       "get": {
         "operationId": "mobileRedirect",
         "operationId": "mobileRedirect",
@@ -3169,85 +3241,6 @@
           }
           }
         ]
         ]
       }
       }
-    },
-    "/jobs": {
-      "get": {
-        "operationId": "getAllJobsStatus",
-        "description": "",
-        "parameters": [],
-        "responses": {
-          "200": {
-            "description": "",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "$ref": "#/components/schemas/AllJobStatusResponseDto"
-                }
-              }
-            }
-          }
-        },
-        "tags": [
-          "Job"
-        ],
-        "security": [
-          {
-            "bearer": []
-          },
-          {
-            "cookie": []
-          }
-        ]
-      }
-    },
-    "/jobs/{jobId}": {
-      "put": {
-        "operationId": "sendJobCommand",
-        "description": "",
-        "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": []
-          },
-          {
-            "cookie": []
-          }
-        ]
-      }
     }
     }
   },
   },
   "info": {
   "info": {
@@ -3604,6 +3597,108 @@
           "isAutoBackup"
           "isAutoBackup"
         ]
         ]
       },
       },
+      "JobCountsDto": {
+        "type": "object",
+        "properties": {
+          "active": {
+            "type": "integer"
+          },
+          "completed": {
+            "type": "integer"
+          },
+          "failed": {
+            "type": "integer"
+          },
+          "delayed": {
+            "type": "integer"
+          },
+          "waiting": {
+            "type": "integer"
+          }
+        },
+        "required": [
+          "active",
+          "completed",
+          "failed",
+          "delayed",
+          "waiting"
+        ]
+      },
+      "AllJobStatusResponseDto": {
+        "type": "object",
+        "properties": {
+          "thumbnail-generation-queue": {
+            "$ref": "#/components/schemas/JobCountsDto"
+          },
+          "metadata-extraction-queue": {
+            "$ref": "#/components/schemas/JobCountsDto"
+          },
+          "video-conversion-queue": {
+            "$ref": "#/components/schemas/JobCountsDto"
+          },
+          "object-tagging-queue": {
+            "$ref": "#/components/schemas/JobCountsDto"
+          },
+          "clip-encoding-queue": {
+            "$ref": "#/components/schemas/JobCountsDto"
+          },
+          "storage-template-migration-queue": {
+            "$ref": "#/components/schemas/JobCountsDto"
+          },
+          "background-task-queue": {
+            "$ref": "#/components/schemas/JobCountsDto"
+          },
+          "search-queue": {
+            "$ref": "#/components/schemas/JobCountsDto"
+          }
+        },
+        "required": [
+          "thumbnail-generation-queue",
+          "metadata-extraction-queue",
+          "video-conversion-queue",
+          "object-tagging-queue",
+          "clip-encoding-queue",
+          "storage-template-migration-queue",
+          "background-task-queue",
+          "search-queue"
+        ]
+      },
+      "JobName": {
+        "type": "string",
+        "enum": [
+          "thumbnail-generation-queue",
+          "metadata-extraction-queue",
+          "video-conversion-queue",
+          "object-tagging-queue",
+          "clip-encoding-queue",
+          "background-task-queue",
+          "storage-template-migration-queue",
+          "search-queue"
+        ]
+      },
+      "JobCommand": {
+        "type": "string",
+        "enum": [
+          "start",
+          "pause",
+          "empty"
+        ]
+      },
+      "JobCommandDto": {
+        "type": "object",
+        "properties": {
+          "command": {
+            "$ref": "#/components/schemas/JobCommand"
+          },
+          "force": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "command",
+          "force"
+        ]
+      },
       "OAuthConfigDto": {
       "OAuthConfigDto": {
         "type": "object",
         "type": "object",
         "properties": {
         "properties": {
@@ -5193,92 +5288,6 @@
           "usage",
           "usage",
           "usageByUser"
           "usageByUser"
         ]
         ]
-      },
-      "JobCounts": {
-        "type": "object",
-        "properties": {
-          "active": {
-            "type": "integer"
-          },
-          "completed": {
-            "type": "integer"
-          },
-          "failed": {
-            "type": "integer"
-          },
-          "delayed": {
-            "type": "integer"
-          },
-          "waiting": {
-            "type": "integer"
-          }
-        },
-        "required": [
-          "active",
-          "completed",
-          "failed",
-          "delayed",
-          "waiting"
-        ]
-      },
-      "AllJobStatusResponseDto": {
-        "type": "object",
-        "properties": {
-          "thumbnail-generation": {
-            "$ref": "#/components/schemas/JobCounts"
-          },
-          "metadata-extraction": {
-            "$ref": "#/components/schemas/JobCounts"
-          },
-          "video-conversion": {
-            "$ref": "#/components/schemas/JobCounts"
-          },
-          "machine-learning": {
-            "$ref": "#/components/schemas/JobCounts"
-          },
-          "storage-template-migration": {
-            "$ref": "#/components/schemas/JobCounts"
-          }
-        },
-        "required": [
-          "thumbnail-generation",
-          "metadata-extraction",
-          "video-conversion",
-          "machine-learning",
-          "storage-template-migration"
-        ]
-      },
-      "JobId": {
-        "type": "string",
-        "enum": [
-          "thumbnail-generation",
-          "metadata-extraction",
-          "video-conversion",
-          "machine-learning",
-          "storage-template-migration"
-        ]
-      },
-      "JobCommand": {
-        "type": "string",
-        "enum": [
-          "start",
-          "stop"
-        ]
-      },
-      "JobCommandDto": {
-        "type": "object",
-        "properties": {
-          "command": {
-            "$ref": "#/components/schemas/JobCommand"
-          },
-          "includeAllAssets": {
-            "type": "boolean"
-          }
-        },
-        "required": [
-          "command",
-          "includeAllAssets"
-        ]
       }
       }
     }
     }
   }
   }

+ 8 - 0
server/libs/common/src/constants/index.ts

@@ -1,4 +1,12 @@
+import { BadRequestException } from '@nestjs/common';
+
 export * from './upload_location.constant';
 export * from './upload_location.constant';
 
 
 export const MACHINE_LEARNING_URL = process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003';
 export const MACHINE_LEARNING_URL = process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003';
 export const MACHINE_LEARNING_ENABLED = MACHINE_LEARNING_URL !== 'false';
 export const MACHINE_LEARNING_ENABLED = MACHINE_LEARNING_URL !== 'false';
+
+export function assertMachineLearningEnabled() {
+  if (!MACHINE_LEARNING_ENABLED) {
+    throw new BadRequestException('Machine learning is not enabled.');
+  }
+}

+ 10 - 0
server/libs/domain/src/asset/asset.repository.ts

@@ -2,12 +2,22 @@ import { AssetEntity, AssetType } from '@app/infra/db/entities';
 
 
 export interface AssetSearchOptions {
 export interface AssetSearchOptions {
   isVisible?: boolean;
   isVisible?: boolean;
+  type?: AssetType;
+}
+
+export enum WithoutProperty {
+  THUMBNAIL = 'thumbnail',
+  ENCODED_VIDEO = 'encoded-video',
+  EXIF = 'exif',
+  CLIP_ENCODING = 'clip-embedding',
+  OBJECT_TAGS = 'object-tags',
 }
 }
 
 
 export const IAssetRepository = 'IAssetRepository';
 export const IAssetRepository = 'IAssetRepository';
 
 
 export interface IAssetRepository {
 export interface IAssetRepository {
   getByIds(ids: string[]): Promise<AssetEntity[]>;
   getByIds(ids: string[]): Promise<AssetEntity[]>;
+  getWithout(property: WithoutProperty): Promise<AssetEntity[]>;
   deleteAll(ownerId: string): Promise<void>;
   deleteAll(ownerId: string): Promise<void>;
   getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>;
   getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>;
   save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
   save(asset: Partial<AssetEntity>): Promise<AssetEntity>;

+ 2 - 0
server/libs/domain/src/domain.module.ts

@@ -3,6 +3,7 @@ import { APIKeyService } from './api-key';
 import { AssetService } from './asset';
 import { AssetService } from './asset';
 import { AuthService } from './auth';
 import { AuthService } from './auth';
 import { DeviceInfoService } from './device-info';
 import { DeviceInfoService } from './device-info';
+import { JobService } from './job';
 import { MediaService } from './media';
 import { MediaService } from './media';
 import { OAuthService } from './oauth';
 import { OAuthService } from './oauth';
 import { SearchService } from './search';
 import { SearchService } from './search';
@@ -18,6 +19,7 @@ const providers: Provider[] = [
   APIKeyService,
   APIKeyService,
   AuthService,
   AuthService,
   DeviceInfoService,
   DeviceInfoService,
+  JobService,
   MediaService,
   MediaService,
   OAuthService,
   OAuthService,
   SmartInfoService,
   SmartInfoService,

+ 1 - 0
server/libs/domain/src/index.ts

@@ -18,3 +18,4 @@ export * from './system-config';
 export * from './tag';
 export * from './tag';
 export * from './user';
 export * from './user';
 export * from './user-token';
 export * from './user-token';
+export * from './util';

+ 2 - 0
server/libs/domain/src/job/dto/index.ts

@@ -0,0 +1,2 @@
+export * from './job-command.dto';
+export * from './job-id.dto';

+ 14 - 0
server/libs/domain/src/job/dto/job-command.dto.ts

@@ -0,0 +1,14 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsBoolean, IsEnum, IsNotEmpty, IsOptional } from 'class-validator';
+import { JobCommand } from '../job.constants';
+
+export class JobCommandDto {
+  @IsNotEmpty()
+  @IsEnum(JobCommand)
+  @ApiProperty({ type: 'string', enum: JobCommand, enumName: 'JobCommand' })
+  command!: JobCommand;
+
+  @IsOptional()
+  @IsBoolean()
+  force!: boolean;
+}

+ 10 - 0
server/libs/domain/src/job/dto/job-id.dto.ts

@@ -0,0 +1,10 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsEnum, IsNotEmpty } from 'class-validator';
+import { QueueName } from '../job.constants';
+
+export class JobIdDto {
+  @IsNotEmpty()
+  @IsEnum(QueueName)
+  @ApiProperty({ type: String, enum: QueueName, enumName: 'JobName' })
+  jobId!: QueueName;
+}

+ 3 - 0
server/libs/domain/src/job/index.ts

@@ -1,3 +1,6 @@
+export * from './dto';
 export * from './job.constants';
 export * from './job.constants';
 export * from './job.interface';
 export * from './job.interface';
 export * from './job.repository';
 export * from './job.repository';
+export * from './job.service';
+export * from './response-dto';

+ 36 - 5
server/libs/domain/src/job/job.constants.ts

@@ -2,32 +2,63 @@ export enum QueueName {
   THUMBNAIL_GENERATION = 'thumbnail-generation-queue',
   THUMBNAIL_GENERATION = 'thumbnail-generation-queue',
   METADATA_EXTRACTION = 'metadata-extraction-queue',
   METADATA_EXTRACTION = 'metadata-extraction-queue',
   VIDEO_CONVERSION = 'video-conversion-queue',
   VIDEO_CONVERSION = 'video-conversion-queue',
-  MACHINE_LEARNING = 'machine-learning-queue',
-  BACKGROUND_TASK = 'background-task',
+  OBJECT_TAGGING = 'object-tagging-queue',
+  CLIP_ENCODING = 'clip-encoding-queue',
+  BACKGROUND_TASK = 'background-task-queue',
   STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue',
   STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue',
   SEARCH = 'search-queue',
   SEARCH = 'search-queue',
 }
 }
 
 
+export enum JobCommand {
+  START = 'start',
+  PAUSE = 'pause',
+  EMPTY = 'empty',
+}
+
 export enum JobName {
 export enum JobName {
+  // upload
   ASSET_UPLOADED = 'asset-uploaded',
   ASSET_UPLOADED = 'asset-uploaded',
-  VIDEO_CONVERSION = 'mp4-conversion',
+
+  // conversion
+  QUEUE_VIDEO_CONVERSION = 'queue-video-conversion',
+  VIDEO_CONVERSION = 'video-conversion',
+
+  // thumbnails
+  QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails',
   GENERATE_JPEG_THUMBNAIL = 'generate-jpeg-thumbnail',
   GENERATE_JPEG_THUMBNAIL = 'generate-jpeg-thumbnail',
   GENERATE_WEBP_THUMBNAIL = 'generate-webp-thumbnail',
   GENERATE_WEBP_THUMBNAIL = 'generate-webp-thumbnail',
+
+  // metadata
+  QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction',
   EXIF_EXTRACTION = 'exif-extraction',
   EXIF_EXTRACTION = 'exif-extraction',
   EXTRACT_VIDEO_METADATA = 'extract-video-metadata',
   EXTRACT_VIDEO_METADATA = 'extract-video-metadata',
   REVERSE_GEOCODING = 'reverse-geocoding',
   REVERSE_GEOCODING = 'reverse-geocoding',
+
+  // user deletion
   USER_DELETION = 'user-deletion',
   USER_DELETION = 'user-deletion',
   USER_DELETE_CHECK = 'user-delete-check',
   USER_DELETE_CHECK = 'user-delete-check',
+
+  // storage template
   STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration',
   STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration',
   SYSTEM_CONFIG_CHANGE = 'system-config-change',
   SYSTEM_CONFIG_CHANGE = 'system-config-change',
-  OBJECT_DETECTION = 'detect-object',
-  IMAGE_TAGGING = 'tag-image',
+
+  // object tagging
+  QUEUE_OBJECT_TAGGING = 'queue-object-tagging',
+  DETECT_OBJECTS = 'detect-objects',
+  CLASSIFY_IMAGE = 'classify-image',
+
+  // cleanup
   DELETE_FILES = 'delete-files',
   DELETE_FILES = 'delete-files',
+
+  // search
   SEARCH_INDEX_ASSETS = 'search-index-assets',
   SEARCH_INDEX_ASSETS = 'search-index-assets',
   SEARCH_INDEX_ASSET = 'search-index-asset',
   SEARCH_INDEX_ASSET = 'search-index-asset',
   SEARCH_INDEX_ALBUMS = 'search-index-albums',
   SEARCH_INDEX_ALBUMS = 'search-index-albums',
   SEARCH_INDEX_ALBUM = 'search-index-album',
   SEARCH_INDEX_ALBUM = 'search-index-album',
   SEARCH_REMOVE_ALBUM = 'search-remove-album',
   SEARCH_REMOVE_ALBUM = 'search-remove-album',
   SEARCH_REMOVE_ASSET = 'search-remove-asset',
   SEARCH_REMOVE_ASSET = 'search-remove-asset',
+
+  // clip
+  QUEUE_ENCODE_CLIP = 'queue-clip-encode',
   ENCODE_CLIP = 'clip-encode',
   ENCODE_CLIP = 'clip-encode',
 }
 }

+ 11 - 7
server/libs/domain/src/job/job.interface.ts

@@ -1,31 +1,35 @@
 import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/db/entities';
 import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/db/entities';
 
 
-export interface IAlbumJob {
+export interface IBaseJob {
+  force?: boolean;
+}
+
+export interface IAlbumJob extends IBaseJob {
   album: AlbumEntity;
   album: AlbumEntity;
 }
 }
 
 
-export interface IAssetJob {
+export interface IAssetJob extends IBaseJob {
   asset: AssetEntity;
   asset: AssetEntity;
 }
 }
 
 
-export interface IBulkEntityJob {
+export interface IBulkEntityJob extends IBaseJob {
   ids: string[];
   ids: string[];
 }
 }
 
 
-export interface IAssetUploadedJob {
+export interface IAssetUploadedJob extends IBaseJob {
   asset: AssetEntity;
   asset: AssetEntity;
   fileName: string;
   fileName: string;
 }
 }
 
 
-export interface IDeleteFilesJob {
+export interface IDeleteFilesJob extends IBaseJob {
   files: Array<string | null | undefined>;
   files: Array<string | null | undefined>;
 }
 }
 
 
-export interface IUserDeletionJob {
+export interface IUserDeletionJob extends IBaseJob {
   user: UserEntity;
   user: UserEntity;
 }
 }
 
 
-export interface IReverseGeocodingJob {
+export interface IReverseGeocodingJob extends IBaseJob {
   assetId: string;
   assetId: string;
   latitude: number;
   latitude: number;
   longitude: number;
   longitude: number;

+ 30 - 4
server/libs/domain/src/job/job.repository.ts

@@ -2,6 +2,7 @@ import { JobName, QueueName } from './job.constants';
 import {
 import {
   IAssetJob,
   IAssetJob,
   IAssetUploadedJob,
   IAssetUploadedJob,
+  IBaseJob,
   IBulkEntityJob,
   IBulkEntityJob,
   IDeleteFilesJob,
   IDeleteFilesJob,
   IReverseGeocodingJob,
   IReverseGeocodingJob,
@@ -17,21 +18,45 @@ export interface JobCounts {
 }
 }
 
 
 export type JobItem =
 export type JobItem =
+  // Asset Upload
   | { name: JobName.ASSET_UPLOADED; data: IAssetUploadedJob }
   | { name: JobName.ASSET_UPLOADED; data: IAssetUploadedJob }
+
+  // Transcoding
+  | { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob }
   | { name: JobName.VIDEO_CONVERSION; data: IAssetJob }
   | { name: JobName.VIDEO_CONVERSION; data: IAssetJob }
+
+  // Thumbnails
+  | { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob }
   | { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IAssetJob }
   | { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IAssetJob }
   | { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IAssetJob }
   | { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IAssetJob }
-  | { name: JobName.EXIF_EXTRACTION; data: IAssetUploadedJob }
-  | { name: JobName.REVERSE_GEOCODING; data: IReverseGeocodingJob }
+
+  // User Deletion
   | { name: JobName.USER_DELETE_CHECK }
   | { name: JobName.USER_DELETE_CHECK }
   | { name: JobName.USER_DELETION; data: IUserDeletionJob }
   | { name: JobName.USER_DELETION; data: IUserDeletionJob }
+
+  // Storage Template
   | { name: JobName.STORAGE_TEMPLATE_MIGRATION }
   | { name: JobName.STORAGE_TEMPLATE_MIGRATION }
   | { name: JobName.SYSTEM_CONFIG_CHANGE }
   | { name: JobName.SYSTEM_CONFIG_CHANGE }
+
+  // Metadata Extraction
+  | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
+  | { name: JobName.EXIF_EXTRACTION; data: IAssetUploadedJob }
   | { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetUploadedJob }
   | { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetUploadedJob }
-  | { name: JobName.OBJECT_DETECTION; data: IAssetJob }
-  | { name: JobName.IMAGE_TAGGING; data: IAssetJob }
+  | { name: JobName.REVERSE_GEOCODING; data: IReverseGeocodingJob }
+
+  // Object Tagging
+  | { name: JobName.QUEUE_OBJECT_TAGGING; data: IBaseJob }
+  | { name: JobName.DETECT_OBJECTS; data: IAssetJob }
+  | { name: JobName.CLASSIFY_IMAGE; data: IAssetJob }
+
+  // Clip Embedding
+  | { name: JobName.QUEUE_ENCODE_CLIP; data: IBaseJob }
   | { name: JobName.ENCODE_CLIP; data: IAssetJob }
   | { name: JobName.ENCODE_CLIP; data: IAssetJob }
+
+  // Filesystem
   | { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
   | { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
+
+  // Search
   | { name: JobName.SEARCH_INDEX_ASSETS }
   | { name: JobName.SEARCH_INDEX_ASSETS }
   | { name: JobName.SEARCH_INDEX_ASSET; data: IBulkEntityJob }
   | { name: JobName.SEARCH_INDEX_ASSET; data: IBulkEntityJob }
   | { name: JobName.SEARCH_INDEX_ALBUMS }
   | { name: JobName.SEARCH_INDEX_ALBUMS }
@@ -43,6 +68,7 @@ export const IJobRepository = 'IJobRepository';
 
 
 export interface IJobRepository {
 export interface IJobRepository {
   queue(item: JobItem): Promise<void>;
   queue(item: JobItem): Promise<void>;
+  pause(name: QueueName): Promise<void>;
   empty(name: QueueName): Promise<void>;
   empty(name: QueueName): Promise<void>;
   isActive(name: QueueName): Promise<boolean>;
   isActive(name: QueueName): Promise<boolean>;
   getJobCounts(name: QueueName): Promise<JobCounts>;
   getJobCounts(name: QueueName): Promise<JobCounts>;

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

@@ -0,0 +1,170 @@
+import { BadRequestException } from '@nestjs/common';
+import { newJobRepositoryMock } from '../../test';
+import { IJobRepository, JobCommand, JobName, JobService, QueueName } from '../job';
+
+describe(JobService.name, () => {
+  let sut: JobService;
+  let jobMock: jest.Mocked<IJobRepository>;
+
+  beforeEach(async () => {
+    jobMock = newJobRepositoryMock();
+    sut = new JobService(jobMock);
+  });
+
+  it('should work', () => {
+    expect(sut).toBeDefined();
+  });
+
+  describe('getAllJobStatus', () => {
+    it('should get all job statuses', async () => {
+      jobMock.getJobCounts.mockResolvedValue({
+        active: 1,
+        completed: 1,
+        failed: 1,
+        delayed: 1,
+        waiting: 1,
+      });
+
+      await expect(sut.getAllJobsStatus()).resolves.toEqual({
+        'background-task-queue': {
+          active: 1,
+          completed: 1,
+          delayed: 1,
+          failed: 1,
+          waiting: 1,
+        },
+        'clip-encoding-queue': {
+          active: 1,
+          completed: 1,
+          delayed: 1,
+          failed: 1,
+          waiting: 1,
+        },
+        'metadata-extraction-queue': {
+          active: 1,
+          completed: 1,
+          delayed: 1,
+          failed: 1,
+          waiting: 1,
+        },
+        'object-tagging-queue': {
+          active: 1,
+          completed: 1,
+          delayed: 1,
+          failed: 1,
+          waiting: 1,
+        },
+        'search-queue': {
+          active: 1,
+          completed: 1,
+          delayed: 1,
+          failed: 1,
+          waiting: 1,
+        },
+        'storage-template-migration-queue': {
+          active: 1,
+          completed: 1,
+          delayed: 1,
+          failed: 1,
+          waiting: 1,
+        },
+        'thumbnail-generation-queue': {
+          active: 1,
+          completed: 1,
+          delayed: 1,
+          failed: 1,
+          waiting: 1,
+        },
+        'video-conversion-queue': {
+          active: 1,
+          completed: 1,
+          delayed: 1,
+          failed: 1,
+          waiting: 1,
+        },
+      });
+    });
+  });
+
+  describe('handleCommand', () => {
+    it('should handle a pause command', async () => {
+      await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.PAUSE, force: false });
+
+      expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
+    });
+
+    it('should handle an empty command', async () => {
+      await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.EMPTY, force: false });
+
+      expect(jobMock.empty).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
+    });
+
+    it('should not start a job that is already running', async () => {
+      jobMock.isActive.mockResolvedValue(true);
+
+      await expect(
+        sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }),
+      ).rejects.toBeInstanceOf(BadRequestException);
+
+      expect(jobMock.queue).not.toHaveBeenCalled();
+    });
+
+    it('should handle a start video conversion command', async () => {
+      jobMock.isActive.mockResolvedValue(false);
+
+      await sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false });
+
+      expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force: false } });
+    });
+
+    it('should handle a start storage template migration command', async () => {
+      jobMock.isActive.mockResolvedValue(false);
+
+      await sut.handleCommand(QueueName.STORAGE_TEMPLATE_MIGRATION, { command: JobCommand.START, force: false });
+
+      expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.STORAGE_TEMPLATE_MIGRATION });
+    });
+
+    it('should handle a start object tagging command', async () => {
+      jobMock.isActive.mockResolvedValue(false);
+
+      await sut.handleCommand(QueueName.OBJECT_TAGGING, { command: JobCommand.START, force: false });
+
+      expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_OBJECT_TAGGING, data: { force: false } });
+    });
+
+    it('should handle a start clip encoding command', async () => {
+      jobMock.isActive.mockResolvedValue(false);
+
+      await sut.handleCommand(QueueName.CLIP_ENCODING, { command: JobCommand.START, force: false });
+
+      expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_ENCODE_CLIP, data: { force: false } });
+    });
+
+    it('should handle a start metadata extraction command', async () => {
+      jobMock.isActive.mockResolvedValue(false);
+
+      await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.START, force: false });
+
+      expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force: false } });
+    });
+
+    it('should handle a start thumbnail generation command', async () => {
+      jobMock.isActive.mockResolvedValue(false);
+
+      await sut.handleCommand(QueueName.THUMBNAIL_GENERATION, { command: JobCommand.START, force: false });
+
+      expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } });
+    });
+
+    it('should throw a bad request when an invalid queue is used', async () => {
+      jobMock.isActive.mockResolvedValue(false);
+
+      await expect(
+        sut.handleCommand(QueueName.BACKGROUND_TASK, { command: JobCommand.START, force: false }),
+      ).rejects.toBeInstanceOf(BadRequestException);
+
+      expect(jobMock.queue).not.toHaveBeenCalled();
+    });
+  });
+});

+ 68 - 0
server/libs/domain/src/job/job.service.ts

@@ -0,0 +1,68 @@
+import { assertMachineLearningEnabled } from '@app/common';
+import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
+import { JobCommandDto } from './dto';
+import { JobCommand, JobName, QueueName } from './job.constants';
+import { IJobRepository } from './job.repository';
+import { AllJobStatusResponseDto } from './response-dto';
+
+@Injectable()
+export class JobService {
+  private logger = new Logger(JobService.name);
+
+  constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {}
+
+  handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> {
+    this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
+
+    switch (dto.command) {
+      case JobCommand.START:
+        return this.start(queueName, dto);
+
+      case JobCommand.PAUSE:
+        return this.jobRepository.pause(queueName);
+
+      case JobCommand.EMPTY:
+        return this.jobRepository.empty(queueName);
+    }
+  }
+
+  async getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
+    const response = new AllJobStatusResponseDto();
+    for (const queueName of Object.values(QueueName)) {
+      response[queueName] = await this.jobRepository.getJobCounts(queueName);
+    }
+    return response;
+  }
+
+  private async start(name: QueueName, { force }: JobCommandDto): Promise<void> {
+    const isActive = await this.jobRepository.isActive(name);
+    if (isActive) {
+      throw new BadRequestException(`Job is already running`);
+    }
+
+    switch (name) {
+      case QueueName.VIDEO_CONVERSION:
+        return this.jobRepository.queue({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force } });
+
+      case QueueName.STORAGE_TEMPLATE_MIGRATION:
+        return this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION });
+
+      case QueueName.OBJECT_TAGGING:
+        assertMachineLearningEnabled();
+        return this.jobRepository.queue({ name: JobName.QUEUE_OBJECT_TAGGING, data: { force } });
+
+      case QueueName.CLIP_ENCODING:
+        assertMachineLearningEnabled();
+        return this.jobRepository.queue({ name: JobName.QUEUE_ENCODE_CLIP, data: { force } });
+
+      case QueueName.METADATA_EXTRACTION:
+        return this.jobRepository.queue({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force } });
+
+      case QueueName.THUMBNAIL_GENERATION:
+        return this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force } });
+
+      default:
+        throw new BadRequestException(`Invalid job name: ${name}`);
+    }
+  }
+}

+ 41 - 0
server/libs/domain/src/job/response-dto/all-job-status-response.dto.ts

@@ -0,0 +1,41 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { QueueName } from '../job.constants';
+
+export class JobCountsDto {
+  @ApiProperty({ type: 'integer' })
+  active!: number;
+  @ApiProperty({ type: 'integer' })
+  completed!: number;
+  @ApiProperty({ type: 'integer' })
+  failed!: number;
+  @ApiProperty({ type: 'integer' })
+  delayed!: number;
+  @ApiProperty({ type: 'integer' })
+  waiting!: number;
+}
+
+export class AllJobStatusResponseDto implements Record<QueueName, JobCountsDto> {
+  @ApiProperty({ type: JobCountsDto })
+  [QueueName.THUMBNAIL_GENERATION]!: JobCountsDto;
+
+  @ApiProperty({ type: JobCountsDto })
+  [QueueName.METADATA_EXTRACTION]!: JobCountsDto;
+
+  @ApiProperty({ type: JobCountsDto })
+  [QueueName.VIDEO_CONVERSION]!: JobCountsDto;
+
+  @ApiProperty({ type: JobCountsDto })
+  [QueueName.OBJECT_TAGGING]!: JobCountsDto;
+
+  @ApiProperty({ type: JobCountsDto })
+  [QueueName.CLIP_ENCODING]!: JobCountsDto;
+
+  @ApiProperty({ type: JobCountsDto })
+  [QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobCountsDto;
+
+  @ApiProperty({ type: JobCountsDto })
+  [QueueName.BACKGROUND_TASK]!: JobCountsDto;
+
+  @ApiProperty({ type: JobCountsDto })
+  [QueueName.SEARCH]!: JobCountsDto;
+}

+ 1 - 0
server/libs/domain/src/job/response-dto/index.ts

@@ -0,0 +1 @@
+export * from './all-job-status-response.dto';

+ 22 - 6
server/libs/domain/src/media/media.service.ts

@@ -3,9 +3,9 @@ import { AssetType } from '@app/infra/db/entities';
 import { Inject, Injectable, Logger } from '@nestjs/common';
 import { Inject, Injectable, Logger } from '@nestjs/common';
 import { join } from 'path';
 import { join } from 'path';
 import sanitize from 'sanitize-filename';
 import sanitize from 'sanitize-filename';
-import { IAssetRepository, mapAsset } from '../asset';
+import { IAssetRepository, mapAsset, WithoutProperty } from '../asset';
 import { CommunicationEvent, ICommunicationRepository } from '../communication';
 import { CommunicationEvent, ICommunicationRepository } from '../communication';
-import { IAssetJob, IJobRepository, JobName } from '../job';
+import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job';
 import { IStorageRepository } from '../storage';
 import { IStorageRepository } from '../storage';
 import { IMediaRepository } from './media.repository';
 import { IMediaRepository } from './media.repository';
 
 
@@ -21,6 +21,22 @@ export class MediaService {
     @Inject(IStorageRepository) private storageRepository: IStorageRepository,
     @Inject(IStorageRepository) private storageRepository: IStorageRepository,
   ) {}
   ) {}
 
 
+  async handleQueueGenerateThumbnails(job: IBaseJob): Promise<void> {
+    try {
+      const { force } = job;
+
+      const assets = force
+        ? await this.assetRepository.getAll()
+        : await this.assetRepository.getWithout(WithoutProperty.THUMBNAIL);
+
+      for (const asset of assets) {
+        await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } });
+      }
+    } catch (error: any) {
+      this.logger.error('Failed to queue generate thumbnail jobs', error.stack);
+    }
+  }
+
   async handleGenerateJpegThumbnail(data: IAssetJob): Promise<void> {
   async handleGenerateJpegThumbnail(data: IAssetJob): Promise<void> {
     const { asset } = data;
     const { asset } = data;
 
 
@@ -52,8 +68,8 @@ export class MediaService {
       asset.resizePath = jpegThumbnailPath;
       asset.resizePath = jpegThumbnailPath;
 
 
       await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } });
       await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } });
-      await this.jobRepository.queue({ name: JobName.IMAGE_TAGGING, data: { asset } });
-      await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, data: { asset } });
+      await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { asset } });
+      await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } });
       await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
       await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
 
 
       this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
       this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
@@ -71,8 +87,8 @@ export class MediaService {
         asset.resizePath = jpegThumbnailPath;
         asset.resizePath = jpegThumbnailPath;
 
 
         await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } });
         await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } });
-        await this.jobRepository.queue({ name: JobName.IMAGE_TAGGING, data: { asset } });
-        await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, data: { asset } });
+        await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { asset } });
+        await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } });
         await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
         await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
 
 
         this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
         this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));

+ 1 - 1
server/libs/domain/src/smart-info/machine-learning.interface.ts

@@ -5,7 +5,7 @@ export interface MachineLearningInput {
 }
 }
 
 
 export interface IMachineLearningRepository {
 export interface IMachineLearningRepository {
-  tagImage(input: MachineLearningInput): Promise<string[]>;
+  classifyImage(input: MachineLearningInput): Promise<string[]>;
   detectObjects(input: MachineLearningInput): Promise<string[]>;
   detectObjects(input: MachineLearningInput): Promise<string[]>;
   encodeImage(input: MachineLearningInput): Promise<number[]>;
   encodeImage(input: MachineLearningInput): Promise<number[]>;
   encodeText(input: string): Promise<number[]>;
   encodeText(input: string): Promise<number[]>;

+ 97 - 13
server/libs/domain/src/smart-info/smart-info.service.spec.ts

@@ -1,6 +1,13 @@
 import { AssetEntity } from '@app/infra/db/entities';
 import { AssetEntity } from '@app/infra/db/entities';
-import { newJobRepositoryMock, newMachineLearningRepositoryMock, newSmartInfoRepositoryMock } from '../../test';
-import { IJobRepository } from '../job';
+import {
+  assetEntityStub,
+  newAssetRepositoryMock,
+  newJobRepositoryMock,
+  newMachineLearningRepositoryMock,
+  newSmartInfoRepositoryMock,
+} from '../../test';
+import { IAssetRepository, WithoutProperty } from '../asset';
+import { IJobRepository, JobName } from '../job';
 import { IMachineLearningRepository } from './machine-learning.interface';
 import { IMachineLearningRepository } from './machine-learning.interface';
 import { ISmartInfoRepository } from './smart-info.repository';
 import { ISmartInfoRepository } from './smart-info.repository';
 import { SmartInfoService } from './smart-info.service';
 import { SmartInfoService } from './smart-info.service';
@@ -12,35 +19,63 @@ const asset = {
 
 
 describe(SmartInfoService.name, () => {
 describe(SmartInfoService.name, () => {
   let sut: SmartInfoService;
   let sut: SmartInfoService;
+  let assetMock: jest.Mocked<IAssetRepository>;
   let jobMock: jest.Mocked<IJobRepository>;
   let jobMock: jest.Mocked<IJobRepository>;
   let smartMock: jest.Mocked<ISmartInfoRepository>;
   let smartMock: jest.Mocked<ISmartInfoRepository>;
   let machineMock: jest.Mocked<IMachineLearningRepository>;
   let machineMock: jest.Mocked<IMachineLearningRepository>;
 
 
   beforeEach(async () => {
   beforeEach(async () => {
+    assetMock = newAssetRepositoryMock();
     smartMock = newSmartInfoRepositoryMock();
     smartMock = newSmartInfoRepositoryMock();
     jobMock = newJobRepositoryMock();
     jobMock = newJobRepositoryMock();
     machineMock = newMachineLearningRepositoryMock();
     machineMock = newMachineLearningRepositoryMock();
-    sut = new SmartInfoService(jobMock, smartMock, machineMock);
+    sut = new SmartInfoService(assetMock, jobMock, smartMock, machineMock);
   });
   });
 
 
   it('should work', () => {
   it('should work', () => {
     expect(sut).toBeDefined();
     expect(sut).toBeDefined();
   });
   });
 
 
+  describe('handleQueueObjectTagging', () => {
+    it('should queue the assets without tags', async () => {
+      assetMock.getWithout.mockResolvedValue([assetEntityStub.image]);
+
+      await sut.handleQueueObjectTagging({ force: false });
+
+      expect(jobMock.queue.mock.calls).toEqual([
+        [{ name: JobName.CLASSIFY_IMAGE, data: { asset: assetEntityStub.image } }],
+        [{ name: JobName.DETECT_OBJECTS, data: { asset: assetEntityStub.image } }],
+      ]);
+      expect(assetMock.getWithout).toHaveBeenCalledWith(WithoutProperty.OBJECT_TAGS);
+    });
+
+    it('should queue all the assets', async () => {
+      assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
+
+      await sut.handleQueueObjectTagging({ force: true });
+
+      expect(jobMock.queue.mock.calls).toEqual([
+        [{ name: JobName.CLASSIFY_IMAGE, data: { asset: assetEntityStub.image } }],
+        [{ name: JobName.DETECT_OBJECTS, data: { asset: assetEntityStub.image } }],
+      ]);
+      expect(assetMock.getAll).toHaveBeenCalled();
+    });
+  });
+
   describe('handleTagImage', () => {
   describe('handleTagImage', () => {
     it('should skip assets without a resize path', async () => {
     it('should skip assets without a resize path', async () => {
-      await sut.handleTagImage({ asset: { resizePath: '' } as AssetEntity });
+      await sut.handleClassifyImage({ asset: { resizePath: '' } as AssetEntity });
 
 
       expect(smartMock.upsert).not.toHaveBeenCalled();
       expect(smartMock.upsert).not.toHaveBeenCalled();
-      expect(machineMock.tagImage).not.toHaveBeenCalled();
+      expect(machineMock.classifyImage).not.toHaveBeenCalled();
     });
     });
 
 
     it('should save the returned tags', async () => {
     it('should save the returned tags', async () => {
-      machineMock.tagImage.mockResolvedValue(['tag1', 'tag2', 'tag3']);
+      machineMock.classifyImage.mockResolvedValue(['tag1', 'tag2', 'tag3']);
 
 
-      await sut.handleTagImage({ asset });
+      await sut.handleClassifyImage({ asset });
 
 
-      expect(machineMock.tagImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' });
+      expect(machineMock.classifyImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' });
       expect(smartMock.upsert).toHaveBeenCalledWith({
       expect(smartMock.upsert).toHaveBeenCalledWith({
         assetId: 'asset-1',
         assetId: 'asset-1',
         tags: ['tag1', 'tag2', 'tag3'],
         tags: ['tag1', 'tag2', 'tag3'],
@@ -48,19 +83,19 @@ describe(SmartInfoService.name, () => {
     });
     });
 
 
     it('should handle an error with the machine learning pipeline', async () => {
     it('should handle an error with the machine learning pipeline', async () => {
-      machineMock.tagImage.mockRejectedValue(new Error('Unable to read thumbnail'));
+      machineMock.classifyImage.mockRejectedValue(new Error('Unable to read thumbnail'));
 
 
-      await sut.handleTagImage({ asset });
+      await sut.handleClassifyImage({ asset });
 
 
       expect(smartMock.upsert).not.toHaveBeenCalled();
       expect(smartMock.upsert).not.toHaveBeenCalled();
     });
     });
 
 
     it('should no update the smart info if no tags were returned', async () => {
     it('should no update the smart info if no tags were returned', async () => {
-      machineMock.tagImage.mockResolvedValue([]);
+      machineMock.classifyImage.mockResolvedValue([]);
 
 
-      await sut.handleTagImage({ asset });
+      await sut.handleClassifyImage({ asset });
 
 
-      expect(machineMock.tagImage).toHaveBeenCalled();
+      expect(machineMock.classifyImage).toHaveBeenCalled();
       expect(smartMock.upsert).not.toHaveBeenCalled();
       expect(smartMock.upsert).not.toHaveBeenCalled();
     });
     });
   });
   });
@@ -102,4 +137,53 @@ describe(SmartInfoService.name, () => {
       expect(smartMock.upsert).not.toHaveBeenCalled();
       expect(smartMock.upsert).not.toHaveBeenCalled();
     });
     });
   });
   });
+
+  describe('handleQueueEncodeClip', () => {
+    it('should queue the assets without clip embeddings', async () => {
+      assetMock.getWithout.mockResolvedValue([assetEntityStub.image]);
+
+      await sut.handleQueueEncodeClip({ force: false });
+
+      expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { asset: assetEntityStub.image } });
+      expect(assetMock.getWithout).toHaveBeenCalledWith(WithoutProperty.CLIP_ENCODING);
+    });
+
+    it('should queue all the assets', async () => {
+      assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
+
+      await sut.handleQueueEncodeClip({ force: true });
+
+      expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { asset: assetEntityStub.image } });
+      expect(assetMock.getAll).toHaveBeenCalled();
+    });
+  });
+
+  describe('handleEncodeClip', () => {
+    it('should skip assets without a resize path', async () => {
+      await sut.handleEncodeClip({ asset: { resizePath: '' } as AssetEntity });
+
+      expect(smartMock.upsert).not.toHaveBeenCalled();
+      expect(machineMock.encodeImage).not.toHaveBeenCalled();
+    });
+
+    it('should save the returned objects', async () => {
+      machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
+
+      await sut.handleEncodeClip({ asset });
+
+      expect(machineMock.encodeImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' });
+      expect(smartMock.upsert).toHaveBeenCalledWith({
+        assetId: 'asset-1',
+        clipEmbedding: [0.01, 0.02, 0.03],
+      });
+    });
+
+    it('should handle an error with the machine learning pipeline', async () => {
+      machineMock.encodeImage.mockRejectedValue(new Error('Unable to read thumbnail'));
+
+      await sut.handleEncodeClip({ asset });
+
+      expect(smartMock.upsert).not.toHaveBeenCalled();
+    });
+  });
 });
 });

+ 42 - 11
server/libs/domain/src/smart-info/smart-info.service.ts

@@ -1,6 +1,7 @@
 import { MACHINE_LEARNING_ENABLED } from '@app/common';
 import { MACHINE_LEARNING_ENABLED } from '@app/common';
 import { Inject, Injectable, Logger } from '@nestjs/common';
 import { Inject, Injectable, Logger } from '@nestjs/common';
-import { IAssetJob, IJobRepository, JobName } from '../job';
+import { IAssetRepository, WithoutProperty } from '../asset';
+import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job';
 import { IMachineLearningRepository } from './machine-learning.interface';
 import { IMachineLearningRepository } from './machine-learning.interface';
 import { ISmartInfoRepository } from './smart-info.repository';
 import { ISmartInfoRepository } from './smart-info.repository';
 
 
@@ -9,12 +10,28 @@ export class SmartInfoService {
   private logger = new Logger(SmartInfoService.name);
   private logger = new Logger(SmartInfoService.name);
 
 
   constructor(
   constructor(
+    @Inject(IAssetRepository) private assetRepository: IAssetRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
     @Inject(ISmartInfoRepository) private repository: ISmartInfoRepository,
     @Inject(ISmartInfoRepository) private repository: ISmartInfoRepository,
     @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
     @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
   ) {}
   ) {}
 
 
-  async handleTagImage(data: IAssetJob) {
+  async handleQueueObjectTagging({ force }: IBaseJob) {
+    try {
+      const assets = force
+        ? await this.assetRepository.getAll()
+        : await this.assetRepository.getWithout(WithoutProperty.OBJECT_TAGS);
+
+      for (const asset of assets) {
+        await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { asset } });
+        await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } });
+      }
+    } catch (error: any) {
+      this.logger.error(`Unable to queue object tagging`, error?.stack);
+    }
+  }
+
+  async handleDetectObjects(data: IAssetJob) {
     const { asset } = data;
     const { asset } = data;
 
 
     if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
     if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
@@ -22,17 +39,17 @@ export class SmartInfoService {
     }
     }
 
 
     try {
     try {
-      const tags = await this.machineLearning.tagImage({ thumbnailPath: asset.resizePath });
-      if (tags.length > 0) {
-        await this.repository.upsert({ assetId: asset.id, tags });
+      const objects = await this.machineLearning.detectObjects({ thumbnailPath: asset.resizePath });
+      if (objects.length > 0) {
+        await this.repository.upsert({ assetId: asset.id, objects });
         await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
         await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
       }
       }
     } catch (error: any) {
     } catch (error: any) {
-      this.logger.error(`Unable to run image tagging pipeline: ${asset.id}`, error?.stack);
+      this.logger.error(`Unable run object detection pipeline: ${asset.id}`, error?.stack);
     }
     }
   }
   }
 
 
-  async handleDetectObjects(data: IAssetJob) {
+  async handleClassifyImage(data: IAssetJob) {
     const { asset } = data;
     const { asset } = data;
 
 
     if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
     if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
@@ -40,13 +57,27 @@ export class SmartInfoService {
     }
     }
 
 
     try {
     try {
-      const objects = await this.machineLearning.detectObjects({ thumbnailPath: asset.resizePath });
-      if (objects.length > 0) {
-        await this.repository.upsert({ assetId: asset.id, objects });
+      const tags = await this.machineLearning.classifyImage({ thumbnailPath: asset.resizePath });
+      if (tags.length > 0) {
+        await this.repository.upsert({ assetId: asset.id, tags });
         await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
         await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
       }
       }
     } catch (error: any) {
     } catch (error: any) {
-      this.logger.error(`Unable run object detection pipeline: ${asset.id}`, error?.stack);
+      this.logger.error(`Unable to run image tagging pipeline: ${asset.id}`, error?.stack);
+    }
+  }
+
+  async handleQueueEncodeClip({ force }: IBaseJob) {
+    try {
+      const assets = force
+        ? await this.assetRepository.getAll()
+        : await this.assetRepository.getWithout(WithoutProperty.CLIP_ENCODING);
+
+      for (const asset of assets) {
+        await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } });
+      }
+    } catch (error: any) {
+      this.logger.error(`Unable to queue clip encoding`, error?.stack);
     }
     }
   }
   }
 
 

+ 0 - 0
server/apps/immich/src/utils/file-name.util.ts → server/libs/domain/src/util.ts


+ 1 - 0
server/libs/domain/test/asset.repository.mock.ts

@@ -3,6 +3,7 @@ import { IAssetRepository } from '../src';
 export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
 export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
   return {
   return {
     getByIds: jest.fn(),
     getByIds: jest.fn(),
+    getWithout: jest.fn(),
     getAll: jest.fn(),
     getAll: jest.fn(),
     deleteAll: jest.fn(),
     deleteAll: jest.fn(),
     save: jest.fn(),
     save: jest.fn(),

+ 1 - 0
server/libs/domain/test/job.repository.mock.ts

@@ -3,6 +3,7 @@ import { IJobRepository } from '../src';
 export const newJobRepositoryMock = (): jest.Mocked<IJobRepository> => {
 export const newJobRepositoryMock = (): jest.Mocked<IJobRepository> => {
   return {
   return {
     empty: jest.fn(),
     empty: jest.fn(),
+    pause: jest.fn(),
     queue: jest.fn().mockImplementation(() => Promise.resolve()),
     queue: jest.fn().mockImplementation(() => Promise.resolve()),
     isActive: jest.fn(),
     isActive: jest.fn(),
     getJobCounts: jest.fn(),
     getJobCounts: jest.fn(),

+ 1 - 1
server/libs/domain/test/machine-learning.repository.mock.ts

@@ -2,7 +2,7 @@ import { IMachineLearningRepository } from '../src';
 
 
 export const newMachineLearningRepositoryMock = (): jest.Mocked<IMachineLearningRepository> => {
 export const newMachineLearningRepositoryMock = (): jest.Mocked<IMachineLearningRepository> => {
   return {
   return {
-    tagImage: jest.fn(),
+    classifyImage: jest.fn(),
     detectObjects: jest.fn(),
     detectObjects: jest.fn(),
     encodeImage: jest.fn(),
     encodeImage: jest.fn(),
     encodeText: jest.fn(),
     encodeText: jest.fn(),

+ 71 - 2
server/libs/infra/src/db/repository/asset.repository.ts

@@ -1,7 +1,7 @@
-import { AssetSearchOptions, IAssetRepository } from '@app/domain';
+import { AssetSearchOptions, IAssetRepository, WithoutProperty } from '@app/domain';
 import { Injectable } from '@nestjs/common';
 import { Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { InjectRepository } from '@nestjs/typeorm';
-import { In, Not, Repository } from 'typeorm';
+import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm';
 import { AssetEntity, AssetType } from '../entities';
 import { AssetEntity, AssetType } from '../entities';
 
 
 @Injectable()
 @Injectable()
@@ -65,4 +65,73 @@ export class AssetRepository implements IAssetRepository {
       },
       },
     });
     });
   }
   }
+
+  getWithout(property: WithoutProperty): Promise<AssetEntity[]> {
+    let relations: FindOptionsRelations<AssetEntity> = {};
+    let where: FindOptionsWhere<AssetEntity> | FindOptionsWhere<AssetEntity>[] = {};
+
+    switch (property) {
+      case WithoutProperty.THUMBNAIL:
+        where = [
+          { resizePath: IsNull(), isVisible: true },
+          { resizePath: '', isVisible: true },
+          { webpPath: IsNull(), isVisible: true },
+          { webpPath: '', isVisible: true },
+        ];
+        break;
+
+      case WithoutProperty.ENCODED_VIDEO:
+        where = [
+          { type: AssetType.VIDEO, encodedVideoPath: IsNull() },
+          { type: AssetType.VIDEO, encodedVideoPath: '' },
+        ];
+        break;
+
+      case WithoutProperty.EXIF:
+        relations = {
+          exifInfo: true,
+        };
+        where = {
+          isVisible: true,
+          resizePath: Not(IsNull()),
+          exifInfo: {
+            assetId: IsNull(),
+          },
+        };
+        break;
+
+      case WithoutProperty.CLIP_ENCODING:
+        relations = {
+          smartInfo: true,
+        };
+        where = {
+          isVisible: true,
+          smartInfo: {
+            clipEmbedding: IsNull(),
+          },
+        };
+        break;
+
+      case WithoutProperty.OBJECT_TAGS:
+        relations = {
+          smartInfo: true,
+        };
+        where = {
+          resizePath: IsNull(),
+          isVisible: true,
+          smartInfo: {
+            tags: IsNull(),
+          },
+        };
+        break;
+
+      default:
+        throw new Error(`Invalid getWithout property: ${property}`);
+    }
+
+    return this.repository.find({
+      relations,
+      where,
+    });
+  }
 }
 }

+ 44 - 30
server/libs/infra/src/job/job.repository.ts

@@ -1,18 +1,38 @@
-import { IAssetJob, IJobRepository, IMetadataExtractionJob, JobCounts, JobItem, JobName, QueueName } from '@app/domain';
+import {
+  IAssetJob,
+  IBaseJob,
+  IJobRepository,
+  IMetadataExtractionJob,
+  JobCounts,
+  JobItem,
+  JobName,
+  QueueName,
+} from '@app/domain';
 import { InjectQueue } from '@nestjs/bull';
 import { InjectQueue } from '@nestjs/bull';
-import { BadRequestException, Logger } from '@nestjs/common';
+import { Logger } from '@nestjs/common';
 import { Queue } from 'bull';
 import { Queue } from 'bull';
 
 
 export class JobRepository implements IJobRepository {
 export class JobRepository implements IJobRepository {
   private logger = new Logger(JobRepository.name);
   private logger = new Logger(JobRepository.name);
+  private queueMap: Record<QueueName, Queue> = {
+    [QueueName.STORAGE_TEMPLATE_MIGRATION]: this.storageTemplateMigration,
+    [QueueName.THUMBNAIL_GENERATION]: this.generateThumbnail,
+    [QueueName.METADATA_EXTRACTION]: this.metadataExtraction,
+    [QueueName.OBJECT_TAGGING]: this.objectTagging,
+    [QueueName.CLIP_ENCODING]: this.clipEmbedding,
+    [QueueName.VIDEO_CONVERSION]: this.videoTranscode,
+    [QueueName.BACKGROUND_TASK]: this.backgroundTask,
+    [QueueName.SEARCH]: this.searchIndex,
+  };
 
 
   constructor(
   constructor(
     @InjectQueue(QueueName.BACKGROUND_TASK) private backgroundTask: Queue,
     @InjectQueue(QueueName.BACKGROUND_TASK) private backgroundTask: Queue,
-    @InjectQueue(QueueName.MACHINE_LEARNING) private machineLearning: Queue<IAssetJob>,
-    @InjectQueue(QueueName.METADATA_EXTRACTION) private metadataExtraction: Queue<IMetadataExtractionJob>,
+    @InjectQueue(QueueName.OBJECT_TAGGING) private objectTagging: Queue<IAssetJob | IBaseJob>,
+    @InjectQueue(QueueName.CLIP_ENCODING) private clipEmbedding: Queue<IAssetJob | IBaseJob>,
+    @InjectQueue(QueueName.METADATA_EXTRACTION) private metadataExtraction: Queue<IMetadataExtractionJob | IBaseJob>,
     @InjectQueue(QueueName.STORAGE_TEMPLATE_MIGRATION) private storageTemplateMigration: Queue,
     @InjectQueue(QueueName.STORAGE_TEMPLATE_MIGRATION) private storageTemplateMigration: Queue,
-    @InjectQueue(QueueName.THUMBNAIL_GENERATION) private thumbnail: Queue,
-    @InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue<IAssetJob>,
+    @InjectQueue(QueueName.THUMBNAIL_GENERATION) private generateThumbnail: Queue,
+    @InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue<IAssetJob | IBaseJob>,
     @InjectQueue(QueueName.SEARCH) private searchIndex: Queue,
     @InjectQueue(QueueName.SEARCH) private searchIndex: Queue,
   ) {}
   ) {}
 
 
@@ -21,12 +41,16 @@ export class JobRepository implements IJobRepository {
     return !!counts.active;
     return !!counts.active;
   }
   }
 
 
+  pause(name: QueueName) {
+    return this.queueMap[name].pause();
+  }
+
   empty(name: QueueName) {
   empty(name: QueueName) {
-    return this.getQueue(name).empty();
+    return this.queueMap[name].empty();
   }
   }
 
 
   getJobCounts(name: QueueName): Promise<JobCounts> {
   getJobCounts(name: QueueName): Promise<JobCounts> {
-    return this.getQueue(name).getJobCounts();
+    return this.queueMap[name].getJobCounts();
   }
   }
 
 
   async queue(item: JobItem): Promise<void> {
   async queue(item: JobItem): Promise<void> {
@@ -39,21 +63,28 @@ export class JobRepository implements IJobRepository {
         await this.backgroundTask.add(item.name, item.data);
         await this.backgroundTask.add(item.name, item.data);
         break;
         break;
 
 
-      case JobName.OBJECT_DETECTION:
-      case JobName.IMAGE_TAGGING:
+      case JobName.QUEUE_OBJECT_TAGGING:
+      case JobName.DETECT_OBJECTS:
+      case JobName.CLASSIFY_IMAGE:
+        await this.objectTagging.add(item.name, item.data);
+        break;
+
+      case JobName.QUEUE_ENCODE_CLIP:
       case JobName.ENCODE_CLIP:
       case JobName.ENCODE_CLIP:
-        await this.machineLearning.add(item.name, item.data);
+        await this.clipEmbedding.add(item.name, item.data);
         break;
         break;
 
 
+      case JobName.QUEUE_METADATA_EXTRACTION:
       case JobName.EXIF_EXTRACTION:
       case JobName.EXIF_EXTRACTION:
       case JobName.EXTRACT_VIDEO_METADATA:
       case JobName.EXTRACT_VIDEO_METADATA:
       case JobName.REVERSE_GEOCODING:
       case JobName.REVERSE_GEOCODING:
         await this.metadataExtraction.add(item.name, item.data);
         await this.metadataExtraction.add(item.name, item.data);
         break;
         break;
 
 
+      case JobName.QUEUE_GENERATE_THUMBNAILS:
       case JobName.GENERATE_JPEG_THUMBNAIL:
       case JobName.GENERATE_JPEG_THUMBNAIL:
       case JobName.GENERATE_WEBP_THUMBNAIL:
       case JobName.GENERATE_WEBP_THUMBNAIL:
-        await this.thumbnail.add(item.name, item.data);
+        await this.generateThumbnail.add(item.name, item.data);
         break;
         break;
 
 
       case JobName.USER_DELETION:
       case JobName.USER_DELETION:
@@ -68,6 +99,7 @@ export class JobRepository implements IJobRepository {
         await this.backgroundTask.add(item.name, {});
         await this.backgroundTask.add(item.name, {});
         break;
         break;
 
 
+      case JobName.QUEUE_VIDEO_CONVERSION:
       case JobName.VIDEO_CONVERSION:
       case JobName.VIDEO_CONVERSION:
         await this.videoTranscode.add(item.name, item.data);
         await this.videoTranscode.add(item.name, item.data);
         break;
         break;
@@ -85,25 +117,7 @@ export class JobRepository implements IJobRepository {
         break;
         break;
 
 
       default:
       default:
-        // TODO inject remaining queues and map job to queue
         this.logger.error('Invalid job', item);
         this.logger.error('Invalid job', item);
     }
     }
   }
   }
-
-  private getQueue(name: QueueName) {
-    switch (name) {
-      case QueueName.STORAGE_TEMPLATE_MIGRATION:
-        return this.storageTemplateMigration;
-      case QueueName.THUMBNAIL_GENERATION:
-        return this.thumbnail;
-      case QueueName.METADATA_EXTRACTION:
-        return this.metadataExtraction;
-      case QueueName.VIDEO_CONVERSION:
-        return this.videoTranscode;
-      case QueueName.MACHINE_LEARNING:
-        return this.machineLearning;
-      default:
-        throw new BadRequestException('Invalid job name');
-    }
-  }
 }
 }

+ 1 - 1
server/libs/infra/src/machine-learning/machine-learning.repository.ts

@@ -7,7 +7,7 @@ const client = axios.create({ baseURL: MACHINE_LEARNING_URL });
 
 
 @Injectable()
 @Injectable()
 export class MachineLearningRepository implements IMachineLearningRepository {
 export class MachineLearningRepository implements IMachineLearningRepository {
-  tagImage(input: MachineLearningInput): Promise<string[]> {
+  classifyImage(input: MachineLearningInput): Promise<string[]> {
     return client.post<string[]>('/image-classifier/tag-image', input).then((res) => res.data);
     return client.post<string[]>('/image-classifier/tag-image', input).then((res) => res.data);
   }
   }
 
 

+ 56 - 34
web/src/api/open-api/api.ts

@@ -291,34 +291,52 @@ export interface AlbumResponseDto {
 export interface AllJobStatusResponseDto {
 export interface AllJobStatusResponseDto {
     /**
     /**
      * 
      * 
-     * @type {JobCounts}
+     * @type {JobCountsDto}
      * @memberof AllJobStatusResponseDto
      * @memberof AllJobStatusResponseDto
      */
      */
-    'thumbnail-generation': JobCounts;
+    'thumbnail-generation-queue': JobCountsDto;
     /**
     /**
      * 
      * 
-     * @type {JobCounts}
+     * @type {JobCountsDto}
      * @memberof AllJobStatusResponseDto
      * @memberof AllJobStatusResponseDto
      */
      */
-    'metadata-extraction': JobCounts;
+    'metadata-extraction-queue': JobCountsDto;
     /**
     /**
      * 
      * 
-     * @type {JobCounts}
+     * @type {JobCountsDto}
      * @memberof AllJobStatusResponseDto
      * @memberof AllJobStatusResponseDto
      */
      */
-    'video-conversion': JobCounts;
+    'video-conversion-queue': JobCountsDto;
     /**
     /**
      * 
      * 
-     * @type {JobCounts}
+     * @type {JobCountsDto}
      * @memberof AllJobStatusResponseDto
      * @memberof AllJobStatusResponseDto
      */
      */
-    'machine-learning': JobCounts;
+    'object-tagging-queue': JobCountsDto;
     /**
     /**
      * 
      * 
-     * @type {JobCounts}
+     * @type {JobCountsDto}
      * @memberof AllJobStatusResponseDto
      * @memberof AllJobStatusResponseDto
      */
      */
-    'storage-template-migration': JobCounts;
+    'clip-encoding-queue': JobCountsDto;
+    /**
+     * 
+     * @type {JobCountsDto}
+     * @memberof AllJobStatusResponseDto
+     */
+    'storage-template-migration-queue': JobCountsDto;
+    /**
+     * 
+     * @type {JobCountsDto}
+     * @memberof AllJobStatusResponseDto
+     */
+    'background-task-queue': JobCountsDto;
+    /**
+     * 
+     * @type {JobCountsDto}
+     * @memberof AllJobStatusResponseDto
+     */
+    'search-queue': JobCountsDto;
 }
 }
 /**
 /**
  * 
  * 
@@ -1203,7 +1221,8 @@ export interface GetAssetCountByTimeBucketDto {
 
 
 export const JobCommand = {
 export const JobCommand = {
     Start: 'start',
     Start: 'start',
-    Stop: 'stop'
+    Pause: 'pause',
+    Empty: 'empty'
 } as const;
 } as const;
 
 
 export type JobCommand = typeof JobCommand[keyof typeof JobCommand];
 export type JobCommand = typeof JobCommand[keyof typeof JobCommand];
@@ -1226,42 +1245,42 @@ export interface JobCommandDto {
      * @type {boolean}
      * @type {boolean}
      * @memberof JobCommandDto
      * @memberof JobCommandDto
      */
      */
-    'includeAllAssets': boolean;
+    'force': boolean;
 }
 }
 /**
 /**
  * 
  * 
  * @export
  * @export
- * @interface JobCounts
+ * @interface JobCountsDto
  */
  */
-export interface JobCounts {
+export interface JobCountsDto {
     /**
     /**
      * 
      * 
      * @type {number}
      * @type {number}
-     * @memberof JobCounts
+     * @memberof JobCountsDto
      */
      */
     'active': number;
     'active': number;
     /**
     /**
      * 
      * 
      * @type {number}
      * @type {number}
-     * @memberof JobCounts
+     * @memberof JobCountsDto
      */
      */
     'completed': number;
     'completed': number;
     /**
     /**
      * 
      * 
      * @type {number}
      * @type {number}
-     * @memberof JobCounts
+     * @memberof JobCountsDto
      */
      */
     'failed': number;
     'failed': number;
     /**
     /**
      * 
      * 
      * @type {number}
      * @type {number}
-     * @memberof JobCounts
+     * @memberof JobCountsDto
      */
      */
     'delayed': number;
     'delayed': number;
     /**
     /**
      * 
      * 
      * @type {number}
      * @type {number}
-     * @memberof JobCounts
+     * @memberof JobCountsDto
      */
      */
     'waiting': number;
     'waiting': number;
 }
 }
@@ -1271,15 +1290,18 @@ export interface JobCounts {
  * @enum {string}
  * @enum {string}
  */
  */
 
 
-export const JobId = {
-    ThumbnailGeneration: 'thumbnail-generation',
-    MetadataExtraction: 'metadata-extraction',
-    VideoConversion: 'video-conversion',
-    MachineLearning: 'machine-learning',
-    StorageTemplateMigration: 'storage-template-migration'
+export const JobName = {
+    ThumbnailGenerationQueue: 'thumbnail-generation-queue',
+    MetadataExtractionQueue: 'metadata-extraction-queue',
+    VideoConversionQueue: 'video-conversion-queue',
+    ObjectTaggingQueue: 'object-tagging-queue',
+    ClipEncodingQueue: 'clip-encoding-queue',
+    BackgroundTaskQueue: 'background-task-queue',
+    StorageTemplateMigrationQueue: 'storage-template-migration-queue',
+    SearchQueue: 'search-queue'
 } as const;
 } as const;
 
 
-export type JobId = typeof JobId[keyof typeof JobId];
+export type JobName = typeof JobName[keyof typeof JobName];
 
 
 
 
 /**
 /**
@@ -6169,12 +6191,12 @@ export const JobApiAxiosParamCreator = function (configuration?: Configuration)
         },
         },
         /**
         /**
          * 
          * 
-         * @param {JobId} jobId 
+         * @param {JobName} jobId 
          * @param {JobCommandDto} jobCommandDto 
          * @param {JobCommandDto} jobCommandDto 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        sendJobCommand: async (jobId: JobId, jobCommandDto: JobCommandDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+        sendJobCommand: async (jobId: JobName, jobCommandDto: JobCommandDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             // verify required parameter 'jobId' is not null or undefined
             // verify required parameter 'jobId' is not null or undefined
             assertParamExists('sendJobCommand', 'jobId', jobId)
             assertParamExists('sendJobCommand', 'jobId', jobId)
             // verify required parameter 'jobCommandDto' is not null or undefined
             // verify required parameter 'jobCommandDto' is not null or undefined
@@ -6233,12 +6255,12 @@ export const JobApiFp = function(configuration?: Configuration) {
         },
         },
         /**
         /**
          * 
          * 
-         * @param {JobId} jobId 
+         * @param {JobName} jobId 
          * @param {JobCommandDto} jobCommandDto 
          * @param {JobCommandDto} jobCommandDto 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        async sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<number>> {
+        async sendJobCommand(jobId: JobName, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
             const localVarAxiosArgs = await localVarAxiosParamCreator.sendJobCommand(jobId, jobCommandDto, options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.sendJobCommand(jobId, jobCommandDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
@@ -6262,12 +6284,12 @@ export const JobApiFactory = function (configuration?: Configuration, basePath?:
         },
         },
         /**
         /**
          * 
          * 
-         * @param {JobId} jobId 
+         * @param {JobName} jobId 
          * @param {JobCommandDto} jobCommandDto 
          * @param {JobCommandDto} jobCommandDto 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: any): AxiosPromise<number> {
+        sendJobCommand(jobId: JobName, jobCommandDto: JobCommandDto, options?: any): AxiosPromise<void> {
             return localVarFp.sendJobCommand(jobId, jobCommandDto, options).then((request) => request(axios, basePath));
             return localVarFp.sendJobCommand(jobId, jobCommandDto, options).then((request) => request(axios, basePath));
         },
         },
     };
     };
@@ -6292,13 +6314,13 @@ export class JobApi extends BaseAPI {
 
 
     /**
     /**
      * 
      * 
-     * @param {JobId} jobId 
+     * @param {JobName} jobId 
      * @param {JobCommandDto} jobCommandDto 
      * @param {JobCommandDto} jobCommandDto 
      * @param {*} [options] Override http request option.
      * @param {*} [options] Override http request option.
      * @throws {RequiredError}
      * @throws {RequiredError}
      * @memberof JobApi
      * @memberof JobApi
      */
      */
-    public sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig) {
+    public sendJobCommand(jobId: JobName, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig) {
         return JobApiFp(this.configuration).sendJobCommand(jobId, jobCommandDto, options).then((request) => request(this.axios, this.basePath));
         return JobApiFp(this.configuration).sendJobCommand(jobId, jobCommandDto, options).then((request) => request(this.axios, this.basePath));
     }
     }
 }
 }

+ 4 - 4
web/src/lib/components/admin-page/jobs/job-tile.svelte

@@ -5,11 +5,11 @@
 	import AllInclusive from 'svelte-material-icons/AllInclusive.svelte';
 	import AllInclusive from 'svelte-material-icons/AllInclusive.svelte';
 	import { locale } from '$lib/stores/preferences.store';
 	import { locale } from '$lib/stores/preferences.store';
 	import { createEventDispatcher } from 'svelte';
 	import { createEventDispatcher } from 'svelte';
-	import { JobCounts } from '@api';
+	import { JobCountsDto } from '@api';
 
 
 	export let title: string;
 	export let title: string;
 	export let subtitle: string;
 	export let subtitle: string;
-	export let jobCounts: JobCounts;
+	export let jobCounts: JobCountsDto;
 	/**
 	/**
 	 * Show options to run job on all assets of just missing ones
 	 * Show options to run job on all assets of just missing ones
 	 */
 	 */
@@ -19,8 +19,8 @@
 
 
 	const dispatch = createEventDispatcher();
 	const dispatch = createEventDispatcher();
 
 
-	const run = (includeAllAssets: boolean) => {
-		dispatch('click', { includeAllAssets });
+	const run = (force: boolean) => {
+		dispatch('click', { force });
 	};
 	};
 </script>
 </script>
 
 

+ 54 - 75
web/src/lib/components/admin-page/jobs/jobs-panel.svelte

@@ -4,7 +4,7 @@
 		NotificationType
 		NotificationType
 	} from '$lib/components/shared-components/notification/notification';
 	} from '$lib/components/shared-components/notification/notification';
 	import { handleError } from '$lib/utils/handle-error';
 	import { handleError } from '$lib/utils/handle-error';
-	import { AllJobStatusResponseDto, api, JobCommand, JobId } from '@api';
+	import { AllJobStatusResponseDto, api, JobCommand, JobName } from '@api';
 	import { onDestroy, onMount } from 'svelte';
 	import { onDestroy, onMount } from 'svelte';
 	import JobTile from './job-tile.svelte';
 	import JobTile from './job-tile.svelte';
 
 
@@ -18,35 +18,42 @@
 
 
 	onMount(async () => {
 	onMount(async () => {
 		await load();
 		await load();
-		timer = setInterval(async () => await load(), 1_000);
+		timer = setInterval(async () => await load(), 5_000);
 	});
 	});
 
 
 	onDestroy(() => {
 	onDestroy(() => {
 		clearInterval(timer);
 		clearInterval(timer);
 	});
 	});
 
 
-	const run = async (
-		jobId: JobId,
-		jobName: string,
-		emptyMessage: string,
-		includeAllAssets: boolean
-	) => {
+	function getJobLabel(jobName: JobName) {
+		const names: Record<JobName, string> = {
+			[JobName.ThumbnailGenerationQueue]: 'Generate Thumbnails',
+			[JobName.MetadataExtractionQueue]: 'Extract Metadata',
+			[JobName.VideoConversionQueue]: 'Transcode Videos',
+			[JobName.ObjectTaggingQueue]: 'Tag Objects',
+			[JobName.ClipEncodingQueue]: 'Clip Encoding',
+			[JobName.BackgroundTaskQueue]: 'Background Task',
+			[JobName.StorageTemplateMigrationQueue]: 'Storage Template Migration',
+			[JobName.SearchQueue]: 'Search'
+		};
+
+		return names[jobName];
+	}
+
+	const start = async (jobId: JobName, force: boolean) => {
+		const label = getJobLabel(jobId);
+
 		try {
 		try {
-			const { data } = await api.jobApi.sendJobCommand(jobId, {
-				command: JobCommand.Start,
-				includeAllAssets
-			});
+			await api.jobApi.sendJobCommand(jobId, { command: JobCommand.Start, force });
+
+			jobs[jobId].active += 1;
 
 
-			if (data) {
-				notificationController.show({
-					message: includeAllAssets ? `Started ${jobName} for all assets` : `Started ${jobName}`,
-					type: NotificationType.Info
-				});
-			} else {
-				notificationController.show({ message: emptyMessage, type: NotificationType.Info });
-			}
+			notificationController.show({
+				message: `Started job: ${label}`,
+				type: NotificationType.Info
+			});
 		} catch (error) {
 		} catch (error) {
-			handleError(error, `Unable to start ${jobName}`);
+			handleError(error, `Unable to start job: ${label}`);
 		}
 		}
 	};
 	};
 </script>
 </script>
@@ -54,76 +61,48 @@
 <div class="flex flex-col gap-7">
 <div class="flex flex-col gap-7">
 	{#if jobs}
 	{#if jobs}
 		<JobTile
 		<JobTile
-			title={'Generate thumbnails'}
-			subtitle={'Regenerate JPEG and WebP thumbnails'}
-			on:click={(e) => {
-				const { includeAllAssets } = e.detail;
-
-				run(
-					JobId.ThumbnailGeneration,
-					'thumbnail generation',
-					'No missing thumbnails found',
-					includeAllAssets
-				);
-			}}
-			jobCounts={jobs[JobId.ThumbnailGeneration]}
+			title="Generate thumbnails"
+			subtitle="Regenerate JPEG and WebP thumbnails"
+			on:click={(e) => start(JobName.ThumbnailGenerationQueue, e.detail.force)}
+			jobCounts={jobs[JobName.ThumbnailGenerationQueue]}
 		/>
 		/>
 
 
 		<JobTile
 		<JobTile
-			title={'EXTRACT METADATA'}
-			subtitle={'Extract metadata information i.e. GPS, resolution...etc'}
-			on:click={(e) => {
-				const { includeAllAssets } = e.detail;
-				run(JobId.MetadataExtraction, 'extract EXIF', 'No missing EXIF found', includeAllAssets);
-			}}
-			jobCounts={jobs[JobId.MetadataExtraction]}
+			title="Extract Metadata"
+			subtitle="Extract metadata information i.e. GPS, resolution...etc"
+			on:click={(e) => start(JobName.MetadataExtractionQueue, e.detail.force)}
+			jobCounts={jobs[JobName.MetadataExtractionQueue]}
 		/>
 		/>
 
 
 		<JobTile
 		<JobTile
-			title={'Detect objects'}
-			subtitle={'Run machine learning process to detect and classify objects'}
-			on:click={(e) => {
-				const { includeAllAssets } = e.detail;
-
-				run(
-					JobId.MachineLearning,
-					'object detection',
-					'No missing object detection found',
-					includeAllAssets
-				);
-			}}
-			jobCounts={jobs[JobId.MachineLearning]}
+			title="Tag Objects"
+			subtitle="Run machine learning to tag objects"
+			on:click={(e) => start(JobName.ObjectTaggingQueue, e.detail.force)}
+			jobCounts={jobs[JobName.ObjectTaggingQueue]}
 		>
 		>
 			Note that some assets may not have any objects detected
 			Note that some assets may not have any objects detected
 		</JobTile>
 		</JobTile>
 
 
 		<JobTile
 		<JobTile
-			title={'Video transcoding'}
-			subtitle={'Transcode videos not in the desired format'}
-			on:click={(e) => {
-				const { includeAllAssets } = e.detail;
-				run(
-					JobId.VideoConversion,
-					'video conversion',
-					'No videos without an encoded version found',
-					includeAllAssets
-				);
-			}}
-			jobCounts={jobs[JobId.VideoConversion]}
+			title="Encode Clip"
+			subtitle="Run machine learning to generate clip embeddings"
+			on:click={(e) => start(JobName.ClipEncodingQueue, e.detail.force)}
+			jobCounts={jobs[JobName.ClipEncodingQueue]}
+		/>
+
+		<JobTile
+			title="Transcode Videos"
+			subtitle="Transcode videos not in the desired format"
+			on:click={(e) => start(JobName.VideoConversionQueue, e.detail.force)}
+			jobCounts={jobs[JobName.VideoConversionQueue]}
 		/>
 		/>
 
 
 		<JobTile
 		<JobTile
-			title={'Storage migration'}
+			title="Storage migration"
 			showOptions={false}
 			showOptions={false}
 			subtitle={''}
 			subtitle={''}
-			on:click={() =>
-				run(
-					JobId.StorageTemplateMigration,
-					'storage template migration',
-					'All files have been migrated to the new storage template',
-					false
-				)}
-			jobCounts={jobs[JobId.StorageTemplateMigration]}
+			on:click={(e) => start(JobName.StorageTemplateMigrationQueue, e.detail.force)}
+			jobCounts={jobs[JobName.StorageTemplateMigrationQueue]}
 		>
 		>
 			Apply the current
 			Apply the current
 			<a
 			<a