0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-21 00:52:43 -05:00

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

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

* chore: open api

* chore: tests
This commit is contained in:
Jason Rasmussen 2023-08-18 10:31:48 -04:00 committed by GitHub
parent 66490d5db4
commit 5e901e4d21
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 896 additions and 18 deletions

View file

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

View file

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

View file

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

View file

@ -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
mobile/openapi/doc/AssetJobName.md generated Normal file
View file

@ -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
mobile/openapi/doc/AssetJobsDto.md generated Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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