1
0
forked from Cutlery/immich

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>
This commit is contained in:
Jason Rasmussen 2023-03-20 11:55:28 -04:00 committed by GitHub
parent db6b14361d
commit 386eef046d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
68 changed files with 1355 additions and 907 deletions

View File

@ -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/JobCountsDto.md
doc/JobId.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_counts_dto.dart
lib/model/job_id.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_counts_dto_test.dart
test/job_id_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

View File

@ -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) - [JobCountsDto](doc//JobCountsDto.md)
- [JobId](doc//JobId.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)

View File

@ -8,11 +8,14 @@ import 'package:openapi/api.dart';
## Properties ## Properties
Name | Type | Description | Notes Name | Type | Description | Notes
------------ | ------------- | ------------- | ------------- ------------ | ------------- | ------------- | -------------
**thumbnailGeneration** | [**JobCounts**](JobCounts.md) | | **thumbnailGenerationQueue** | [**JobCountsDto**](JobCountsDto.md) | |
**metadataExtraction** | [**JobCounts**](JobCounts.md) | | **metadataExtractionQueue** | [**JobCountsDto**](JobCountsDto.md) | |
**videoConversion** | [**JobCounts**](JobCounts.md) | | **videoConversionQueue** | [**JobCountsDto**](JobCountsDto.md) | |
**machineLearning** | [**JobCounts**](JobCounts.md) | | **objectTaggingQueue** | [**JobCountsDto**](JobCountsDto.md) | |
**storageTemplateMigration** | [**JobCounts**](JobCounts.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)

View File

@ -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); api_instance.sendJobCommand(jobId, jobCommandDto);
print(result);
} 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)

View File

@ -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)

View File

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

View File

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

View File

@ -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_counts_dto.dart';
part 'model/job_id.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';

View File

@ -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;
} }
} }

View File

@ -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': case 'JobCountsDto':
return JobCounts.fromJson(value); return JobCountsDto.fromJson(value);
case 'JobId': case 'JobName':
return JobIdTypeTransformer().decode(value); return JobNameTypeTransformer().decode(value);
case 'LoginCredentialDto': case 'LoginCredentialDto':
return LoginCredentialDto.fromJson(value); return LoginCredentialDto.fromJson(value);
case 'LoginResponseDto': case 'LoginResponseDto':

View File

@ -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) { if (value is JobName) {
return JobIdTypeTransformer().encode(value).toString(); return JobNameTypeTransformer().encode(value).toString();
} }
if (value is SharedLinkType) { if (value is SharedLinkType) {
return SharedLinkTypeTypeTransformer().encode(value).toString(); return SharedLinkTypeTypeTransformer().encode(value).toString();

View File

@ -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.thumbnailGenerationQueue,
required this.metadataExtraction, required this.metadataExtractionQueue,
required this.videoConversion, required this.videoConversionQueue,
required this.machineLearning, required this.objectTaggingQueue,
required this.storageTemplateMigration, 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.thumbnailGenerationQueue == thumbnailGenerationQueue &&
other.metadataExtraction == metadataExtraction && other.metadataExtractionQueue == metadataExtractionQueue &&
other.videoConversion == videoConversion && other.videoConversionQueue == videoConversionQueue &&
other.machineLearning == machineLearning && other.objectTaggingQueue == objectTaggingQueue &&
other.storageTemplateMigration == storageTemplateMigration; 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) + (thumbnailGenerationQueue.hashCode) +
(metadataExtraction.hashCode) + (metadataExtractionQueue.hashCode) +
(videoConversion.hashCode) + (videoConversionQueue.hashCode) +
(machineLearning.hashCode) + (objectTaggingQueue.hashCode) +
(storageTemplateMigration.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'thumbnail-generation-queue'] = this.thumbnailGenerationQueue;
json[r'metadata-extraction'] = this.metadataExtraction; json[r'metadata-extraction-queue'] = this.metadataExtractionQueue;
json[r'video-conversion'] = this.videoConversion; json[r'video-conversion-queue'] = this.videoConversionQueue;
json[r'machine-learning'] = this.machineLearning; json[r'object-tagging-queue'] = this.objectTaggingQueue;
json[r'storage-template-migration'] = this.storageTemplateMigration; 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'])!, thumbnailGenerationQueue: JobCountsDto.fromJson(json[r'thumbnail-generation-queue'])!,
metadataExtraction: JobCounts.fromJson(json[r'metadata-extraction'])!, metadataExtractionQueue: JobCountsDto.fromJson(json[r'metadata-extraction-queue'])!,
videoConversion: JobCounts.fromJson(json[r'video-conversion'])!, videoConversionQueue: JobCountsDto.fromJson(json[r'video-conversion-queue'])!,
machineLearning: JobCounts.fromJson(json[r'machine-learning'])!, objectTaggingQueue: JobCountsDto.fromJson(json[r'object-tagging-queue'])!,
storageTemplateMigration: JobCounts.fromJson(json[r'storage-template-migration'])!, 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', 'thumbnail-generation-queue',
'metadata-extraction', 'metadata-extraction-queue',
'video-conversion', 'video-conversion-queue',
'machine-learning', 'object-tagging-queue',
'storage-template-migration', 'clip-encoding-queue',
'storage-template-migration-queue',
'background-task-queue',
'search-queue',
}; };
} }

