Browse Source

feat(web,server): run jobs for specific assets (#3712)

* feat(web,server): manually queue asset job

* chore: open api

* chore: tests
Jason Rasmussen 1 year ago
parent
commit
5e901e4d21

+ 124 - 0
cli/src/api/open-api/api.ts

@@ -525,6 +525,42 @@ export const AssetIdsResponseDtoErrorEnum = {
 
 export type AssetIdsResponseDtoErrorEnum = typeof AssetIdsResponseDtoErrorEnum[keyof typeof AssetIdsResponseDtoErrorEnum];
 
+/**
+ * 
+ * @export
+ * @enum {string}
+ */
+
+export const AssetJobName = {
+    RegenerateThumbnail: 'regenerate-thumbnail',
+    RefreshMetadata: 'refresh-metadata',
+    TranscodeVideo: 'transcode-video'
+} as const;
+
+export type AssetJobName = typeof AssetJobName[keyof typeof AssetJobName];
+
+
+/**
+ * 
+ * @export
+ * @interface AssetJobsDto
+ */
+export interface AssetJobsDto {
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof AssetJobsDto
+     */
+    'assetIds': Array<string>;
+    /**
+     * 
+     * @type {AssetJobName}
+     * @memberof AssetJobsDto
+     */
+    'name': AssetJobName;
+}
+
+
 /**
  * 
  * @export
@@ -5784,6 +5820,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 options: localVarRequestOptions,
             };
         },
+        /**
+         * 
+         * @param {AssetJobsDto} assetJobsDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        runAssetJobs: async (assetJobsDto: AssetJobsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'assetJobsDto' is not null or undefined
+            assertParamExists('runAssetJobs', 'assetJobsDto', assetJobsDto)
+            const localVarPath = `/asset/jobs`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(assetJobsDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
         /**
          * 
          * @param {SearchAssetDto} searchAssetDto 
@@ -6331,6 +6411,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {AssetJobsDto} assetJobsDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async runAssetJobs(assetJobsDto: AssetJobsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.runAssetJobs(assetJobsDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {SearchAssetDto} searchAssetDto 
@@ -6584,6 +6674,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig): AxiosPromise<AssetFileUploadResponseDto> {
             return localVarFp.importFile(requestParameters.importAssetDto, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        runAssetJobs(requestParameters: AssetApiRunAssetJobsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
+            return localVarFp.runAssetJobs(requestParameters.assetJobsDto, options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {AssetApiSearchAssetRequest} requestParameters Request parameters.
@@ -7066,6 +7165,20 @@ export interface AssetApiImportFileRequest {
     readonly importAssetDto: ImportAssetDto
 }
 
+/**
+ * Request parameters for runAssetJobs operation in AssetApi.
+ * @export
+ * @interface AssetApiRunAssetJobsRequest
+ */
+export interface AssetApiRunAssetJobsRequest {
+    /**
+     * 
+     * @type {AssetJobsDto}
+     * @memberof AssetApiRunAssetJobs
+     */
+    readonly assetJobsDto: AssetJobsDto
+}
+
 /**
  * Request parameters for searchAsset operation in AssetApi.
  * @export
@@ -7472,6 +7585,17 @@ export class AssetApi extends BaseAPI {
         return AssetApiFp(this.configuration).importFile(requestParameters.importAssetDto, options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public runAssetJobs(requestParameters: AssetApiRunAssetJobsRequest, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).runAssetJobs(requestParameters.assetJobsDto, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {AssetApiSearchAssetRequest} requestParameters Request parameters.

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

@@ -23,6 +23,8 @@ doc/AssetBulkUploadCheckResult.md
 doc/AssetFileUploadResponseDto.md
 doc/AssetIdsDto.md
 doc/AssetIdsResponseDto.md
+doc/AssetJobName.md
+doc/AssetJobsDto.md
 doc/AssetResponseDto.md
 doc/AssetStatsResponseDto.md
 doc/AssetTypeEnum.md
@@ -168,6 +170,8 @@ lib/model/asset_bulk_upload_check_result.dart
 lib/model/asset_file_upload_response_dto.dart
 lib/model/asset_ids_dto.dart
 lib/model/asset_ids_response_dto.dart
+lib/model/asset_job_name.dart
+lib/model/asset_jobs_dto.dart
 lib/model/asset_response_dto.dart
 lib/model/asset_stats_response_dto.dart
 lib/model/asset_type_enum.dart
@@ -282,6 +286,8 @@ test/asset_bulk_upload_check_result_test.dart
 test/asset_file_upload_response_dto_test.dart
 test/asset_ids_dto_test.dart
 test/asset_ids_response_dto_test.dart
+test/asset_job_name_test.dart
+test/asset_jobs_dto_test.dart
 test/asset_response_dto_test.dart
 test/asset_stats_response_dto_test.dart
 test/asset_type_enum_test.dart

+ 3 - 0
mobile/openapi/README.md

@@ -107,6 +107,7 @@ Class | Method | HTTP request | Description
 *AssetApi* | [**getTimeBuckets**](doc//AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | 
 *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 *AssetApi* | [**importFile**](doc//AssetApi.md#importfile) | **POST** /asset/import | 
+*AssetApi* | [**runAssetJobs**](doc//AssetApi.md#runassetjobs) | **POST** /asset/jobs | 
 *AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search | 
 *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 *AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} | 
@@ -197,6 +198,8 @@ Class | Method | HTTP request | Description
  - [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
  - [AssetIdsDto](doc//AssetIdsDto.md)
  - [AssetIdsResponseDto](doc//AssetIdsResponseDto.md)
+ - [AssetJobName](doc//AssetJobName.md)
+ - [AssetJobsDto](doc//AssetJobsDto.md)
  - [AssetResponseDto](doc//AssetResponseDto.md)
  - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md)
  - [AssetTypeEnum](doc//AssetTypeEnum.md)

+ 55 - 0
mobile/openapi/doc/AssetApi.md

@@ -29,6 +29,7 @@ Method | HTTP request | Description
 [**getTimeBuckets**](AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | 
 [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 [**importFile**](AssetApi.md#importfile) | **POST** /asset/import | 
+[**runAssetJobs**](AssetApi.md#runassetjobs) | **POST** /asset/jobs | 
 [**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search | 
 [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 [**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} | 
@@ -1192,6 +1193,60 @@ Name | Type | Description  | Notes
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
+# **runAssetJobs**
+> runAssetJobs(assetJobsDto)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = AssetApi();
+final assetJobsDto = AssetJobsDto(); // AssetJobsDto | 
+
+try {
+    api_instance.runAssetJobs(assetJobsDto);
+} catch (e) {
+    print('Exception when calling AssetApi->runAssetJobs: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **assetJobsDto** | [**AssetJobsDto**](AssetJobsDto.md)|  | 
+
+### Return type
+
+void (empty response body)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **Accept**: Not defined
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **searchAsset**
 > List<AssetResponseDto> searchAsset(searchAssetDto)
 

+ 14 - 0
mobile/openapi/doc/AssetJobName.md

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

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

@@ -0,0 +1,16 @@
+# openapi.model.AssetJobsDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**assetIds** | **List<String>** |  | [default to const []]
+**name** | [**AssetJobName**](AssetJobName.md) |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

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

@@ -60,6 +60,8 @@ part 'model/asset_bulk_upload_check_result.dart';
 part 'model/asset_file_upload_response_dto.dart';
 part 'model/asset_ids_dto.dart';
 part 'model/asset_ids_response_dto.dart';
+part 'model/asset_job_name.dart';
+part 'model/asset_jobs_dto.dart';
 part 'model/asset_response_dto.dart';
 part 'model/asset_stats_response_dto.dart';
 part 'model/asset_type_enum.dart';

+ 39 - 0
mobile/openapi/lib/api/asset_api.dart

@@ -1227,6 +1227,45 @@ class AssetApi {
     return null;
   }
 
+  /// Performs an HTTP 'POST /asset/jobs' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [AssetJobsDto] assetJobsDto (required):
+  Future<Response> runAssetJobsWithHttpInfo(AssetJobsDto assetJobsDto,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/asset/jobs';
+
+    // ignore: prefer_final_locals
+    Object? postBody = assetJobsDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'POST',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [AssetJobsDto] assetJobsDto (required):
+  Future<void> runAssetJobs(AssetJobsDto assetJobsDto,) async {
+    final response = await runAssetJobsWithHttpInfo(assetJobsDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+
   /// Performs an HTTP 'POST /asset/search' operation and returns the [Response].
   /// Parameters:
   ///

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

@@ -215,6 +215,10 @@ class ApiClient {
           return AssetIdsDto.fromJson(value);
         case 'AssetIdsResponseDto':
           return AssetIdsResponseDto.fromJson(value);
+        case 'AssetJobName':
+          return AssetJobNameTypeTransformer().decode(value);
+        case 'AssetJobsDto':
+          return AssetJobsDto.fromJson(value);
         case 'AssetResponseDto':
           return AssetResponseDto.fromJson(value);
         case 'AssetStatsResponseDto':

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

@@ -55,6 +55,9 @@ String parameterToString(dynamic value) {
   if (value is DateTime) {
     return value.toUtc().toIso8601String();
   }
+  if (value is AssetJobName) {
+    return AssetJobNameTypeTransformer().encode(value).toString();
+  }
   if (value is AssetTypeEnum) {
     return AssetTypeEnumTypeTransformer().encode(value).toString();
   }

+ 88 - 0
mobile/openapi/lib/model/asset_job_name.dart

@@ -0,0 +1,88 @@
+//
+// 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 AssetJobName {
+  /// Instantiate a new enum with the provided [value].
+  const AssetJobName._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const regenerateThumbnail = AssetJobName._(r'regenerate-thumbnail');
+  static const refreshMetadata = AssetJobName._(r'refresh-metadata');
+  static const transcodeVideo = AssetJobName._(r'transcode-video');
+
+  /// List of all possible values in this [enum][AssetJobName].
+  static const values = <AssetJobName>[
+    regenerateThumbnail,
+    refreshMetadata,
+    transcodeVideo,
+  ];
+
+  static AssetJobName? fromJson(dynamic value) => AssetJobNameTypeTransformer().decode(value);
+
+  static List<AssetJobName>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <AssetJobName>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = AssetJobName.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [AssetJobName] to String,
+/// and [decode] dynamic data back to [AssetJobName].
+class AssetJobNameTypeTransformer {
+  factory AssetJobNameTypeTransformer() => _instance ??= const AssetJobNameTypeTransformer._();
+
+  const AssetJobNameTypeTransformer._();
+
+  String encode(AssetJobName data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a AssetJobName.
+  ///
+  /// 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.
+  AssetJobName? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data) {
+        case r'regenerate-thumbnail': return AssetJobName.regenerateThumbnail;
+        case r'refresh-metadata': return AssetJobName.refreshMetadata;
+        case r'transcode-video': return AssetJobName.transcodeVideo;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [AssetJobNameTypeTransformer] instance.
+  static AssetJobNameTypeTransformer? _instance;
+}
+

+ 108 - 0
mobile/openapi/lib/model/asset_jobs_dto.dart

@@ -0,0 +1,108 @@
+//
+// 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 AssetJobsDto {
+  /// Returns a new [AssetJobsDto] instance.
+  AssetJobsDto({
+    this.assetIds = const [],
+    required this.name,
+  });
+
+  List<String> assetIds;
+
+  AssetJobName name;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is AssetJobsDto &&
+     other.assetIds == assetIds &&
+     other.name == name;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (assetIds.hashCode) +
+    (name.hashCode);
+
+  @override
+  String toString() => 'AssetJobsDto[assetIds=$assetIds, name=$name]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'assetIds'] = this.assetIds;
+      json[r'name'] = this.name;
+    return json;
+  }
+
+  /// Returns a new [AssetJobsDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static AssetJobsDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return AssetJobsDto(
+        assetIds: json[r'assetIds'] is List
+            ? (json[r'assetIds'] as List).cast<String>()
+            : const [],
+        name: AssetJobName.fromJson(json[r'name'])!,
+      );
+    }
+    return null;
+  }
+
+  static List<AssetJobsDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <AssetJobsDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = AssetJobsDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, AssetJobsDto> mapFromJson(dynamic json) {
+    final map = <String, AssetJobsDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = AssetJobsDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of AssetJobsDto-objects as value to a dart map
+  static Map<String, List<AssetJobsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<AssetJobsDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = AssetJobsDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'assetIds',
+    'name',
+  };
+}
+

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

@@ -129,6 +129,11 @@ void main() {
       // TODO
     });
 
+    //Future runAssetJobs(AssetJobsDto assetJobsDto) async
+    test('test runAssetJobs', () async {
+      // TODO
+    });
+
     //Future<List<AssetResponseDto>> searchAsset(SearchAssetDto searchAssetDto) async
     test('test searchAsset', () async {
       // TODO

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

@@ -0,0 +1,21 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for AssetJobName
+void main() {
+
+  group('test AssetJobName', () {
+
+  });
+
+}

+ 32 - 0
mobile/openapi/test/asset_jobs_dto_test.dart

@@ -0,0 +1,32 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for AssetJobsDto
+void main() {
+  // final instance = AssetJobsDto();
+
+  group('test AssetJobsDto', () {
+    // List<String> assetIds (default value: const [])
+    test('to test the property `assetIds`', () async {
+      // TODO
+    });
+
+    // AssetJobName name
+    test('to test the property `name`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 62 - 0
server/immich-openapi-specs.json

@@ -1367,6 +1367,41 @@
         ]
       }
     },
+    "/asset/jobs": {
+      "post": {
+        "operationId": "runAssetJobs",
+        "parameters": [],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/AssetJobsDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "204": {
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Asset"
+        ]
+      }
+    },
     "/asset/map-marker": {
       "get": {
         "operationId": "getMapMarkers",
@@ -5042,6 +5077,33 @@
         ],
         "type": "object"
       },
+      "AssetJobName": {
+        "enum": [
+          "regenerate-thumbnail",
+          "refresh-metadata",
+          "transcode-video"
+        ],
+        "type": "string"
+      },
+      "AssetJobsDto": {
+        "properties": {
+          "assetIds": {
+            "items": {
+              "format": "uuid",
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "name": {
+            "$ref": "#/components/schemas/AssetJobName"
+          }
+        },
+        "required": [
+          "assetIds",
+          "name"
+        ],
+        "type": "object"
+      },
       "AssetResponseDto": {
         "properties": {
           "checksum": {

+ 26 - 2
server/src/domain/asset/asset.service.spec.ts

@@ -7,15 +7,17 @@ import {
   newAccessRepositoryMock,
   newAssetRepositoryMock,
   newCryptoRepositoryMock,
+  newJobRepositoryMock,
   newStorageRepositoryMock,
 } from '@test';
 import { when } from 'jest-when';
 import { Readable } from 'stream';
 import { ICryptoRepository } from '../crypto';
+import { IJobRepository, JobName } from '../index';
 import { IStorageRepository } from '../storage';
 import { AssetStats, IAssetRepository } from './asset.repository';
 import { AssetService, UploadFieldName } from './asset.service';
-import { AssetStatsResponseDto, DownloadResponseDto } from './dto';
+import { AssetJobName, AssetStatsResponseDto, DownloadResponseDto } from './dto';
 import { mapAsset } from './response-dto';
 
 const downloadResponse: DownloadResponseDto = {
@@ -145,6 +147,7 @@ describe(AssetService.name, () => {
   let accessMock: IAccessRepositoryMock;
   let assetMock: jest.Mocked<IAssetRepository>;
   let cryptoMock: jest.Mocked<ICryptoRepository>;
+  let jobMock: jest.Mocked<IJobRepository>;
   let storageMock: jest.Mocked<IStorageRepository>;
 
   it('should work', () => {
@@ -155,8 +158,9 @@ describe(AssetService.name, () => {
     accessMock = newAccessRepositoryMock();
     assetMock = newAssetRepositoryMock();
     cryptoMock = newCryptoRepositoryMock();
+    jobMock = newJobRepositoryMock();
     storageMock = newStorageRepositoryMock();
-    sut = new AssetService(accessMock, assetMock, cryptoMock, storageMock);
+    sut = new AssetService(accessMock, assetMock, cryptoMock, jobMock, storageMock);
   });
 
   describe('canUpload', () => {
@@ -532,4 +536,24 @@ describe(AssetService.name, () => {
       expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
     });
   });
+
+  describe('run', () => {
+    it('should run the refresh metadata job', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+      await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA }),
+        expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } });
+    });
+
+    it('should run the refresh thumbnails job', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+      await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }),
+        expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1' } });
+    });
+
+    it('should run the transcode video', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+      await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.TRANSCODE_VIDEO }),
+        expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } });
+    });
+  });
 });

+ 24 - 0
server/src/domain/asset/asset.service.ts

@@ -8,11 +8,14 @@ import { AuthUserDto } from '../auth';
 import { ICryptoRepository } from '../crypto';
 import { mimeTypes } from '../domain.constant';
 import { HumanReadableSize, usePagination } from '../domain.util';
+import { IJobRepository, JobName } from '../job';
 import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage';
 import { IAssetRepository } from './asset.repository';
 import {
   AssetBulkUpdateDto,
   AssetIdsDto,
+  AssetJobName,
+  AssetJobsDto,
   DownloadArchiveInfo,
   DownloadInfoDto,
   DownloadResponseDto,
@@ -54,6 +57,7 @@ export class AssetService {
     @Inject(IAccessRepository) accessRepository: IAccessRepository,
     @Inject(IAssetRepository) private assetRepository: IAssetRepository,
     @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
+    @Inject(IJobRepository) private jobRepository: IJobRepository,
     @Inject(IStorageRepository) private storageRepository: IStorageRepository,
   ) {
     this.access = new AccessCore(accessRepository);
@@ -275,4 +279,24 @@ export class AssetService {
     await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
     await this.assetRepository.updateAll(ids, options);
   }
+
+  async run(authUser: AuthUserDto, dto: AssetJobsDto) {
+    await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds);
+
+    for (const id of dto.assetIds) {
+      switch (dto.name) {
+        case AssetJobName.REFRESH_METADATA:
+          await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id } });
+          break;
+
+        case AssetJobName.REGENERATE_THUMBNAIL:
+          await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id } });
+          break;
+
+        case AssetJobName.TRANSCODE_VIDEO:
+          await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id } });
+          break;
+      }
+    }
+  }
 }

+ 14 - 0
server/src/domain/asset/dto/asset-ids.dto.ts

@@ -1,6 +1,20 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsEnum } from 'class-validator';
 import { ValidateUUID } from '../../domain.util';
 
 export class AssetIdsDto {
   @ValidateUUID({ each: true })
   assetIds!: string[];
 }
+
+export enum AssetJobName {
+  REGENERATE_THUMBNAIL = 'regenerate-thumbnail',
+  REFRESH_METADATA = 'refresh-metadata',
+  TRANSCODE_VIDEO = 'transcode-video',
+}
+
+export class AssetJobsDto extends AssetIdsDto {
+  @ApiProperty({ enumName: 'AssetJobName', enum: AssetJobName })
+  @IsEnum(AssetJobName)
+  name!: AssetJobName;
+}

+ 7 - 0
server/src/immich/controllers/asset.controller.ts

@@ -1,6 +1,7 @@
 import {
   AssetBulkUpdateDto,
   AssetIdsDto,
+  AssetJobsDto,
   AssetResponseDto,
   AssetService,
   AssetStatsDto,
@@ -78,6 +79,12 @@ export class AssetController {
     return this.service.getByTimeBucket(authUser, dto);
   }
 
+  @Post('jobs')
+  @HttpCode(HttpStatus.NO_CONTENT)
+  runAssetJobs(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetJobsDto): Promise<void> {
+    return this.service.run(authUser, dto);
+  }
+
   @Put()
   @HttpCode(HttpStatus.NO_CONTENT)
   updateAssets(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetBulkUpdateDto): Promise<void> {

+ 21 - 0
web/src/api/api.ts

@@ -3,6 +3,7 @@ import {
   APIKeyApi,
   AssetApi,
   AssetApiFp,
+  AssetJobName,
   AuthenticationApi,
   Configuration,
   ConfigurationParameters,
@@ -120,6 +121,26 @@ export class ImmichApi {
 
     return names[jobName];
   }
+
+  public getAssetJobName(job: AssetJobName) {
+    const names: Record<AssetJobName, string> = {
+      [AssetJobName.RefreshMetadata]: 'Refresh metadata',
+      [AssetJobName.RegenerateThumbnail]: 'Refresh thumbnails',
+      [AssetJobName.TranscodeVideo]: 'Refresh encoded videos',
+    };
+
+    return names[job];
+  }
+
+  public getAssetJobMessage(job: AssetJobName) {
+    const messages: Record<AssetJobName, string> = {
+      [AssetJobName.RefreshMetadata]: 'Refreshing metadata',
+      [AssetJobName.RegenerateThumbnail]: `Regenerating thumbnails`,
+      [AssetJobName.TranscodeVideo]: `Refreshing encoded video`,
+    };
+
+    return messages[job];
+  }
 }
 
 export const api = new ImmichApi({ basePath: '/api' });

+ 124 - 0
web/src/api/open-api/api.ts

@@ -525,6 +525,42 @@ export const AssetIdsResponseDtoErrorEnum = {
 
 export type AssetIdsResponseDtoErrorEnum = typeof AssetIdsResponseDtoErrorEnum[keyof typeof AssetIdsResponseDtoErrorEnum];
 
+/**
+ * 
+ * @export
+ * @enum {string}
+ */
+
+export const AssetJobName = {
+    RegenerateThumbnail: 'regenerate-thumbnail',
+    RefreshMetadata: 'refresh-metadata',
+    TranscodeVideo: 'transcode-video'
+} as const;
+
+export type AssetJobName = typeof AssetJobName[keyof typeof AssetJobName];
+
+
+/**
+ * 
+ * @export
+ * @interface AssetJobsDto
+ */
+export interface AssetJobsDto {
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof AssetJobsDto
+     */
+    'assetIds': Array<string>;
+    /**
+     * 
+     * @type {AssetJobName}
+     * @memberof AssetJobsDto
+     */
+    'name': AssetJobName;
+}
+
+
 /**
  * 
  * @export
@@ -5784,6 +5820,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 options: localVarRequestOptions,
             };
         },
+        /**
+         * 
+         * @param {AssetJobsDto} assetJobsDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        runAssetJobs: async (assetJobsDto: AssetJobsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'assetJobsDto' is not null or undefined
+            assertParamExists('runAssetJobs', 'assetJobsDto', assetJobsDto)
+            const localVarPath = `/asset/jobs`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(assetJobsDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
         /**
          * 
          * @param {SearchAssetDto} searchAssetDto 
@@ -6331,6 +6411,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {AssetJobsDto} assetJobsDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async runAssetJobs(assetJobsDto: AssetJobsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.runAssetJobs(assetJobsDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {SearchAssetDto} searchAssetDto 
@@ -6584,6 +6674,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig): AxiosPromise<AssetFileUploadResponseDto> {
             return localVarFp.importFile(requestParameters.importAssetDto, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        runAssetJobs(requestParameters: AssetApiRunAssetJobsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
+            return localVarFp.runAssetJobs(requestParameters.assetJobsDto, options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {AssetApiSearchAssetRequest} requestParameters Request parameters.
@@ -7066,6 +7165,20 @@ export interface AssetApiImportFileRequest {
     readonly importAssetDto: ImportAssetDto
 }
 
+/**
+ * Request parameters for runAssetJobs operation in AssetApi.
+ * @export
+ * @interface AssetApiRunAssetJobsRequest
+ */
+export interface AssetApiRunAssetJobsRequest {
+    /**
+     * 
+     * @type {AssetJobsDto}
+     * @memberof AssetApiRunAssetJobs
+     */
+    readonly assetJobsDto: AssetJobsDto
+}
+
 /**
  * Request parameters for searchAsset operation in AssetApi.
  * @export
@@ -7472,6 +7585,17 @@ export class AssetApi extends BaseAPI {
         return AssetApiFp(this.configuration).importFile(requestParameters.importAssetDto, options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public runAssetJobs(requestParameters: AssetApiRunAssetJobsRequest, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).runAssetJobs(requestParameters.assetJobsDto, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {AssetApiSearchAssetRequest} requestParameters Request parameters.

+ 48 - 15
web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte

@@ -1,7 +1,7 @@
 <script lang="ts">
   import { page } from '$app/stores';
   import { clickOutside } from '$lib/utils/click-outside';
-  import type { AssetResponseDto } from '@api';
+  import { AssetJobName, AssetResponseDto, AssetTypeEnum, api } from '@api';
   import { createEventDispatcher } from 'svelte';
   import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
   import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
@@ -29,7 +29,22 @@
 
   const isOwner = asset.ownerId === $page.data.user?.id;
 
-  const dispatch = createEventDispatcher();
+  type MenuItemEvent = 'addToAlbum' | 'addToSharedAlbum' | 'asProfileImage' | 'runJob';
+
+  const dispatch = createEventDispatcher<{
+    goBack: void;
+    stopMotionPhoto: void;
+    playMotionPhoto: void;
+    download: void;
+    showDetail: void;
+    favorite: void;
+    delete: void;
+    toggleArchive: void;
+    addToAlbum: void;
+    addToSharedAlbum: void;
+    asProfileImage: void;
+    runJob: AssetJobName;
+  }>();
 
   let contextMenuPosition = { x: 0, y: 0 };
   let isShowAssetOptions = false;
@@ -39,7 +54,12 @@
     isShowAssetOptions = !isShowAssetOptions;
   };
 
-  const onMenuClick = (eventName: string) => {
+  const onJobClick = (name: AssetJobName) => {
+    isShowAssetOptions = false;
+    dispatch('runJob', name);
+  };
+
+  const onMenuClick = (eventName: MenuItemEvent) => {
     isShowAssetOptions = false;
     dispatch(eventName);
   };
@@ -114,22 +134,35 @@
     {#if isOwner}
       <CircleIconButton isOpacity={true} logo={DeleteOutline} on:click={() => dispatch('delete')} title="Delete" />
       <div use:clickOutside on:outclick={() => (isShowAssetOptions = false)}>
-        <CircleIconButton isOpacity={true} logo={DotsVertical} on:click={showOptionsMenu} title="More">
-          {#if isShowAssetOptions}
-            <ContextMenu {...contextMenuPosition} direction="left">
-              <MenuOption on:click={() => onMenuClick('addToAlbum')} text="Add to Album" />
-              <MenuOption on:click={() => onMenuClick('addToSharedAlbum')} text="Add to Shared Album" />
+        <CircleIconButton isOpacity={true} logo={DotsVertical} on:click={showOptionsMenu} title="More" />
+        {#if isShowAssetOptions}
+          <ContextMenu {...contextMenuPosition} direction="left">
+            <MenuOption on:click={() => onMenuClick('addToAlbum')} text="Add to Album" />
+            <MenuOption on:click={() => onMenuClick('addToSharedAlbum')} text="Add to Shared Album" />
 
-              {#if isOwner}
+            {#if isOwner}
+              <MenuOption
+                on:click={() => dispatch('toggleArchive')}
+                text={asset.isArchived ? 'Unarchive' : 'Archive'}
+              />
+              <MenuOption on:click={() => onMenuClick('asProfileImage')} text="As profile picture" />
+              <MenuOption
+                on:click={() => onJobClick(AssetJobName.RefreshMetadata)}
+                text={api.getAssetJobName(AssetJobName.RefreshMetadata)}
+              />
+              <MenuOption
+                on:click={() => onJobClick(AssetJobName.RegenerateThumbnail)}
+                text={api.getAssetJobName(AssetJobName.RegenerateThumbnail)}
+              />
+              {#if asset.type === AssetTypeEnum.Video}
                 <MenuOption
-                  on:click={() => dispatch('toggleArchive')}
-                  text={asset.isArchived ? 'Unarchive' : 'Archive'}
+                  on:click={() => onJobClick(AssetJobName.TranscodeVideo)}
+                  text={api.getAssetJobName(AssetJobName.TranscodeVideo)}
                 />
               {/if}
-              <MenuOption on:click={() => onMenuClick('asProfileImage')} text="As profile picture" />
-            </ContextMenu>
-          {/if}
-        </CircleIconButton>
+            {/if}
+          </ContextMenu>
+        {/if}
       </div>
     {/if}
   </div>

+ 11 - 1
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -1,6 +1,6 @@
 <script lang="ts">
   import { goto } from '$app/navigation';
-  import { AlbumResponseDto, api, AssetResponseDto, AssetTypeEnum, SharedLinkResponseDto } from '@api';
+  import { AlbumResponseDto, api, AssetJobName, AssetResponseDto, AssetTypeEnum, SharedLinkResponseDto } from '@api';
   import { createEventDispatcher, onDestroy, onMount } from 'svelte';
   import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
   import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
@@ -245,6 +245,15 @@
         return 'Asset';
     }
   };
+
+  const handleRunJob = async (name: AssetJobName) => {
+    try {
+      await api.assetApi.runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
+      notificationController.show({ type: NotificationType.Info, message: api.getAssetJobMessage(name) });
+    } catch (error) {
+      handleError(error, `Unable to submit job`);
+    }
+  };
 </script>
 
 <section
@@ -270,6 +279,7 @@
       on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
       on:toggleArchive={toggleArchive}
       on:asProfileImage={() => (isShowProfileImageCrop = true)}
+      on:runJob={({ detail: job }) => handleRunJob(job)}
     />
   </div>
 

+ 37 - 0
web/src/lib/components/photos-page/actions/asset-job-actions.svelte

@@ -0,0 +1,37 @@
+<script lang="ts">
+  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
+  import {
+    NotificationType,
+    notificationController,
+  } from '$lib/components/shared-components/notification/notification';
+  import { handleError } from '$lib/utils/handle-error';
+  import { AssetJobName, AssetTypeEnum, api } from '@api';
+  import { getAssetControlContext } from '../asset-select-control-bar.svelte';
+
+  export let jobs: AssetJobName[] = [
+    AssetJobName.RegenerateThumbnail,
+    AssetJobName.RefreshMetadata,
+    AssetJobName.TranscodeVideo,
+  ];
+
+  const { getAssets, clearSelect } = getAssetControlContext();
+
+  $: isAllVideos = Array.from(getAssets()).every((asset) => asset.type === AssetTypeEnum.Video);
+
+  const handleRunJob = async (name: AssetJobName) => {
+    try {
+      const ids = Array.from(getAssets()).map(({ id }) => id);
+      await api.assetApi.runAssetJobs({ assetJobsDto: { assetIds: ids, name } });
+      notificationController.show({ message: api.getAssetJobMessage(name), type: NotificationType.Info });
+      clearSelect();
+    } catch (error) {
+      handleError(error, 'Unable to submit job');
+    }
+  };
+</script>
+
+{#each jobs as job}
+  {#if isAllVideos || job !== AssetJobName.TranscodeVideo}
+    <MenuOption text={api.getAssetJobName(job)} on:click={() => handleRunJob(job)} />
+  {/if}
+{/each}

+ 2 - 0
web/src/routes/(user)/photos/+page.svelte

@@ -2,6 +2,7 @@
   import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
   import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
   import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
+  import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
   import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
   import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
   import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
@@ -52,6 +53,7 @@
           <FavoriteAction menuItem removeFavorite={isAllFavorite} />
           <DownloadAction menuItem />
           <ArchiveAction menuItem onArchive={(ids) => assetStore.removeAssets(ids)} />
+          <AssetJobActions />
         </AssetSelectContextMenu>
       </AssetSelectControlBar>
     {/if}