View File

@ -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');

View File

@ -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',
}; };
} }

View File

@ -10,9 +10,9 @@
part of openapi.api; part of openapi.api;
class JobCounts { class JobCountsDto {
/// Returns a new [JobCounts] instance. /// Returns a new [JobCountsDto] instance.
JobCounts({ 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.containsKey(key), 'Required key "JobCountsDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "JobCounts[$key]" has a null value in 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,}) { static List<JobCountsDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <JobCounts>[]; 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) { static Map<String, JobCountsDto> mapFromJson(dynamic json) {
final map = <String, JobCounts>{}; 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 // maps a json object with a list of JobCountsDto-objects as value to a dart map
static Map<String, List<JobCounts>> mapListFromJson(dynamic json, {bool growable = false,}) { static Map<String, List<JobCountsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<JobCounts>>{}; 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;
} }

View File

@ -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
mobile/openapi/lib/model/job_name.dart generated Normal file
View File

@ -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;
}

View File

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

View File

@ -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
}); });

View File

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

View File

@ -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

View File

@ -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', () {
}); });

View File

@ -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

View File

@ -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(),
}; };

View File

@ -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);

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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 {}

View File

@ -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}`);
}
}
}

View File

@ -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;
}

View File

@ -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,

View File

@ -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';

View File

@ -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);
}
}

View File

@ -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,

View File

@ -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) @Processor(QueueName.OBJECT_TAGGING)
export class MachineLearningProcessor { export class ObjectTaggingProcessor {
constructor(private smartInfoService: SmartInfoService) {} constructor(private smartInfoService: SmartInfoService) {}
@Process({ name: JobName.IMAGE_TAGGING, concurrency: 1 }) @Process({ name: JobName.QUEUE_OBJECT_TAGGING, concurrency: 1 })
async onTagImage(job: Job<IAssetJob>) { async onQueueObjectTagging(job: Job<IBaseJob>) {
await this.smartInfoService.handleTagImage(job.data); await this.smartInfoService.handleQueueObjectTagging(job.data);
} }
@Process({ name: JobName.OBJECT_DETECTION, concurrency: 1 }) @Process({ name: JobName.DETECT_OBJECTS, concurrency: 1 })
async onDetectObject(job: Job<IAssetJob>) { 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);

View File

@ -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(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) jobRepository: IJobRepository, @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 {

View File

@ -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 { AssetEntity, AssetType } from '@app/infra';
import { IAssetJob, IAssetRepository, JobName, QueueName, SystemConfigService } from '@app/domain'; 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`;

View File

@ -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"
]
} }
} }
} }

View File

@ -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.');
}
}

View File

@ -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>;

View File

@ -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,

View File

@ -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';

View File

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

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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';

View File

@ -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', OBJECT_TAGGING = 'object-tagging-queue',
BACKGROUND_TASK = 'background-task', 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',
} }

View File

@ -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;

View File

@ -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.REVERSE_GEOCODING; data: IReverseGeocodingJob }
| { name: JobName.IMAGE_TAGGING; data: IAssetJob }
// 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>;

View File

@ -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();
});
});
});

View File

@ -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}`);
}
}
}

View File

@ -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;
}

View File

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

View File

@ -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.CLASSIFY_IMAGE, data: { asset } });
await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, 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.CLASSIFY_IMAGE, data: { asset } });
await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, 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));

View File

@ -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[]>;

View File

@ -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 {
import { IJobRepository } from '../job'; 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();
});
});
}); });

View File

@ -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,26 +10,24 @@ 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) {
const { asset } = data;
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
return;
}
try { try {
const tags = await this.machineLearning.tagImage({ thumbnailPath: asset.resizePath }); const assets = force
if (tags.length > 0) { ? await this.assetRepository.getAll()
await this.repository.upsert({ assetId: asset.id, tags }); : await this.assetRepository.getWithout(WithoutProperty.OBJECT_TAGS);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } });
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) { } catch (error: any) {
this.logger.error(`Unable to run image tagging pipeline: ${asset.id}`, error?.stack); this.logger.error(`Unable to queue object tagging`, error?.stack);
} }
} }
@ -50,6 +49,38 @@ export class SmartInfoService {
} }
} }
async handleClassifyImage(data: IAssetJob) {
const { asset } = data;
if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) {
return;
}
try {
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] } });
}
} catch (error: any) {
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);
}
}
async handleEncodeClip(data: IAssetJob) { async handleEncodeClip(data: IAssetJob) {
const { asset } = data; const { asset } = data;

View File

@ -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(),

View File

@ -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(),

View File

@ -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(),

View File

@ -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,
});
}
} }

View File

@ -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.OBJECT_TAGGING) private objectTagging: Queue<IAssetJob | IBaseJob>,
@InjectQueue(QueueName.METADATA_EXTRACTION) private metadataExtraction: Queue<IMetadataExtractionJob>, @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.THUMBNAIL_GENERATION) private generateThumbnail: Queue,
@InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue<IAssetJob>, @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.QUEUE_OBJECT_TAGGING:
case JobName.IMAGE_TAGGING: case JobName.DETECT_OBJECTS:
case JobName.ENCODE_CLIP: case JobName.CLASSIFY_IMAGE:
await this.machineLearning.add(item.name, item.data); await this.objectTagging.add(item.name, item.data);
break; break;
case JobName.QUEUE_ENCODE_CLIP:
case JobName.ENCODE_CLIP:
await this.clipEmbedding.add(item.name, item.data);
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');
}
}
} }

View File

@ -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);
} }

View File

@ -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 = { export const JobName = {
ThumbnailGeneration: 'thumbnail-generation', ThumbnailGenerationQueue: 'thumbnail-generation-queue',
MetadataExtraction: 'metadata-extraction', MetadataExtractionQueue: 'metadata-extraction-queue',
VideoConversion: 'video-conversion', VideoConversionQueue: 'video-conversion-queue',
MachineLearning: 'machine-learning', ObjectTaggingQueue: 'object-tagging-queue',
StorageTemplateMigration: 'storage-template-migration' 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));
} }
} }

View File

@ -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) => { const run = (force: boolean) => {
dispatch('click', { includeAllAssets }); dispatch('click', { force });
}; };
</script> </script>

View File

@ -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 ( function getJobLabel(jobName: JobName) {
jobId: JobId, const names: Record<JobName, string> = {
jobName: string, [JobName.ThumbnailGenerationQueue]: 'Generate Thumbnails',
emptyMessage: string, [JobName.MetadataExtractionQueue]: 'Extract Metadata',
includeAllAssets: boolean [JobName.VideoConversionQueue]: 'Transcode Videos',
) => { [JobName.ObjectTaggingQueue]: 'Tag Objects',
try { [JobName.ClipEncodingQueue]: 'Clip Encoding',
const { data } = await api.jobApi.sendJobCommand(jobId, { [JobName.BackgroundTaskQueue]: 'Background Task',
command: JobCommand.Start, [JobName.StorageTemplateMigrationQueue]: 'Storage Template Migration',
includeAllAssets [JobName.SearchQueue]: 'Search'
}); };
if (data) { return names[jobName];
notificationController.show({ }
message: includeAllAssets ? `Started ${jobName} for all assets` : `Started ${jobName}`,
type: NotificationType.Info const start = async (jobId: JobName, force: boolean) => {
}); const label = getJobLabel(jobId);
} else {
notificationController.show({ message: emptyMessage, type: NotificationType.Info }); try {
} await api.jobApi.sendJobCommand(jobId, { command: JobCommand.Start, force });
jobs[jobId].active += 1;
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'} title="Generate thumbnails"
subtitle={'Regenerate JPEG and WebP thumbnails'} subtitle="Regenerate JPEG and WebP thumbnails"
on:click={(e) => { on:click={(e) => start(JobName.ThumbnailGenerationQueue, e.detail.force)}
const { includeAllAssets } = e.detail; jobCounts={jobs[JobName.ThumbnailGenerationQueue]}
run(
JobId.ThumbnailGeneration,
'thumbnail generation',
'No missing thumbnails found',
includeAllAssets
);
}}
jobCounts={jobs[JobId.ThumbnailGeneration]}
/> />
<JobTile <JobTile
title={'EXTRACT METADATA'} title="Extract Metadata"
subtitle={'Extract metadata information i.e. GPS, resolution...etc'} subtitle="Extract metadata information i.e. GPS, resolution...etc"
on:click={(e) => { on:click={(e) => start(JobName.MetadataExtractionQueue, e.detail.force)}
const { includeAllAssets } = e.detail; jobCounts={jobs[JobName.MetadataExtractionQueue]}
run(JobId.MetadataExtraction, 'extract EXIF', 'No missing EXIF found', includeAllAssets);
}}
jobCounts={jobs[JobId.MetadataExtraction]}
/> />
<JobTile <JobTile
title={'Detect objects'} title="Tag Objects"
subtitle={'Run machine learning process to detect and classify objects'} subtitle="Run machine learning to tag objects"
on:click={(e) => { on:click={(e) => start(JobName.ObjectTaggingQueue, e.detail.force)}
const { includeAllAssets } = e.detail; jobCounts={jobs[JobName.ObjectTaggingQueue]}
run(
JobId.MachineLearning,
'object detection',
'No missing object detection found',
includeAllAssets
);
}}
jobCounts={jobs[JobId.MachineLearning]}
> >
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'} title="Encode Clip"
subtitle={'Transcode videos not in the desired format'} subtitle="Run machine learning to generate clip embeddings"
on:click={(e) => { on:click={(e) => start(JobName.ClipEncodingQueue, e.detail.force)}
const { includeAllAssets } = e.detail; jobCounts={jobs[JobName.ClipEncodingQueue]}
run(
JobId.VideoConversion,
'video conversion',
'No videos without an encoded version found',
includeAllAssets
);
}}
jobCounts={jobs[JobId.VideoConversion]}
/> />
<JobTile <JobTile
title={'Storage migration'} 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
title="Storage migration"
showOptions={false} showOptions={false}
subtitle={''} subtitle={''}
on:click={() => on:click={(e) => start(JobName.StorageTemplateMigrationQueue, e.detail.force)}
run( jobCounts={jobs[JobName.StorageTemplateMigrationQueue]}
JobId.StorageTemplateMigration,
'storage template migration',
'All files have been migrated to the new storage template',
false
)}
jobCounts={jobs[JobId.StorageTemplateMigration]}
> >
Apply the current Apply the current
<a <a