mirror of
https://github.com/immich-app/immich.git
synced 2026-07-19 21:33:11 +03:00
Compare commits
4 Commits
feat/serve
...
feat/workf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
024b7a0930 | ||
|
|
ba848dcd99 | ||
|
|
792c8f2691 | ||
|
|
3b79b471cb |
@@ -50,7 +50,6 @@
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"structured-headers": "^2.0.2",
|
||||
"supertest": "^7.0.0",
|
||||
"typescript": "^6.0.0",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -704,16 +704,6 @@ export const utils = {
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
downloadAsset: async (accessToken: string, id: string) => {
|
||||
const downloadedRes = await fetch(`${baseUrl}/api/assets/${id}/original`, {
|
||||
headers: asBearerAuth(accessToken),
|
||||
});
|
||||
if (!downloadedRes.ok) {
|
||||
throw new Error(`Failed to download asset ${id}: ${downloadedRes.status} ${await downloadedRes.text()}`);
|
||||
}
|
||||
return await downloadedRes.blob();
|
||||
},
|
||||
};
|
||||
|
||||
utils.initSdk();
|
||||
|
||||
@@ -1787,6 +1787,7 @@
|
||||
"restore_all": "Restore all",
|
||||
"restore_user": "Restore user",
|
||||
"restored_asset": "Restored asset",
|
||||
"result": "Result",
|
||||
"resume": "Resume",
|
||||
"resume_paused_jobs": "Resume {count, plural, one {# paused job} other {# paused jobs}}",
|
||||
"review_duplicates": "Review duplicates",
|
||||
@@ -2273,6 +2274,13 @@
|
||||
"workflow_info": "Workflow info",
|
||||
"workflow_json": "Workflow JSON",
|
||||
"workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.",
|
||||
"workflow_logging_completed": "Completed",
|
||||
"workflow_logging_disable": "Disable logging",
|
||||
"workflow_logging_disabled_description": "Logging is currently disabled for this workflow.",
|
||||
"workflow_logging_enable": "Enable logging",
|
||||
"workflow_logging_error_step": "Error on step #{step}",
|
||||
"workflow_logging_halted": "Halted",
|
||||
"workflow_logging_halted_step": "Halted on step #{step}",
|
||||
"workflow_name": "Workflow name",
|
||||
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
|
||||
"workflow_summary": "Workflow summary",
|
||||
|
||||
@@ -41,6 +41,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
||||
'SyncAssetV1': {'isEdited': false},
|
||||
'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false},
|
||||
'MemoriesResponse': {'duration': 5},
|
||||
'WorkflowResponseDto': {'logging': false},
|
||||
};
|
||||
|
||||
void upgradeDto(dynamic value, String targetType) {
|
||||
|
||||
11
mobile/openapi/README.md
generated
11
mobile/openapi/README.md
generated
@@ -308,11 +308,6 @@ Class | Method | HTTP request | Description
|
||||
*TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty | Empty trash
|
||||
*TrashApi* | [**restoreAssets**](doc//TrashApi.md#restoreassets) | **POST** /trash/restore/assets | Restore assets
|
||||
*TrashApi* | [**restoreTrash**](doc//TrashApi.md#restoretrash) | **POST** /trash/restore | Restore trash
|
||||
*UploadApi* | [**cancelUpload**](doc//UploadApi.md#cancelupload) | **DELETE** /upload/{id} |
|
||||
*UploadApi* | [**getUploadOptions**](doc//UploadApi.md#getuploadoptions) | **OPTIONS** /upload |
|
||||
*UploadApi* | [**getUploadStatus**](doc//UploadApi.md#getuploadstatus) | **HEAD** /upload/{id} |
|
||||
*UploadApi* | [**resumeUpload**](doc//UploadApi.md#resumeupload) | **PATCH** /upload/{id} |
|
||||
*UploadApi* | [**startUpload**](doc//UploadApi.md#startupload) | **POST** /upload |
|
||||
*UsersApi* | [**createProfileImage**](doc//UsersApi.md#createprofileimage) | **POST** /users/profile-image | Create user profile image
|
||||
*UsersApi* | [**deleteProfileImage**](doc//UsersApi.md#deleteprofileimage) | **DELETE** /users/profile-image | Delete user profile image
|
||||
*UsersApi* | [**deleteUserLicense**](doc//UsersApi.md#deleteuserlicense) | **DELETE** /users/me/license | Delete user product key
|
||||
@@ -346,6 +341,7 @@ Class | Method | HTTP request | Description
|
||||
*WorkflowsApi* | [**deleteWorkflow**](doc//WorkflowsApi.md#deleteworkflow) | **DELETE** /workflows/{id} | Delete a workflow
|
||||
*WorkflowsApi* | [**getWorkflow**](doc//WorkflowsApi.md#getworkflow) | **GET** /workflows/{id} | Retrieve a workflow
|
||||
*WorkflowsApi* | [**getWorkflowForShare**](doc//WorkflowsApi.md#getworkflowforshare) | **GET** /workflows/{id}/share | Retrieve a workflow
|
||||
*WorkflowsApi* | [**getWorkflowLogs**](doc//WorkflowsApi.md#getworkflowlogs) | **GET** /workflows/{id}/logs | Retrieve a workflow
|
||||
*WorkflowsApi* | [**getWorkflowTriggers**](doc//WorkflowsApi.md#getworkflowtriggers) | **GET** /workflows/triggers | List all workflow triggers
|
||||
*WorkflowsApi* | [**searchWorkflows**](doc//WorkflowsApi.md#searchworkflows) | **GET** /workflows | List all workflows
|
||||
*WorkflowsApi* | [**updateWorkflow**](doc//WorkflowsApi.md#updateworkflow) | **PUT** /workflows/{id} | Update a workflow
|
||||
@@ -640,7 +636,6 @@ Class | Method | HTTP request | Description
|
||||
- [SyncUserMetadataV1](doc//SyncUserMetadataV1.md)
|
||||
- [SyncUserV1](doc//SyncUserV1.md)
|
||||
- [SystemConfigBackupsDto](doc//SystemConfigBackupsDto.md)
|
||||
- [SystemConfigBackupsDtoUpload](doc//SystemConfigBackupsDtoUpload.md)
|
||||
- [SystemConfigDto](doc//SystemConfigDto.md)
|
||||
- [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md)
|
||||
- [SystemConfigFFmpegRealtimeDto](doc//SystemConfigFFmpegRealtimeDto.md)
|
||||
@@ -696,7 +691,6 @@ Class | Method | HTTP request | Description
|
||||
- [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md)
|
||||
- [UpdateAssetDto](doc//UpdateAssetDto.md)
|
||||
- [UpdateLibraryDto](doc//UpdateLibraryDto.md)
|
||||
- [UploadOkDto](doc//UploadOkDto.md)
|
||||
- [UsageByUserDto](doc//UsageByUserDto.md)
|
||||
- [UserAdminCreateDto](doc//UserAdminCreateDto.md)
|
||||
- [UserAdminDeleteDto](doc//UserAdminDeleteDto.md)
|
||||
@@ -718,7 +712,10 @@ Class | Method | HTTP request | Description
|
||||
- [VideoCodec](doc//VideoCodec.md)
|
||||
- [VideoContainer](doc//VideoContainer.md)
|
||||
- [WorkflowCreateDto](doc//WorkflowCreateDto.md)
|
||||
- [WorkflowLogEntryDto](doc//WorkflowLogEntryDto.md)
|
||||
- [WorkflowLogEntryDtoLastStep](doc//WorkflowLogEntryDtoLastStep.md)
|
||||
- [WorkflowResponseDto](doc//WorkflowResponseDto.md)
|
||||
- [WorkflowResult](doc//WorkflowResult.md)
|
||||
- [WorkflowShareResponseDto](doc//WorkflowShareResponseDto.md)
|
||||
- [WorkflowShareStepDto](doc//WorkflowShareStepDto.md)
|
||||
- [WorkflowStepDto](doc//WorkflowStepDto.md)
|
||||
|
||||
6
mobile/openapi/lib/api.dart
generated
6
mobile/openapi/lib/api.dart
generated
@@ -64,7 +64,6 @@ part 'api/system_metadata_api.dart';
|
||||
part 'api/tags_api.dart';
|
||||
part 'api/timeline_api.dart';
|
||||
part 'api/trash_api.dart';
|
||||
part 'api/upload_api.dart';
|
||||
part 'api/users_api.dart';
|
||||
part 'api/users_admin_api.dart';
|
||||
part 'api/views_api.dart';
|
||||
@@ -357,7 +356,6 @@ part 'model/sync_user_metadata_delete_v1.dart';
|
||||
part 'model/sync_user_metadata_v1.dart';
|
||||
part 'model/sync_user_v1.dart';
|
||||
part 'model/system_config_backups_dto.dart';
|
||||
part 'model/system_config_backups_dto_upload.dart';
|
||||
part 'model/system_config_dto.dart';
|
||||
part 'model/system_config_f_fmpeg_dto.dart';
|
||||
part 'model/system_config_f_fmpeg_realtime_dto.dart';
|
||||
@@ -413,7 +411,6 @@ part 'model/update_album_dto.dart';
|
||||
part 'model/update_album_user_dto.dart';
|
||||
part 'model/update_asset_dto.dart';
|
||||
part 'model/update_library_dto.dart';
|
||||
part 'model/upload_ok_dto.dart';
|
||||
part 'model/usage_by_user_dto.dart';
|
||||
part 'model/user_admin_create_dto.dart';
|
||||
part 'model/user_admin_delete_dto.dart';
|
||||
@@ -435,7 +432,10 @@ part 'model/version_check_state_response_dto.dart';
|
||||
part 'model/video_codec.dart';
|
||||
part 'model/video_container.dart';
|
||||
part 'model/workflow_create_dto.dart';
|
||||
part 'model/workflow_log_entry_dto.dart';
|
||||
part 'model/workflow_log_entry_dto_last_step.dart';
|
||||
part 'model/workflow_response_dto.dart';
|
||||
part 'model/workflow_result.dart';
|
||||
part 'model/workflow_share_response_dto.dart';
|
||||
part 'model/workflow_share_step_dto.dart';
|
||||
part 'model/workflow_step_dto.dart';
|
||||
|
||||
364
mobile/openapi/lib/api/upload_api.dart
generated
364
mobile/openapi/lib/api/upload_api.dart
generated
@@ -1,364 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// 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 UploadApi {
|
||||
UploadApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Performs an HTTP 'DELETE /upload/{id}' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<Response> cancelUploadWithHttpInfo(String id, { String? key, String? slug, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/upload/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (slug != null) {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'DELETE',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<void> cancelUpload(String id, { String? key, String? slug, Future<void>? abortTrigger, }) async {
|
||||
final response = await cancelUploadWithHttpInfo(id, key: key, slug: slug, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'OPTIONS /upload' operation and returns the [Response].
|
||||
Future<Response> getUploadOptionsWithHttpInfo({ Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/upload';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'OPTIONS',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> getUploadOptions({ Future<void>? abortTrigger, }) async {
|
||||
final response = await getUploadOptionsWithHttpInfo(abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'HEAD /upload/{id}' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] uploadDraftInteropVersion (required):
|
||||
/// Indicates the version of the RUFH protocol supported by the client.
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<Response> getUploadStatusWithHttpInfo(String id, String uploadDraftInteropVersion, { String? key, String? slug, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/upload/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (slug != null) {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
headerParams[r'upload-draft-interop-version'] = parameterToString(uploadDraftInteropVersion);
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'HEAD',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] uploadDraftInteropVersion (required):
|
||||
/// Indicates the version of the RUFH protocol supported by the client.
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<void> getUploadStatus(String id, String uploadDraftInteropVersion, { String? key, String? slug, Future<void>? abortTrigger, }) async {
|
||||
final response = await getUploadStatusWithHttpInfo(id, uploadDraftInteropVersion, key: key, slug: slug, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'PATCH /upload/{id}' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] contentLength (required):
|
||||
/// Non-negative size of the request body in bytes.
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] uploadComplete (required):
|
||||
/// Structured boolean indicating whether this request completes the file. Use Upload-Incomplete instead for version <= 3.
|
||||
///
|
||||
/// * [String] uploadDraftInteropVersion (required):
|
||||
/// Indicates the version of the RUFH protocol supported by the client.
|
||||
///
|
||||
/// * [String] uploadOffset (required):
|
||||
/// Non-negative byte offset indicating the starting position of the data in the request body within the entire file.
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<Response> resumeUploadWithHttpInfo(String contentLength, String id, String uploadComplete, String uploadDraftInteropVersion, String uploadOffset, { String? key, String? slug, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/upload/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (slug != null) {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
headerParams[r'content-length'] = parameterToString(contentLength);
|
||||
headerParams[r'upload-complete'] = parameterToString(uploadComplete);
|
||||
headerParams[r'upload-draft-interop-version'] = parameterToString(uploadDraftInteropVersion);
|
||||
headerParams[r'upload-offset'] = parameterToString(uploadOffset);
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PATCH',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] contentLength (required):
|
||||
/// Non-negative size of the request body in bytes.
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] uploadComplete (required):
|
||||
/// Structured boolean indicating whether this request completes the file. Use Upload-Incomplete instead for version <= 3.
|
||||
///
|
||||
/// * [String] uploadDraftInteropVersion (required):
|
||||
/// Indicates the version of the RUFH protocol supported by the client.
|
||||
///
|
||||
/// * [String] uploadOffset (required):
|
||||
/// Non-negative byte offset indicating the starting position of the data in the request body within the entire file.
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<UploadOkDto?> resumeUpload(String contentLength, String id, String uploadComplete, String uploadDraftInteropVersion, String uploadOffset, { String? key, String? slug, Future<void>? abortTrigger, }) async {
|
||||
final response = await resumeUploadWithHttpInfo(contentLength, id, uploadComplete, uploadDraftInteropVersion, uploadOffset, key: key, slug: slug, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UploadOkDto',) as UploadOkDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'POST /upload' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] contentLength (required):
|
||||
/// Non-negative size of the request body in bytes.
|
||||
///
|
||||
/// * [String] reprDigest (required):
|
||||
/// RFC 9651 structured dictionary containing an `sha` (bytesequence) checksum used to detect duplicate files and validate data integrity.
|
||||
///
|
||||
/// * [String] xImmichAssetData (required):
|
||||
/// RFC 9651 structured dictionary containing asset metadata with the following keys: - device-asset-id (string, required): Unique device asset identifier - device-id (string, required): Device identifier - file-created-at (string/date, required): ISO 8601 date string or Unix timestamp - file-modified-at (string/date, required): ISO 8601 date string or Unix timestamp - filename (string, required): Original filename - is-favorite (boolean, optional): Favorite status - live-photo-video-id (string, optional): Live photo ID for assets from iOS devices - icloud-id (string, optional): iCloud identifier for assets from iOS devices
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
///
|
||||
/// * [String] uploadComplete:
|
||||
/// Structured boolean indicating whether this request completes the file. Use Upload-Incomplete instead for version <= 3.
|
||||
///
|
||||
/// * [String] uploadDraftInteropVersion:
|
||||
/// Indicates the version of the RUFH protocol supported by the client.
|
||||
Future<Response> startUploadWithHttpInfo(String contentLength, String reprDigest, String xImmichAssetData, { String? key, String? slug, String? uploadComplete, String? uploadDraftInteropVersion, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/upload';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (slug != null) {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
headerParams[r'content-length'] = parameterToString(contentLength);
|
||||
headerParams[r'repr-digest'] = parameterToString(reprDigest);
|
||||
if (uploadComplete != null) {
|
||||
headerParams[r'upload-complete'] = parameterToString(uploadComplete);
|
||||
}
|
||||
if (uploadDraftInteropVersion != null) {
|
||||
headerParams[r'upload-draft-interop-version'] = parameterToString(uploadDraftInteropVersion);
|
||||
}
|
||||
headerParams[r'x-immich-asset-data'] = parameterToString(xImmichAssetData);
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] contentLength (required):
|
||||
/// Non-negative size of the request body in bytes.
|
||||
///
|
||||
/// * [String] reprDigest (required):
|
||||
/// RFC 9651 structured dictionary containing an `sha` (bytesequence) checksum used to detect duplicate files and validate data integrity.
|
||||
///
|
||||
/// * [String] xImmichAssetData (required):
|
||||
/// RFC 9651 structured dictionary containing asset metadata with the following keys: - device-asset-id (string, required): Unique device asset identifier - device-id (string, required): Device identifier - file-created-at (string/date, required): ISO 8601 date string or Unix timestamp - file-modified-at (string/date, required): ISO 8601 date string or Unix timestamp - filename (string, required): Original filename - is-favorite (boolean, optional): Favorite status - live-photo-video-id (string, optional): Live photo ID for assets from iOS devices - icloud-id (string, optional): iCloud identifier for assets from iOS devices
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
///
|
||||
/// * [String] uploadComplete:
|
||||
/// Structured boolean indicating whether this request completes the file. Use Upload-Incomplete instead for version <= 3.
|
||||
///
|
||||
/// * [String] uploadDraftInteropVersion:
|
||||
/// Indicates the version of the RUFH protocol supported by the client.
|
||||
Future<UploadOkDto?> startUpload(String contentLength, String reprDigest, String xImmichAssetData, { String? key, String? slug, String? uploadComplete, String? uploadDraftInteropVersion, Future<void>? abortTrigger, }) async {
|
||||
final response = await startUploadWithHttpInfo(contentLength, reprDigest, xImmichAssetData, key: key, slug: slug, uploadComplete: uploadComplete, uploadDraftInteropVersion: uploadDraftInteropVersion, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UploadOkDto',) as UploadOkDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
104
mobile/openapi/lib/api/workflows_api.dart
generated
104
mobile/openapi/lib/api/workflows_api.dart
generated
@@ -239,6 +239,95 @@ class WorkflowsApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Retrieve a workflow
|
||||
///
|
||||
/// Retrieve a workflow details without ids, default values, etc.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [DateTime] before:
|
||||
/// Filter by runs before a date/time
|
||||
///
|
||||
/// * [int] limit:
|
||||
/// Maximum number of logs
|
||||
///
|
||||
/// * [WorkflowResult] result:
|
||||
/// Filter by run result
|
||||
Future<Response> getWorkflowLogsWithHttpInfo(String id, { DateTime? before, int? limit, WorkflowResult? result, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/workflows/{id}/logs'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (before != null) {
|
||||
queryParams.addAll(_queryParams('', 'before', before));
|
||||
}
|
||||
if (limit != null) {
|
||||
queryParams.addAll(_queryParams('', 'limit', limit));
|
||||
}
|
||||
if (result != null) {
|
||||
queryParams.addAll(_queryParams('', 'result', result));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Retrieve a workflow
|
||||
///
|
||||
/// Retrieve a workflow details without ids, default values, etc.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [DateTime] before:
|
||||
/// Filter by runs before a date/time
|
||||
///
|
||||
/// * [int] limit:
|
||||
/// Maximum number of logs
|
||||
///
|
||||
/// * [WorkflowResult] result:
|
||||
/// Filter by run result
|
||||
Future<List<WorkflowLogEntryDto>?> getWorkflowLogs(String id, { DateTime? before, int? limit, WorkflowResult? result, Future<void>? abortTrigger, }) async {
|
||||
final response = await getWorkflowLogsWithHttpInfo(id, before: before, limit: limit, result: result, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
return (await apiClient.deserializeAsync(responseBody, 'List<WorkflowLogEntryDto>') as List)
|
||||
.cast<WorkflowLogEntryDto>()
|
||||
.toList(growable: false);
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// List all workflow triggers
|
||||
///
|
||||
/// Retrieve a list of all available workflow triggers.
|
||||
@@ -308,12 +397,15 @@ class WorkflowsApi {
|
||||
/// * [String] id:
|
||||
/// Workflow ID
|
||||
///
|
||||
/// * [bool] logging:
|
||||
/// Workflow logs run results
|
||||
///
|
||||
/// * [String] name:
|
||||
/// Workflow name
|
||||
///
|
||||
/// * [WorkflowTrigger] trigger:
|
||||
/// Workflow trigger type
|
||||
Future<Response> searchWorkflowsWithHttpInfo({ String? description, bool? enabled, String? id, String? name, WorkflowTrigger? trigger, Future<void>? abortTrigger, }) async {
|
||||
Future<Response> searchWorkflowsWithHttpInfo({ String? description, bool? enabled, String? id, bool? logging, String? name, WorkflowTrigger? trigger, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/workflows';
|
||||
|
||||
@@ -333,6 +425,9 @@ class WorkflowsApi {
|
||||
if (id != null) {
|
||||
queryParams.addAll(_queryParams('', 'id', id));
|
||||
}
|
||||
if (logging != null) {
|
||||
queryParams.addAll(_queryParams('', 'logging', logging));
|
||||
}
|
||||
if (name != null) {
|
||||
queryParams.addAll(_queryParams('', 'name', name));
|
||||
}
|
||||
@@ -370,13 +465,16 @@ class WorkflowsApi {
|
||||
/// * [String] id:
|
||||
/// Workflow ID
|
||||
///
|
||||
/// * [bool] logging:
|
||||
/// Workflow logs run results
|
||||
///
|
||||
/// * [String] name:
|
||||
/// Workflow name
|
||||
///
|
||||
/// * [WorkflowTrigger] trigger:
|
||||
/// Workflow trigger type
|
||||
Future<List<WorkflowResponseDto>?> searchWorkflows({ String? description, bool? enabled, String? id, String? name, WorkflowTrigger? trigger, Future<void>? abortTrigger, }) async {
|
||||
final response = await searchWorkflowsWithHttpInfo(description: description, enabled: enabled, id: id, name: name, trigger: trigger, abortTrigger: abortTrigger,);
|
||||
Future<List<WorkflowResponseDto>?> searchWorkflows({ String? description, bool? enabled, String? id, bool? logging, String? name, WorkflowTrigger? trigger, Future<void>? abortTrigger, }) async {
|
||||
final response = await searchWorkflowsWithHttpInfo(description: description, enabled: enabled, id: id, logging: logging, name: name, trigger: trigger, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
10
mobile/openapi/lib/api_client.dart
generated
10
mobile/openapi/lib/api_client.dart
generated
@@ -757,8 +757,6 @@ class ApiClient {
|
||||
return SyncUserV1.fromJson(value);
|
||||
case 'SystemConfigBackupsDto':
|
||||
return SystemConfigBackupsDto.fromJson(value);
|
||||
case 'SystemConfigBackupsDtoUpload':
|
||||
return SystemConfigBackupsDtoUpload.fromJson(value);
|
||||
case 'SystemConfigDto':
|
||||
return SystemConfigDto.fromJson(value);
|
||||
case 'SystemConfigFFmpegDto':
|
||||
@@ -869,8 +867,6 @@ class ApiClient {
|
||||
return UpdateAssetDto.fromJson(value);
|
||||
case 'UpdateLibraryDto':
|
||||
return UpdateLibraryDto.fromJson(value);
|
||||
case 'UploadOkDto':
|
||||
return UploadOkDto.fromJson(value);
|
||||
case 'UsageByUserDto':
|
||||
return UsageByUserDto.fromJson(value);
|
||||
case 'UserAdminCreateDto':
|
||||
@@ -913,8 +909,14 @@ class ApiClient {
|
||||
return VideoContainerTypeTransformer().decode(value);
|
||||
case 'WorkflowCreateDto':
|
||||
return WorkflowCreateDto.fromJson(value);
|
||||
case 'WorkflowLogEntryDto':
|
||||
return WorkflowLogEntryDto.fromJson(value);
|
||||
case 'WorkflowLogEntryDtoLastStep':
|
||||
return WorkflowLogEntryDtoLastStep.fromJson(value);
|
||||
case 'WorkflowResponseDto':
|
||||
return WorkflowResponseDto.fromJson(value);
|
||||
case 'WorkflowResult':
|
||||
return WorkflowResultTypeTransformer().decode(value);
|
||||
case 'WorkflowShareResponseDto':
|
||||
return WorkflowShareResponseDto.fromJson(value);
|
||||
case 'WorkflowShareStepDto':
|
||||
|
||||
3
mobile/openapi/lib/api_helper.dart
generated
3
mobile/openapi/lib/api_helper.dart
generated
@@ -214,6 +214,9 @@ String parameterToString(dynamic value) {
|
||||
if (value is VideoContainer) {
|
||||
return VideoContainerTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is WorkflowResult) {
|
||||
return WorkflowResultTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is WorkflowTrigger) {
|
||||
return WorkflowTriggerTypeTransformer().encode(value).toString();
|
||||
}
|
||||
|
||||
6
mobile/openapi/lib/model/job_name.dart
generated
6
mobile/openapi/lib/model/job_name.dart
generated
@@ -38,8 +38,6 @@ class JobName {
|
||||
static const assetFileMigration = JobName._(r'AssetFileMigration');
|
||||
static const assetGenerateThumbnailsQueueAll = JobName._(r'AssetGenerateThumbnailsQueueAll');
|
||||
static const assetGenerateThumbnails = JobName._(r'AssetGenerateThumbnails');
|
||||
static const partialAssetCleanup = JobName._(r'PartialAssetCleanup');
|
||||
static const partialAssetCleanupQueueAll = JobName._(r'PartialAssetCleanupQueueAll');
|
||||
static const auditTableCleanup = JobName._(r'AuditTableCleanup');
|
||||
static const databaseBackup = JobName._(r'DatabaseBackup');
|
||||
static const facialRecognitionQueueAll = JobName._(r'FacialRecognitionQueueAll');
|
||||
@@ -109,8 +107,6 @@ class JobName {
|
||||
assetFileMigration,
|
||||
assetGenerateThumbnailsQueueAll,
|
||||
assetGenerateThumbnails,
|
||||
partialAssetCleanup,
|
||||
partialAssetCleanupQueueAll,
|
||||
auditTableCleanup,
|
||||
databaseBackup,
|
||||
facialRecognitionQueueAll,
|
||||
@@ -215,8 +211,6 @@ class JobNameTypeTransformer {
|
||||
case r'AssetFileMigration': return JobName.assetFileMigration;
|
||||
case r'AssetGenerateThumbnailsQueueAll': return JobName.assetGenerateThumbnailsQueueAll;
|
||||
case r'AssetGenerateThumbnails': return JobName.assetGenerateThumbnails;
|
||||
case r'PartialAssetCleanup': return JobName.partialAssetCleanup;
|
||||
case r'PartialAssetCleanupQueueAll': return JobName.partialAssetCleanupQueueAll;
|
||||
case r'AuditTableCleanup': return JobName.auditTableCleanup;
|
||||
case r'DatabaseBackup': return JobName.databaseBackup;
|
||||
case r'FacialRecognitionQueueAll': return JobName.facialRecognitionQueueAll;
|
||||
|
||||
3
mobile/openapi/lib/model/permission.dart
generated
3
mobile/openapi/lib/model/permission.dart
generated
@@ -172,6 +172,7 @@ class Permission {
|
||||
static const workflowPeriodRead = Permission._(r'workflow.read');
|
||||
static const workflowPeriodUpdate = Permission._(r'workflow.update');
|
||||
static const workflowPeriodDelete = Permission._(r'workflow.delete');
|
||||
static const workflowPeriodLogs = Permission._(r'workflow.logs');
|
||||
static const adminUserPeriodCreate = Permission._(r'adminUser.create');
|
||||
static const adminUserPeriodRead = Permission._(r'adminUser.read');
|
||||
static const adminUserPeriodUpdate = Permission._(r'adminUser.update');
|
||||
@@ -330,6 +331,7 @@ class Permission {
|
||||
workflowPeriodRead,
|
||||
workflowPeriodUpdate,
|
||||
workflowPeriodDelete,
|
||||
workflowPeriodLogs,
|
||||
adminUserPeriodCreate,
|
||||
adminUserPeriodRead,
|
||||
adminUserPeriodUpdate,
|
||||
@@ -523,6 +525,7 @@ class PermissionTypeTransformer {
|
||||
case r'workflow.read': return Permission.workflowPeriodRead;
|
||||
case r'workflow.update': return Permission.workflowPeriodUpdate;
|
||||
case r'workflow.delete': return Permission.workflowPeriodDelete;
|
||||
case r'workflow.logs': return Permission.workflowPeriodLogs;
|
||||
case r'adminUser.create': return Permission.adminUserPeriodCreate;
|
||||
case r'adminUser.read': return Permission.adminUserPeriodRead;
|
||||
case r'adminUser.update': return Permission.adminUserPeriodUpdate;
|
||||
|
||||
@@ -14,31 +14,25 @@ class SystemConfigBackupsDto {
|
||||
/// Returns a new [SystemConfigBackupsDto] instance.
|
||||
SystemConfigBackupsDto({
|
||||
required this.database,
|
||||
required this.upload,
|
||||
});
|
||||
|
||||
DatabaseBackupConfig database;
|
||||
|
||||
SystemConfigBackupsDtoUpload upload;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigBackupsDto &&
|
||||
other.database == database &&
|
||||
other.upload == upload;
|
||||
other.database == database;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(database.hashCode) +
|
||||
(upload.hashCode);
|
||||
(database.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigBackupsDto[database=$database, upload=$upload]';
|
||||
String toString() => 'SystemConfigBackupsDto[database=$database]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'database'] = this.database;
|
||||
json[r'upload'] = this.upload;
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -52,7 +46,6 @@ class SystemConfigBackupsDto {
|
||||
|
||||
return SystemConfigBackupsDto(
|
||||
database: DatabaseBackupConfig.fromJson(json[r'database'])!,
|
||||
upload: SystemConfigBackupsDtoUpload.fromJson(json[r'upload'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -101,7 +94,6 @@ class SystemConfigBackupsDto {
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'database',
|
||||
'upload',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// 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 SystemConfigBackupsDtoUpload {
|
||||
/// Returns a new [SystemConfigBackupsDtoUpload] instance.
|
||||
SystemConfigBackupsDtoUpload({
|
||||
required this.maxAgeHours,
|
||||
});
|
||||
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 9007199254740991
|
||||
int maxAgeHours;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigBackupsDtoUpload &&
|
||||
other.maxAgeHours == maxAgeHours;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(maxAgeHours.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigBackupsDtoUpload[maxAgeHours=$maxAgeHours]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'maxAgeHours'] = this.maxAgeHours;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SystemConfigBackupsDtoUpload] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SystemConfigBackupsDtoUpload? fromJson(dynamic value) {
|
||||
upgradeDto(value, "SystemConfigBackupsDtoUpload");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SystemConfigBackupsDtoUpload(
|
||||
maxAgeHours: mapValueOfType<int>(json, r'maxAgeHours')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SystemConfigBackupsDtoUpload> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SystemConfigBackupsDtoUpload>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SystemConfigBackupsDtoUpload.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SystemConfigBackupsDtoUpload> mapFromJson(dynamic json) {
|
||||
final map = <String, SystemConfigBackupsDtoUpload>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SystemConfigBackupsDtoUpload.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SystemConfigBackupsDtoUpload-objects as value to a dart map
|
||||
static Map<String, List<SystemConfigBackupsDtoUpload>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SystemConfigBackupsDtoUpload>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SystemConfigBackupsDtoUpload.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'maxAgeHours',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ class SystemConfigNightlyTasksDto {
|
||||
required this.databaseCleanup,
|
||||
required this.generateMemories,
|
||||
required this.missingThumbnails,
|
||||
required this.removeStaleUploads,
|
||||
required this.startTime,
|
||||
required this.syncQuotaUsage,
|
||||
});
|
||||
@@ -34,9 +33,6 @@ class SystemConfigNightlyTasksDto {
|
||||
/// Missing thumbnails
|
||||
bool missingThumbnails;
|
||||
|
||||
/// Remove stale uploads
|
||||
bool removeStaleUploads;
|
||||
|
||||
/// Start time (HH:MM)
|
||||
String startTime;
|
||||
|
||||
@@ -49,7 +45,6 @@ class SystemConfigNightlyTasksDto {
|
||||
other.databaseCleanup == databaseCleanup &&
|
||||
other.generateMemories == generateMemories &&
|
||||
other.missingThumbnails == missingThumbnails &&
|
||||
other.removeStaleUploads == removeStaleUploads &&
|
||||
other.startTime == startTime &&
|
||||
other.syncQuotaUsage == syncQuotaUsage;
|
||||
|
||||
@@ -60,12 +55,11 @@ class SystemConfigNightlyTasksDto {
|
||||
(databaseCleanup.hashCode) +
|
||||
(generateMemories.hashCode) +
|
||||
(missingThumbnails.hashCode) +
|
||||
(removeStaleUploads.hashCode) +
|
||||
(startTime.hashCode) +
|
||||
(syncQuotaUsage.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigNightlyTasksDto[clusterNewFaces=$clusterNewFaces, databaseCleanup=$databaseCleanup, generateMemories=$generateMemories, missingThumbnails=$missingThumbnails, removeStaleUploads=$removeStaleUploads, startTime=$startTime, syncQuotaUsage=$syncQuotaUsage]';
|
||||
String toString() => 'SystemConfigNightlyTasksDto[clusterNewFaces=$clusterNewFaces, databaseCleanup=$databaseCleanup, generateMemories=$generateMemories, missingThumbnails=$missingThumbnails, startTime=$startTime, syncQuotaUsage=$syncQuotaUsage]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -73,7 +67,6 @@ class SystemConfigNightlyTasksDto {
|
||||
json[r'databaseCleanup'] = this.databaseCleanup;
|
||||
json[r'generateMemories'] = this.generateMemories;
|
||||
json[r'missingThumbnails'] = this.missingThumbnails;
|
||||
json[r'removeStaleUploads'] = this.removeStaleUploads;
|
||||
json[r'startTime'] = this.startTime;
|
||||
json[r'syncQuotaUsage'] = this.syncQuotaUsage;
|
||||
return json;
|
||||
@@ -92,7 +85,6 @@ class SystemConfigNightlyTasksDto {
|
||||
databaseCleanup: mapValueOfType<bool>(json, r'databaseCleanup')!,
|
||||
generateMemories: mapValueOfType<bool>(json, r'generateMemories')!,
|
||||
missingThumbnails: mapValueOfType<bool>(json, r'missingThumbnails')!,
|
||||
removeStaleUploads: mapValueOfType<bool>(json, r'removeStaleUploads')!,
|
||||
startTime: mapValueOfType<String>(json, r'startTime')!,
|
||||
syncQuotaUsage: mapValueOfType<bool>(json, r'syncQuotaUsage')!,
|
||||
);
|
||||
@@ -146,7 +138,6 @@ class SystemConfigNightlyTasksDto {
|
||||
'databaseCleanup',
|
||||
'generateMemories',
|
||||
'missingThumbnails',
|
||||
'removeStaleUploads',
|
||||
'startTime',
|
||||
'syncQuotaUsage',
|
||||
};
|
||||
|
||||
99
mobile/openapi/lib/model/upload_ok_dto.dart
generated
99
mobile/openapi/lib/model/upload_ok_dto.dart
generated
@@ -1,99 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// 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 UploadOkDto {
|
||||
/// Returns a new [UploadOkDto] instance.
|
||||
UploadOkDto({
|
||||
required this.id,
|
||||
});
|
||||
|
||||
String id;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is UploadOkDto &&
|
||||
other.id == id;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(id.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'UploadOkDto[id=$id]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'id'] = this.id;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [UploadOkDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static UploadOkDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "UploadOkDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return UploadOkDto(
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<UploadOkDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <UploadOkDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = UploadOkDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, UploadOkDto> mapFromJson(dynamic json) {
|
||||
final map = <String, UploadOkDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = UploadOkDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of UploadOkDto-objects as value to a dart map
|
||||
static Map<String, List<UploadOkDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<UploadOkDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = UploadOkDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'id',
|
||||
};
|
||||
}
|
||||
|
||||
19
mobile/openapi/lib/model/workflow_create_dto.dart
generated
19
mobile/openapi/lib/model/workflow_create_dto.dart
generated
@@ -15,6 +15,7 @@ class WorkflowCreateDto {
|
||||
WorkflowCreateDto({
|
||||
this.description = const Optional.absent(),
|
||||
this.enabled = const Optional.absent(),
|
||||
this.logging = const Optional.absent(),
|
||||
this.name = const Optional.absent(),
|
||||
this.steps = const Optional.present(const []),
|
||||
required this.trigger,
|
||||
@@ -32,6 +33,15 @@ class WorkflowCreateDto {
|
||||
///
|
||||
Optional<bool?> enabled;
|
||||
|
||||
/// Workflow logs run results
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
Optional<bool?> logging;
|
||||
|
||||
/// Workflow name
|
||||
Optional<String?> name;
|
||||
|
||||
@@ -43,6 +53,7 @@ class WorkflowCreateDto {
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowCreateDto &&
|
||||
other.description == description &&
|
||||
other.enabled == enabled &&
|
||||
other.logging == logging &&
|
||||
other.name == name &&
|
||||
_deepEquality.equals(other.steps, steps) &&
|
||||
other.trigger == trigger;
|
||||
@@ -52,12 +63,13 @@ class WorkflowCreateDto {
|
||||
// ignore: unnecessary_parenthesis
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(enabled == null ? 0 : enabled!.hashCode) +
|
||||
(logging == null ? 0 : logging!.hashCode) +
|
||||
(name == null ? 0 : name!.hashCode) +
|
||||
(steps.hashCode) +
|
||||
(trigger.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowCreateDto[description=$description, enabled=$enabled, name=$name, steps=$steps, trigger=$trigger]';
|
||||
String toString() => 'WorkflowCreateDto[description=$description, enabled=$enabled, logging=$logging, name=$name, steps=$steps, trigger=$trigger]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -69,6 +81,10 @@ class WorkflowCreateDto {
|
||||
final value = this.enabled.value;
|
||||
json[r'enabled'] = value;
|
||||
}
|
||||
if (this.logging.isPresent) {
|
||||
final value = this.logging.value;
|
||||
json[r'logging'] = value;
|
||||
}
|
||||
if (this.name.isPresent) {
|
||||
final value = this.name.value;
|
||||
json[r'name'] = value;
|
||||
@@ -92,6 +108,7 @@ class WorkflowCreateDto {
|
||||
return WorkflowCreateDto(
|
||||
description: json.containsKey(r'description') ? Optional.present(mapValueOfType<String>(json, r'description')) : const Optional.absent(),
|
||||
enabled: json.containsKey(r'enabled') ? Optional.present(mapValueOfType<bool>(json, r'enabled')) : const Optional.absent(),
|
||||
logging: json.containsKey(r'logging') ? Optional.present(mapValueOfType<bool>(json, r'logging')) : const Optional.absent(),
|
||||
name: json.containsKey(r'name') ? Optional.present(mapValueOfType<String>(json, r'name')) : const Optional.absent(),
|
||||
steps: json.containsKey(r'steps') ? Optional.present(WorkflowStepDto.listFromJson(json[r'steps'])) : const Optional.absent(),
|
||||
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
|
||||
|
||||
150
mobile/openapi/lib/model/workflow_log_entry_dto.dart
generated
Normal file
150
mobile/openapi/lib/model/workflow_log_entry_dto.dart
generated
Normal file
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// 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 WorkflowLogEntryDto {
|
||||
/// Returns a new [WorkflowLogEntryDto] instance.
|
||||
WorkflowLogEntryDto({
|
||||
required this.at,
|
||||
required this.id,
|
||||
this.lastStep = const Optional.absent(),
|
||||
required this.result,
|
||||
this.triggerDataId = const Optional.absent(),
|
||||
});
|
||||
|
||||
/// Workflow run date/time
|
||||
String at;
|
||||
|
||||
/// Workflow log entry ID
|
||||
String id;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
Optional<WorkflowLogEntryDtoLastStep?> lastStep;
|
||||
|
||||
WorkflowResult result;
|
||||
|
||||
/// Workflow trigger data ID
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
Optional<String?> triggerDataId;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowLogEntryDto &&
|
||||
other.at == at &&
|
||||
other.id == id &&
|
||||
other.lastStep == lastStep &&
|
||||
other.result == result &&
|
||||
other.triggerDataId == triggerDataId;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(at.hashCode) +
|
||||
(id.hashCode) +
|
||||
(lastStep == null ? 0 : lastStep!.hashCode) +
|
||||
(result.hashCode) +
|
||||
(triggerDataId == null ? 0 : triggerDataId!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowLogEntryDto[at=$at, id=$id, lastStep=$lastStep, result=$result, triggerDataId=$triggerDataId]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'at'] = this.at;
|
||||
json[r'id'] = this.id;
|
||||
if (this.lastStep.isPresent) {
|
||||
final value = this.lastStep.value;
|
||||
json[r'lastStep'] = value;
|
||||
}
|
||||
json[r'result'] = this.result;
|
||||
if (this.triggerDataId.isPresent) {
|
||||
final value = this.triggerDataId.value;
|
||||
json[r'triggerDataId'] = value;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [WorkflowLogEntryDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static WorkflowLogEntryDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "WorkflowLogEntryDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return WorkflowLogEntryDto(
|
||||
at: mapValueOfType<String>(json, r'at')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
lastStep: json.containsKey(r'lastStep') ? Optional.present(WorkflowLogEntryDtoLastStep.fromJson(json[r'lastStep'])) : const Optional.absent(),
|
||||
result: WorkflowResult.fromJson(json[r'result'])!,
|
||||
triggerDataId: json.containsKey(r'triggerDataId') ? Optional.present(mapValueOfType<String>(json, r'triggerDataId')) : const Optional.absent(),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<WorkflowLogEntryDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <WorkflowLogEntryDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = WorkflowLogEntryDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, WorkflowLogEntryDto> mapFromJson(dynamic json) {
|
||||
final map = <String, WorkflowLogEntryDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = WorkflowLogEntryDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of WorkflowLogEntryDto-objects as value to a dart map
|
||||
static Map<String, List<WorkflowLogEntryDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<WorkflowLogEntryDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = WorkflowLogEntryDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'at',
|
||||
'id',
|
||||
'result',
|
||||
};
|
||||
}
|
||||
|
||||
112
mobile/openapi/lib/model/workflow_log_entry_dto_last_step.dart
generated
Normal file
112
mobile/openapi/lib/model/workflow_log_entry_dto_last_step.dart
generated
Normal file
@@ -0,0 +1,112 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// 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 WorkflowLogEntryDtoLastStep {
|
||||
/// Returns a new [WorkflowLogEntryDtoLastStep] instance.
|
||||
WorkflowLogEntryDtoLastStep({
|
||||
required this.index,
|
||||
required this.method,
|
||||
});
|
||||
|
||||
/// Index of the step in the workflow
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int index;
|
||||
|
||||
/// Method of the step
|
||||
String method;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowLogEntryDtoLastStep &&
|
||||
other.index == index &&
|
||||
other.method == method;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(index.hashCode) +
|
||||
(method.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowLogEntryDtoLastStep[index=$index, method=$method]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'index'] = this.index;
|
||||
json[r'method'] = this.method;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [WorkflowLogEntryDtoLastStep] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static WorkflowLogEntryDtoLastStep? fromJson(dynamic value) {
|
||||
upgradeDto(value, "WorkflowLogEntryDtoLastStep");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return WorkflowLogEntryDtoLastStep(
|
||||
index: mapValueOfType<int>(json, r'index')!,
|
||||
method: mapValueOfType<String>(json, r'method')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<WorkflowLogEntryDtoLastStep> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <WorkflowLogEntryDtoLastStep>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = WorkflowLogEntryDtoLastStep.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, WorkflowLogEntryDtoLastStep> mapFromJson(dynamic json) {
|
||||
final map = <String, WorkflowLogEntryDtoLastStep>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = WorkflowLogEntryDtoLastStep.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of WorkflowLogEntryDtoLastStep-objects as value to a dart map
|
||||
static Map<String, List<WorkflowLogEntryDtoLastStep>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<WorkflowLogEntryDtoLastStep>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = WorkflowLogEntryDtoLastStep.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'index',
|
||||
'method',
|
||||
};
|
||||
}
|
||||
|
||||
11
mobile/openapi/lib/model/workflow_response_dto.dart
generated
11
mobile/openapi/lib/model/workflow_response_dto.dart
generated
@@ -17,6 +17,7 @@ class WorkflowResponseDto {
|
||||
required this.description,
|
||||
required this.enabled,
|
||||
required this.id,
|
||||
required this.logging,
|
||||
required this.name,
|
||||
this.steps = const [],
|
||||
required this.trigger,
|
||||
@@ -35,6 +36,9 @@ class WorkflowResponseDto {
|
||||
/// Workflow ID
|
||||
String id;
|
||||
|
||||
/// Workflow logs run results
|
||||
bool logging;
|
||||
|
||||
/// Workflow name
|
||||
String? name;
|
||||
|
||||
@@ -52,6 +56,7 @@ class WorkflowResponseDto {
|
||||
other.description == description &&
|
||||
other.enabled == enabled &&
|
||||
other.id == id &&
|
||||
other.logging == logging &&
|
||||
other.name == name &&
|
||||
_deepEquality.equals(other.steps, steps) &&
|
||||
other.trigger == trigger &&
|
||||
@@ -64,13 +69,14 @@ class WorkflowResponseDto {
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(enabled.hashCode) +
|
||||
(id.hashCode) +
|
||||
(logging.hashCode) +
|
||||
(name == null ? 0 : name!.hashCode) +
|
||||
(steps.hashCode) +
|
||||
(trigger.hashCode) +
|
||||
(updatedAt.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowResponseDto[createdAt=$createdAt, description=$description, enabled=$enabled, id=$id, name=$name, steps=$steps, trigger=$trigger, updatedAt=$updatedAt]';
|
||||
String toString() => 'WorkflowResponseDto[createdAt=$createdAt, description=$description, enabled=$enabled, id=$id, logging=$logging, name=$name, steps=$steps, trigger=$trigger, updatedAt=$updatedAt]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -82,6 +88,7 @@ class WorkflowResponseDto {
|
||||
}
|
||||
json[r'enabled'] = this.enabled;
|
||||
json[r'id'] = this.id;
|
||||
json[r'logging'] = this.logging;
|
||||
if (this.name != null) {
|
||||
json[r'name'] = this.name;
|
||||
} else {
|
||||
@@ -106,6 +113,7 @@ class WorkflowResponseDto {
|
||||
description: mapValueOfType<String>(json, r'description'),
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
logging: mapValueOfType<bool>(json, r'logging')!,
|
||||
name: mapValueOfType<String>(json, r'name'),
|
||||
steps: WorkflowStepDto.listFromJson(json[r'steps']),
|
||||
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
|
||||
@@ -161,6 +169,7 @@ class WorkflowResponseDto {
|
||||
'description',
|
||||
'enabled',
|
||||
'id',
|
||||
'logging',
|
||||
'name',
|
||||
'steps',
|
||||
'trigger',
|
||||
|
||||
88
mobile/openapi/lib/model/workflow_result.dart
generated
Normal file
88
mobile/openapi/lib/model/workflow_result.dart
generated
Normal file
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// 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;
|
||||
|
||||
/// Workflow run result
|
||||
class WorkflowResult {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const WorkflowResult._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const completed = WorkflowResult._(r'completed');
|
||||
static const halted = WorkflowResult._(r'halted');
|
||||
static const error = WorkflowResult._(r'error');
|
||||
|
||||
/// List of all possible values in this [enum][WorkflowResult].
|
||||
static const values = <WorkflowResult>[
|
||||
completed,
|
||||
halted,
|
||||
error,
|
||||
];
|
||||
|
||||
static WorkflowResult? fromJson(dynamic value) => WorkflowResultTypeTransformer().decode(value);
|
||||
|
||||
static List<WorkflowResult> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <WorkflowResult>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = WorkflowResult.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [WorkflowResult] to String,
|
||||
/// and [decode] dynamic data back to [WorkflowResult].
|
||||
class WorkflowResultTypeTransformer {
|
||||
factory WorkflowResultTypeTransformer() => _instance ??= const WorkflowResultTypeTransformer._();
|
||||
|
||||
const WorkflowResultTypeTransformer._();
|
||||
|
||||
String encode(WorkflowResult data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a WorkflowResult.
|
||||
///
|
||||
/// 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.
|
||||
WorkflowResult? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'completed': return WorkflowResult.completed;
|
||||
case r'halted': return WorkflowResult.halted;
|
||||
case r'error': return WorkflowResult.error;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [WorkflowResultTypeTransformer] instance.
|
||||
static WorkflowResultTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
19
mobile/openapi/lib/model/workflow_update_dto.dart
generated
19
mobile/openapi/lib/model/workflow_update_dto.dart
generated
@@ -15,6 +15,7 @@ class WorkflowUpdateDto {
|
||||
WorkflowUpdateDto({
|
||||
this.description = const Optional.absent(),
|
||||
this.enabled = const Optional.absent(),
|
||||
this.logging = const Optional.absent(),
|
||||
this.name = const Optional.absent(),
|
||||
this.steps = const Optional.present(const []),
|
||||
this.trigger = const Optional.absent(),
|
||||
@@ -32,6 +33,15 @@ class WorkflowUpdateDto {
|
||||
///
|
||||
Optional<bool?> enabled;
|
||||
|
||||
/// Workflow logs run results
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
Optional<bool?> logging;
|
||||
|
||||
/// Workflow name
|
||||
Optional<String?> name;
|
||||
|
||||
@@ -49,6 +59,7 @@ class WorkflowUpdateDto {
|
||||
bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto &&
|
||||
other.description == description &&
|
||||
other.enabled == enabled &&
|
||||
other.logging == logging &&
|
||||
other.name == name &&
|
||||
_deepEquality.equals(other.steps, steps) &&
|
||||
other.trigger == trigger;
|
||||
@@ -58,12 +69,13 @@ class WorkflowUpdateDto {
|
||||
// ignore: unnecessary_parenthesis
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(enabled == null ? 0 : enabled!.hashCode) +
|
||||
(logging == null ? 0 : logging!.hashCode) +
|
||||
(name == null ? 0 : name!.hashCode) +
|
||||
(steps.hashCode) +
|
||||
(trigger == null ? 0 : trigger!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkflowUpdateDto[description=$description, enabled=$enabled, name=$name, steps=$steps, trigger=$trigger]';
|
||||
String toString() => 'WorkflowUpdateDto[description=$description, enabled=$enabled, logging=$logging, name=$name, steps=$steps, trigger=$trigger]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -75,6 +87,10 @@ class WorkflowUpdateDto {
|
||||
final value = this.enabled.value;
|
||||
json[r'enabled'] = value;
|
||||
}
|
||||
if (this.logging.isPresent) {
|
||||
final value = this.logging.value;
|
||||
json[r'logging'] = value;
|
||||
}
|
||||
if (this.name.isPresent) {
|
||||
final value = this.name.value;
|
||||
json[r'name'] = value;
|
||||
@@ -101,6 +117,7 @@ class WorkflowUpdateDto {
|
||||
return WorkflowUpdateDto(
|
||||
description: json.containsKey(r'description') ? Optional.present(mapValueOfType<String>(json, r'description')) : const Optional.absent(),
|
||||
enabled: json.containsKey(r'enabled') ? Optional.present(mapValueOfType<bool>(json, r'enabled')) : const Optional.absent(),
|
||||
logging: json.containsKey(r'logging') ? Optional.present(mapValueOfType<bool>(json, r'logging')) : const Optional.absent(),
|
||||
name: json.containsKey(r'name') ? Optional.present(mapValueOfType<String>(json, r'name')) : const Optional.absent(),
|
||||
steps: json.containsKey(r'steps') ? Optional.present(WorkflowStepDto.listFromJson(json[r'steps'])) : const Optional.absent(),
|
||||
trigger: json.containsKey(r'trigger') ? Optional.present(WorkflowTrigger.fromJson(json[r'trigger'])) : const Optional.absent(),
|
||||
|
||||
@@ -14819,321 +14819,6 @@
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
"/upload": {
|
||||
"options": {
|
||||
"operationId": "getUploadOptions",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Upload"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "startUpload",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "content-length",
|
||||
"in": "header",
|
||||
"description": "Non-negative size of the request body in bytes.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "repr-digest",
|
||||
"in": "header",
|
||||
"description": "RFC 9651 structured dictionary containing an `sha` (bytesequence) checksum used to detect duplicate files and validate data integrity.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upload-complete",
|
||||
"in": "header",
|
||||
"description": "Structured boolean indicating whether this request completes the file. Use Upload-Incomplete instead for version <= 3.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upload-draft-interop-version",
|
||||
"in": "header",
|
||||
"description": "Indicates the version of the RUFH protocol supported by the client.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x-immich-asset-data",
|
||||
"in": "header",
|
||||
"description": "RFC 9651 structured dictionary containing asset metadata with the following keys:\n- device-asset-id (string, required): Unique device asset identifier\n- device-id (string, required): Device identifier\n- file-created-at (string/date, required): ISO 8601 date string or Unix timestamp\n- file-modified-at (string/date, required): ISO 8601 date string or Unix timestamp\n- filename (string, required): Original filename\n- is-favorite (boolean, optional): Favorite status\n- live-photo-video-id (string, optional): Live photo ID for assets from iOS devices\n- icloud-id (string, optional): iCloud identifier for assets from iOS devices",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"example": "device-asset-id=\"abc123\", device-id=\"phone1\", filename=\"photo.jpg\", file-created-at=\"2024-01-01T00:00:00Z\", file-modified-at=\"2024-01-01T00:00:00Z\""
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UploadOkDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Upload"
|
||||
],
|
||||
"x-immich-permission": "asset.upload"
|
||||
}
|
||||
},
|
||||
"/upload/{id}": {
|
||||
"delete": {
|
||||
"operationId": "cancelUpload",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Upload"
|
||||
],
|
||||
"x-immich-permission": "asset.upload"
|
||||
},
|
||||
"head": {
|
||||
"operationId": "getUploadStatus",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upload-draft-interop-version",
|
||||
"in": "header",
|
||||
"description": "Indicates the version of the RUFH protocol supported by the client.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Upload"
|
||||
],
|
||||
"x-immich-permission": "asset.upload"
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "resumeUpload",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "content-length",
|
||||
"in": "header",
|
||||
"description": "Non-negative size of the request body in bytes.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upload-complete",
|
||||
"in": "header",
|
||||
"description": "Structured boolean indicating whether this request completes the file. Use Upload-Incomplete instead for version <= 3.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upload-draft-interop-version",
|
||||
"in": "header",
|
||||
"description": "Indicates the version of the RUFH protocol supported by the client.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upload-offset",
|
||||
"in": "header",
|
||||
"description": "Non-negative byte offset indicating the starting position of the data in the request body within the entire file.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UploadOkDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Upload"
|
||||
],
|
||||
"x-immich-permission": "asset.upload"
|
||||
}
|
||||
},
|
||||
"/users": {
|
||||
"get": {
|
||||
"description": "Retrieve a list of all users on the server.",
|
||||
@@ -16169,6 +15854,15 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "logging",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Workflow logs run results",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"required": false,
|
||||
@@ -16482,6 +16176,95 @@
|
||||
"x-immich-state": "Deprecated"
|
||||
}
|
||||
},
|
||||
"/workflows/{id}/logs": {
|
||||
"get": {
|
||||
"description": "Retrieve a workflow details without ids, default values, etc.",
|
||||
"operationId": "getWorkflowLogs",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "before",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter by runs before a date/time",
|
||||
"schema": {
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
|
||||
"example": "2024-01-01T00:00:00.000Z",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Maximum number of logs",
|
||||
"schema": {
|
||||
"maximum": 9007199254740991,
|
||||
"exclusiveMinimum": true,
|
||||
"default": 50,
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "result",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter by run result",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WorkflowResult"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WorkflowLogEntryDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Retrieve a workflow",
|
||||
"tags": [
|
||||
"Workflows"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3.0.0",
|
||||
"state": "Added"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "workflow.logs"
|
||||
}
|
||||
},
|
||||
"/workflows/{id}/share": {
|
||||
"get": {
|
||||
"description": "Retrieve a workflow details without ids, default values, etc.",
|
||||
@@ -19571,8 +19354,6 @@
|
||||
"AssetFileMigration",
|
||||
"AssetGenerateThumbnailsQueueAll",
|
||||
"AssetGenerateThumbnails",
|
||||
"PartialAssetCleanup",
|
||||
"PartialAssetCleanupQueueAll",
|
||||
"AuditTableCleanup",
|
||||
"DatabaseBackup",
|
||||
"FacialRecognitionQueueAll",
|
||||
@@ -21332,6 +21113,7 @@
|
||||
"workflow.read",
|
||||
"workflow.update",
|
||||
"workflow.delete",
|
||||
"workflow.logs",
|
||||
"adminUser.create",
|
||||
"adminUser.read",
|
||||
"adminUser.update",
|
||||
@@ -25862,24 +25644,10 @@
|
||||
"properties": {
|
||||
"database": {
|
||||
"$ref": "#/components/schemas/DatabaseBackupConfig"
|
||||
},
|
||||
"upload": {
|
||||
"properties": {
|
||||
"maxAgeHours": {
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"maxAgeHours"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"database",
|
||||
"upload"
|
||||
"database"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -26540,10 +26308,6 @@
|
||||
"description": "Missing thumbnails",
|
||||
"type": "boolean"
|
||||
},
|
||||
"removeStaleUploads": {
|
||||
"description": "Remove stale uploads",
|
||||
"type": "boolean"
|
||||
},
|
||||
"startTime": {
|
||||
"description": "Start time (HH:MM)",
|
||||
"pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d$",
|
||||
@@ -26559,7 +26323,6 @@
|
||||
"databaseCleanup",
|
||||
"generateMemories",
|
||||
"missingThumbnails",
|
||||
"removeStaleUploads",
|
||||
"startTime",
|
||||
"syncQuotaUsage"
|
||||
],
|
||||
@@ -27543,17 +27306,6 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"UploadOkDto": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UsageByUserDto": {
|
||||
"properties": {
|
||||
"photos": {
|
||||
@@ -28202,6 +27954,10 @@
|
||||
"description": "Workflow enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logging": {
|
||||
"description": "Workflow logs run results",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Workflow name",
|
||||
"nullable": true,
|
||||
@@ -28223,6 +27979,56 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"WorkflowLogEntryDto": {
|
||||
"properties": {
|
||||
"at": {
|
||||
"description": "Workflow run date/time",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"description": "Workflow log entry ID",
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
},
|
||||
"lastStep": {
|
||||
"description": "Last step ran, if the workflow ended early",
|
||||
"properties": {
|
||||
"index": {
|
||||
"description": "Index of the step in the workflow",
|
||||
"exclusiveMinimum": true,
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"method": {
|
||||
"description": "Method of the step",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"index"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"result": {
|
||||
"$ref": "#/components/schemas/WorkflowResult"
|
||||
},
|
||||
"triggerDataId": {
|
||||
"description": "Workflow trigger data ID",
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"at",
|
||||
"id",
|
||||
"result"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"WorkflowResponseDto": {
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
@@ -28244,6 +28050,10 @@
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
},
|
||||
"logging": {
|
||||
"description": "Workflow logs run results",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Workflow name",
|
||||
"nullable": true,
|
||||
@@ -28270,6 +28080,7 @@
|
||||
"description",
|
||||
"enabled",
|
||||
"id",
|
||||
"logging",
|
||||
"name",
|
||||
"steps",
|
||||
"trigger",
|
||||
@@ -28277,6 +28088,15 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"WorkflowResult": {
|
||||
"description": "Workflow run result",
|
||||
"enum": [
|
||||
"completed",
|
||||
"halted",
|
||||
"error"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"WorkflowShareResponseDto": {
|
||||
"properties": {
|
||||
"description": {
|
||||
@@ -28401,6 +28221,10 @@
|
||||
"description": "Workflow enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logging": {
|
||||
"description": "Workflow logs run results",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Workflow name",
|
||||
"nullable": true,
|
||||
|
||||
@@ -2298,9 +2298,6 @@ export type DatabaseBackupConfig = {
|
||||
};
|
||||
export type SystemConfigBackupsDto = {
|
||||
database: DatabaseBackupConfig;
|
||||
upload: {
|
||||
maxAgeHours: number;
|
||||
};
|
||||
};
|
||||
export type SystemConfigFFmpegRealtimeDto = {
|
||||
/** Enable real-time HLS transcoding (alpha) */
|
||||
@@ -2518,8 +2515,6 @@ export type SystemConfigNightlyTasksDto = {
|
||||
generateMemories: boolean;
|
||||
/** Missing thumbnails */
|
||||
missingThumbnails: boolean;
|
||||
/** Remove stale uploads */
|
||||
removeStaleUploads: boolean;
|
||||
/** Start time (HH:MM) */
|
||||
startTime: string;
|
||||
/** Sync quota usage */
|
||||
@@ -2748,9 +2743,6 @@ export type TrashResponseDto = {
|
||||
/** Number of items in trash */
|
||||
count: number;
|
||||
};
|
||||
export type UploadOkDto = {
|
||||
id: string;
|
||||
};
|
||||
export type UserUpdateMeDto = {
|
||||
avatarColor?: (UserAvatarColor) | null;
|
||||
/** User email */
|
||||
@@ -2799,6 +2791,8 @@ export type WorkflowResponseDto = {
|
||||
enabled: boolean;
|
||||
/** Workflow ID */
|
||||
id: string;
|
||||
/** Workflow logs run results */
|
||||
logging: boolean;
|
||||
/** Workflow name */
|
||||
name: string | null;
|
||||
/** Workflow steps */
|
||||
@@ -2813,6 +2807,8 @@ export type WorkflowCreateDto = {
|
||||
description?: string | null;
|
||||
/** Workflow enabled */
|
||||
enabled?: boolean;
|
||||
/** Workflow logs run results */
|
||||
logging?: boolean;
|
||||
/** Workflow name */
|
||||
name?: string | null;
|
||||
steps?: WorkflowStepDto[];
|
||||
@@ -2830,12 +2826,30 @@ export type WorkflowUpdateDto = {
|
||||
description?: string | null;
|
||||
/** Workflow enabled */
|
||||
enabled?: boolean;
|
||||
/** Workflow logs run results */
|
||||
logging?: boolean;
|
||||
/** Workflow name */
|
||||
name?: string | null;
|
||||
steps?: WorkflowStepDto[];
|
||||
/** Workflow trigger type */
|
||||
trigger?: WorkflowTrigger;
|
||||
};
|
||||
export type WorkflowLogEntryDto = {
|
||||
/** Workflow run date/time */
|
||||
at: string;
|
||||
/** Workflow log entry ID */
|
||||
id: string;
|
||||
/** Last step ran, if the workflow ended early */
|
||||
lastStep?: {
|
||||
/** Index of the step in the workflow */
|
||||
index: number;
|
||||
/** Method of the step */
|
||||
method: string;
|
||||
};
|
||||
result: WorkflowResult;
|
||||
/** Workflow trigger data ID */
|
||||
triggerDataId?: string;
|
||||
};
|
||||
export type WorkflowShareStepDto = {
|
||||
/** Step configuration */
|
||||
config: {
|
||||
@@ -6754,95 +6768,6 @@ export function restoreAssets({ bulkIdsDto }: {
|
||||
body: bulkIdsDto
|
||||
})));
|
||||
}
|
||||
export function getUploadOptions(opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText("/upload", {
|
||||
...opts,
|
||||
method: "OPTIONS"
|
||||
}));
|
||||
}
|
||||
export function startUpload({ contentLength, key, reprDigest, slug, uploadComplete, uploadDraftInteropVersion, xImmichAssetData }: {
|
||||
contentLength: string;
|
||||
key?: string;
|
||||
reprDigest: string;
|
||||
slug?: string;
|
||||
uploadComplete?: string;
|
||||
uploadDraftInteropVersion?: string;
|
||||
xImmichAssetData: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: UploadOkDto;
|
||||
}>(`/upload${QS.query(QS.explode({
|
||||
key,
|
||||
slug
|
||||
}))}`, {
|
||||
...opts,
|
||||
method: "POST",
|
||||
headers: oazapfts.mergeHeaders(opts?.headers, {
|
||||
"content-length": contentLength,
|
||||
"repr-digest": reprDigest,
|
||||
"upload-complete": uploadComplete,
|
||||
"upload-draft-interop-version": uploadDraftInteropVersion,
|
||||
"x-immich-asset-data": xImmichAssetData
|
||||
})
|
||||
}));
|
||||
}
|
||||
export function cancelUpload({ id, key, slug }: {
|
||||
id: string;
|
||||
key?: string;
|
||||
slug?: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText(`/upload/${encodeURIComponent(id)}${QS.query(QS.explode({
|
||||
key,
|
||||
slug
|
||||
}))}`, {
|
||||
...opts,
|
||||
method: "DELETE"
|
||||
}));
|
||||
}
|
||||
export function getUploadStatus({ id, key, slug, uploadDraftInteropVersion }: {
|
||||
id: string;
|
||||
key?: string;
|
||||
slug?: string;
|
||||
uploadDraftInteropVersion: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText(`/upload/${encodeURIComponent(id)}${QS.query(QS.explode({
|
||||
key,
|
||||
slug
|
||||
}))}`, {
|
||||
...opts,
|
||||
method: "HEAD",
|
||||
headers: oazapfts.mergeHeaders(opts?.headers, {
|
||||
"upload-draft-interop-version": uploadDraftInteropVersion
|
||||
})
|
||||
}));
|
||||
}
|
||||
export function resumeUpload({ contentLength, id, key, slug, uploadComplete, uploadDraftInteropVersion, uploadOffset }: {
|
||||
contentLength: string;
|
||||
id: string;
|
||||
key?: string;
|
||||
slug?: string;
|
||||
uploadComplete: string;
|
||||
uploadDraftInteropVersion: string;
|
||||
uploadOffset: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: UploadOkDto;
|
||||
}>(`/upload/${encodeURIComponent(id)}${QS.query(QS.explode({
|
||||
key,
|
||||
slug
|
||||
}))}`, {
|
||||
...opts,
|
||||
method: "PATCH",
|
||||
headers: oazapfts.mergeHeaders(opts?.headers, {
|
||||
"content-length": contentLength,
|
||||
"upload-complete": uploadComplete,
|
||||
"upload-draft-interop-version": uploadDraftInteropVersion,
|
||||
"upload-offset": uploadOffset
|
||||
})
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Get all users
|
||||
*/
|
||||
@@ -7074,10 +6999,11 @@ export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) {
|
||||
/**
|
||||
* List all workflows
|
||||
*/
|
||||
export function searchWorkflows({ description, enabled, id, name, trigger }: {
|
||||
export function searchWorkflows({ description, enabled, id, logging, name, trigger }: {
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
id?: string;
|
||||
logging?: boolean;
|
||||
name?: string;
|
||||
trigger?: WorkflowTrigger;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
@@ -7088,6 +7014,7 @@ export function searchWorkflows({ description, enabled, id, name, trigger }: {
|
||||
description,
|
||||
enabled,
|
||||
id,
|
||||
logging,
|
||||
name,
|
||||
trigger
|
||||
}))}`, {
|
||||
@@ -7160,6 +7087,26 @@ export function updateWorkflow({ id, workflowUpdateDto }: {
|
||||
body: workflowUpdateDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Retrieve a workflow
|
||||
*/
|
||||
export function getWorkflowLogs({ before, id, limit, result }: {
|
||||
before?: string;
|
||||
id: string;
|
||||
limit?: number;
|
||||
result?: WorkflowResult;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: WorkflowLogEntryDto[];
|
||||
}>(`/workflows/${encodeURIComponent(id)}/logs${QS.query(QS.explode({
|
||||
before,
|
||||
limit,
|
||||
result
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Retrieve a workflow
|
||||
*/
|
||||
@@ -7407,6 +7354,7 @@ export enum Permission {
|
||||
WorkflowRead = "workflow.read",
|
||||
WorkflowUpdate = "workflow.update",
|
||||
WorkflowDelete = "workflow.delete",
|
||||
WorkflowLogs = "workflow.logs",
|
||||
AdminUserCreate = "adminUser.create",
|
||||
AdminUserRead = "adminUser.read",
|
||||
AdminUserUpdate = "adminUser.update",
|
||||
@@ -7546,8 +7494,6 @@ export enum JobName {
|
||||
AssetFileMigration = "AssetFileMigration",
|
||||
AssetGenerateThumbnailsQueueAll = "AssetGenerateThumbnailsQueueAll",
|
||||
AssetGenerateThumbnails = "AssetGenerateThumbnails",
|
||||
PartialAssetCleanup = "PartialAssetCleanup",
|
||||
PartialAssetCleanupQueueAll = "PartialAssetCleanupQueueAll",
|
||||
AuditTableCleanup = "AuditTableCleanup",
|
||||
DatabaseBackup = "DatabaseBackup",
|
||||
FacialRecognitionQueueAll = "FacialRecognitionQueueAll",
|
||||
@@ -7785,6 +7731,11 @@ export enum AssetOrderBy {
|
||||
TakenAt = "takenAt",
|
||||
CreatedAt = "createdAt"
|
||||
}
|
||||
export enum WorkflowResult {
|
||||
Completed = "completed",
|
||||
Halted = "halted",
|
||||
Error = "error"
|
||||
}
|
||||
export enum ReleaseType {
|
||||
Major = "major",
|
||||
Premajor = "premajor",
|
||||
|
||||
4920
pnpm-lock.yaml
generated
4920
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -110,7 +110,6 @@
|
||||
"sharp": "^0.34.5",
|
||||
"sirv": "^3.0.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"structured-headers": "^2.0.2",
|
||||
"tailwindcss-preset-email": "^1.4.0",
|
||||
"thumbhash": "^0.1.1",
|
||||
"transformation-matrix": "^3.1.0",
|
||||
|
||||
@@ -24,9 +24,6 @@ export type SystemConfig = {
|
||||
cronExpression: string;
|
||||
keepLastAmount: number;
|
||||
};
|
||||
upload: {
|
||||
maxAgeHours: number;
|
||||
};
|
||||
};
|
||||
ffmpeg: {
|
||||
crf: number;
|
||||
@@ -170,7 +167,6 @@ export type SystemConfig = {
|
||||
clusterNewFaces: boolean;
|
||||
generateMemories: boolean;
|
||||
syncQuotaUsage: boolean;
|
||||
removeStaleUploads: boolean;
|
||||
};
|
||||
trash: {
|
||||
enabled: boolean;
|
||||
@@ -229,9 +225,6 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
cronExpression: CronExpression.EVERY_DAY_AT_2AM,
|
||||
keepLastAmount: 14,
|
||||
},
|
||||
upload: {
|
||||
maxAgeHours: 72,
|
||||
},
|
||||
},
|
||||
ffmpeg: {
|
||||
crf: 23,
|
||||
@@ -406,7 +399,6 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
syncQuotaUsage: true,
|
||||
missingThumbnails: true,
|
||||
clusterNewFaces: true,
|
||||
removeStaleUploads: true,
|
||||
},
|
||||
trash: {
|
||||
enabled: true,
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { AssetUploadController } from 'src/controllers/asset-upload.controller';
|
||||
import { AssetUploadService } from 'src/services/asset-upload.service';
|
||||
import { serializeDictionary } from 'structured-headers';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
const makeAssetData = (overrides?: Partial<any>): string => {
|
||||
return serializeDictionary({
|
||||
filename: 'test-image.jpg',
|
||||
'device-asset-id': 'test-asset-id',
|
||||
'device-id': 'test-device',
|
||||
'file-created-at': new Date('2025-01-02T00:00:00Z').toISOString(),
|
||||
'file-modified-at': new Date('2025-01-01T00:00:00Z').toISOString(),
|
||||
'is-favorite': false,
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
const expectValidationIssue = (body: unknown, path: string[], message: string) => {
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({ errors: expect.arrayContaining([expect.objectContaining({ path, message })]) }),
|
||||
);
|
||||
};
|
||||
|
||||
describe(AssetUploadController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
let buffer: Buffer;
|
||||
let checksum: string;
|
||||
const service = mockBaseService(AssetUploadService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(AssetUploadController, [{ provide: AssetUploadService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
service.startUpload.mockImplementation((_, __, res, ___) => {
|
||||
res.send();
|
||||
return Promise.resolve();
|
||||
});
|
||||
service.resumeUpload.mockImplementation((_, __, res, ___, ____) => {
|
||||
res.send();
|
||||
return Promise.resolve();
|
||||
});
|
||||
service.cancelUpload.mockImplementation((_, __, res) => {
|
||||
res.send();
|
||||
return Promise.resolve();
|
||||
});
|
||||
service.getUploadStatus.mockImplementation((_, res, __, ___) => {
|
||||
res.send();
|
||||
return Promise.resolve();
|
||||
});
|
||||
ctx.reset();
|
||||
|
||||
buffer = Buffer.from(randomUUID());
|
||||
checksum = `sha=:${createHash('sha1').update(buffer).digest('base64')}:`;
|
||||
});
|
||||
|
||||
describe('POST /upload', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/upload');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require at least version 3 of Upload-Draft-Interop-Version header if provided', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('X-Immich-Asset-Data', makeAssetData())
|
||||
.set('Upload-Draft-Interop-Version', '2')
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expectValidationIssue(body, ['upload-draft-interop-version'], 'Too small: expected number to be >=3');
|
||||
});
|
||||
|
||||
it('should require X-Immich-Asset-Data header', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expectValidationIssue(body, ['x-immich-asset-data'], 'Invalid input: expected string, received undefined');
|
||||
});
|
||||
|
||||
it('should require Repr-Digest header', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', makeAssetData())
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expectValidationIssue(body, ['repr-digest'], 'Invalid input: expected string, received undefined');
|
||||
});
|
||||
|
||||
it('should allow conventional upload without Upload-Complete header', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('X-Immich-Asset-Data', makeAssetData())
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(201);
|
||||
});
|
||||
|
||||
it('should require Upload-Length header for incomplete upload', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', makeAssetData())
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', '?0')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expectValidationIssue(body, ['upload-length'], 'Missing upload-length header');
|
||||
});
|
||||
|
||||
it('should infer upload length from content length if complete upload', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', makeAssetData())
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', '?1')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(201);
|
||||
});
|
||||
|
||||
it('should reject invalid Repr-Digest format', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', checksum)
|
||||
.set('Repr-Digest', 'invalid-format')
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expectValidationIssue(body, ['repr-digest'], 'Invalid repr-digest header');
|
||||
});
|
||||
|
||||
it('should validate device-asset-id is required in asset data', async () => {
|
||||
const assetData = serializeDictionary({
|
||||
filename: 'test.jpg',
|
||||
'device-id': 'test-device',
|
||||
'file-created-at': new Date().toISOString(),
|
||||
'file-modified-at': new Date().toISOString(),
|
||||
});
|
||||
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expectValidationIssue(
|
||||
body,
|
||||
['x-immich-asset-data', 'deviceAssetId'],
|
||||
'Invalid input: expected string, received undefined',
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate device-id is required in asset data', async () => {
|
||||
const assetData = serializeDictionary({
|
||||
filename: 'test.jpg',
|
||||
'device-asset-id': 'test-asset',
|
||||
'file-created-at': new Date().toISOString(),
|
||||
'file-modified-at': new Date().toISOString(),
|
||||
});
|
||||
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expectValidationIssue(
|
||||
body,
|
||||
['x-immich-asset-data', 'deviceId'],
|
||||
'Invalid input: expected string, received undefined',
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate filename is required in asset data', async () => {
|
||||
const assetData = serializeDictionary({
|
||||
'device-asset-id': 'test-asset',
|
||||
'device-id': 'test-device',
|
||||
'file-created-at': new Date().toISOString(),
|
||||
'file-modified-at': new Date().toISOString(),
|
||||
});
|
||||
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expectValidationIssue(
|
||||
body,
|
||||
['x-immich-asset-data', 'filename'],
|
||||
'Invalid input: expected string, received undefined',
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept Upload-Incomplete header for version 3', async () => {
|
||||
const { body, status } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '3')
|
||||
.set('X-Immich-Asset-Data', makeAssetData())
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Incomplete', '?0')
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(body).toEqual({});
|
||||
expect(status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should validate Upload-Complete is a boolean structured field', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', makeAssetData())
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', 'true')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expectValidationIssue(body, ['upload-complete'], 'upload-complete must be a structured boolean value');
|
||||
});
|
||||
|
||||
it('should validate Upload-Length is a positive integer', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', makeAssetData())
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '-100')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expectValidationIssue(body, ['upload-length'], 'Too small: expected number to be >=1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /upload/:id', () => {
|
||||
const uploadId = factory.uuid();
|
||||
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).patch(`/upload/${uploadId}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require Upload-Draft-Interop-Version header', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.patch(`/upload/${uploadId}`)
|
||||
.set('Upload-Offset', '0')
|
||||
.set('Upload-Complete', '?1')
|
||||
.send(Buffer.from('test'));
|
||||
|
||||
expect(status).toBe(400);
|
||||
expectValidationIssue(body, ['upload-draft-interop-version'], 'Invalid input: expected number, received NaN');
|
||||
});
|
||||
|
||||
it('should require Upload-Offset header', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.patch(`/upload/${uploadId}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('Upload-Complete', '?1')
|
||||
.send(Buffer.from('test'));
|
||||
|
||||
expect(status).toBe(400);
|
||||
expectValidationIssue(body, ['upload-offset'], 'Invalid input: expected number, received NaN');
|
||||
});
|
||||
|
||||
it('should require Upload-Complete header', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.patch(`/upload/${uploadId}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('Upload-Offset', '0')
|
||||
.set('Content-Type', 'application/partial-upload')
|
||||
.send(Buffer.from('test'));
|
||||
|
||||
expect(status).toBe(400);
|
||||
expectValidationIssue(body, ['upload-complete'], 'is required');
|
||||
});
|
||||
|
||||
it('should validate UUID parameter', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.patch('/upload/invalid-uuid')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('Upload-Offset', '0')
|
||||
.set('Upload-Complete', '?0')
|
||||
.send(Buffer.from('test'));
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
errors: expect.arrayContaining([expect.objectContaining({ message: 'Invalid UUID', path: ['id'] })]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate Upload-Offset is a non-negative integer', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.patch(`/upload/${uploadId}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('Upload-Offset', '-50')
|
||||
.set('Upload-Complete', '?0')
|
||||
.send(Buffer.from('test'));
|
||||
|
||||
expect(status).toBe(400);
|
||||
expectValidationIssue(body, ['upload-offset'], 'Too small: expected number to be >=0');
|
||||
});
|
||||
|
||||
it('should require Content-Type: application/partial-upload for version >= 6', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.patch(`/upload/${uploadId}`)
|
||||
.set('Upload-Draft-Interop-Version', '6')
|
||||
.set('Upload-Offset', '0')
|
||||
.set('Upload-Complete', '?0')
|
||||
.set('Content-Type', 'application/octet-stream')
|
||||
.send(Buffer.from('test'));
|
||||
|
||||
expect(status).toBe(400);
|
||||
expectValidationIssue(body, ['content-type'], 'must be equal to application/partial-upload');
|
||||
});
|
||||
|
||||
it('should allow other Content-Type for version < 6', async () => {
|
||||
const { body } = await request(ctx.getHttpServer())
|
||||
.patch(`/upload/${uploadId}`)
|
||||
.set('Upload-Draft-Interop-Version', '3')
|
||||
.set('Upload-Offset', '0')
|
||||
.set('Upload-Incomplete', '?1')
|
||||
.set('Content-Type', 'application/octet-stream')
|
||||
.send();
|
||||
|
||||
// Will fail for other reasons, but not content-type validation
|
||||
expect(body).not.toEqual(
|
||||
expect.objectContaining({
|
||||
message: expect.arrayContaining([expect.stringContaining('contentType')]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept Upload-Incomplete header for version 3', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.patch(`/upload/${uploadId}`)
|
||||
.set('Upload-Draft-Interop-Version', '3')
|
||||
.set('Upload-Offset', '0')
|
||||
.set('Upload-Incomplete', '?1')
|
||||
.send();
|
||||
|
||||
// Should not fail validation
|
||||
expect(status).not.toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /upload/:id', () => {
|
||||
const uploadId = factory.uuid();
|
||||
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/upload/${uploadId}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate UUID parameter', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete('/upload/invalid-uuid');
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
errors: expect.arrayContaining([expect.objectContaining({ message: 'Invalid UUID', path: ['id'] })]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HEAD /upload/:id', () => {
|
||||
const uploadId = factory.uuid();
|
||||
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).head(`/upload/${uploadId}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require Upload-Draft-Interop-Version header', async () => {
|
||||
const { status } = await request(ctx.getHttpServer()).head(`/upload/${uploadId}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('should validate UUID parameter', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.head('/upload/invalid-uuid')
|
||||
.set('Upload-Draft-Interop-Version', '8');
|
||||
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import { Controller, Delete, Head, HttpCode, HttpStatus, Options, Param, Patch, Post, Req, Res } from '@nestjs/common';
|
||||
import { ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { GetUploadStatusDto, Header, ResumeUploadDto, StartUploadDto, UploadOkDto } from 'src/dtos/asset-upload.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ImmichHeader, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { AssetUploadService } from 'src/services/asset-upload.service';
|
||||
import { validateSyncOrReject } from 'src/utils/request';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
const apiInteropVersion = {
|
||||
name: Header.InteropVersion,
|
||||
description: `Indicates the version of the RUFH protocol supported by the client.`,
|
||||
required: true,
|
||||
};
|
||||
|
||||
const apiUploadComplete = {
|
||||
name: Header.UploadComplete,
|
||||
description:
|
||||
'Structured boolean indicating whether this request completes the file. Use Upload-Incomplete instead for version <= 3.',
|
||||
required: true,
|
||||
};
|
||||
|
||||
const apiContentLength = {
|
||||
name: Header.ContentLength,
|
||||
description: 'Non-negative size of the request body in bytes.',
|
||||
required: true,
|
||||
};
|
||||
|
||||
// This is important to let go of the asset lock for an inactive request
|
||||
const SOCKET_TIMEOUT_MS = 30_000;
|
||||
|
||||
@ApiTags('Upload')
|
||||
@Controller('upload')
|
||||
export class AssetUploadController {
|
||||
constructor(private service: AssetUploadService) {}
|
||||
|
||||
@Post()
|
||||
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
|
||||
@ApiHeader({
|
||||
name: ImmichHeader.AssetData,
|
||||
description: `RFC 9651 structured dictionary containing asset metadata with the following keys:
|
||||
- device-asset-id (string, required): Unique device asset identifier
|
||||
- device-id (string, required): Device identifier
|
||||
- file-created-at (string/date, required): ISO 8601 date string or Unix timestamp
|
||||
- file-modified-at (string/date, required): ISO 8601 date string or Unix timestamp
|
||||
- filename (string, required): Original filename
|
||||
- is-favorite (boolean, optional): Favorite status
|
||||
- live-photo-video-id (string, optional): Live photo ID for assets from iOS devices
|
||||
- icloud-id (string, optional): iCloud identifier for assets from iOS devices`,
|
||||
required: true,
|
||||
example:
|
||||
'device-asset-id="abc123", device-id="phone1", filename="photo.jpg", file-created-at="2024-01-01T00:00:00Z", file-modified-at="2024-01-01T00:00:00Z"',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: Header.ReprDigest,
|
||||
description:
|
||||
'RFC 9651 structured dictionary containing an `sha` (bytesequence) checksum used to detect duplicate files and validate data integrity.',
|
||||
required: true,
|
||||
})
|
||||
@ApiHeader({ ...apiInteropVersion, required: false })
|
||||
@ApiHeader({ ...apiUploadComplete, required: false })
|
||||
@ApiHeader(apiContentLength)
|
||||
@ApiOkResponse({ type: UploadOkDto })
|
||||
startUpload(@Auth() auth: AuthDto, @Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
res.setTimeout(SOCKET_TIMEOUT_MS);
|
||||
return this.service.startUpload(auth, req, res, validateSyncOrReject(StartUploadDto, req.headers));
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
|
||||
@ApiHeader({
|
||||
name: Header.UploadOffset,
|
||||
description:
|
||||
'Non-negative byte offset indicating the starting position of the data in the request body within the entire file.',
|
||||
required: true,
|
||||
})
|
||||
@ApiHeader(apiInteropVersion)
|
||||
@ApiHeader(apiUploadComplete)
|
||||
@ApiHeader(apiContentLength)
|
||||
@ApiOkResponse({ type: UploadOkDto })
|
||||
resumeUpload(@Auth() auth: AuthDto, @Req() req: Request, @Res() res: Response, @Param() { id }: UUIDParamDto) {
|
||||
res.setTimeout(SOCKET_TIMEOUT_MS);
|
||||
return this.service.resumeUpload(auth, req, res, id, validateSyncOrReject(ResumeUploadDto, req.headers));
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
|
||||
cancelUpload(@Auth() auth: AuthDto, @Res() res: Response, @Param() { id }: UUIDParamDto) {
|
||||
res.setTimeout(SOCKET_TIMEOUT_MS);
|
||||
return this.service.cancelUpload(auth, id, res);
|
||||
}
|
||||
|
||||
@Head(':id')
|
||||
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
|
||||
@ApiHeader(apiInteropVersion)
|
||||
getUploadStatus(@Auth() auth: AuthDto, @Req() req: Request, @Res() res: Response, @Param() { id }: UUIDParamDto) {
|
||||
res.setTimeout(SOCKET_TIMEOUT_MS);
|
||||
return this.service.getUploadStatus(auth, res, id, validateSyncOrReject(GetUploadStatusDto, req.headers));
|
||||
}
|
||||
|
||||
@Options()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
getUploadOptions(@Res() res: Response) {
|
||||
return this.service.getUploadOptions(res);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { AlbumController } from 'src/controllers/album.controller';
|
||||
import { ApiKeyController } from 'src/controllers/api-key.controller';
|
||||
import { AppController } from 'src/controllers/app.controller';
|
||||
import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
||||
import { AssetUploadController } from 'src/controllers/asset-upload.controller';
|
||||
import { AssetController } from 'src/controllers/asset.controller';
|
||||
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
|
||||
import { AuthController } from 'src/controllers/auth.controller';
|
||||
@@ -48,7 +47,6 @@ export const controllers = [
|
||||
AppController,
|
||||
AssetController,
|
||||
AssetMediaController,
|
||||
AssetUploadController,
|
||||
AuthController,
|
||||
AuthAdminController,
|
||||
DatabaseBackupController,
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
WorkflowCreateDto,
|
||||
WorkflowGetLogsDto,
|
||||
WorkflowLogEntryDto,
|
||||
WorkflowResponseDto,
|
||||
WorkflowSearchDto,
|
||||
WorkflowShareResponseDto,
|
||||
@@ -113,4 +115,19 @@ export class WorkflowController {
|
||||
deleteWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.delete(auth, id);
|
||||
}
|
||||
|
||||
@Get(':id/logs')
|
||||
@Authenticated({ permission: Permission.WorkflowLogs })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve a workflow',
|
||||
description: 'Retrieve a workflow details without ids, default values, etc.',
|
||||
history: HistoryBuilder.v3(),
|
||||
})
|
||||
getWorkflowLogs(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Query() dto: WorkflowGetLogsDto,
|
||||
): Promise<WorkflowLogEntryDto[]> {
|
||||
return this.service.getLogs(auth, id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,11 +190,11 @@ type CustomExtensions = {
|
||||
};
|
||||
|
||||
enum ApiState {
|
||||
Stable = 'Stable',
|
||||
Alpha = 'Alpha',
|
||||
Beta = 'Beta',
|
||||
Internal = 'Internal',
|
||||
Deprecated = 'Deprecated',
|
||||
'Stable' = 'Stable',
|
||||
'Alpha' = 'Alpha',
|
||||
'Beta' = 'Beta',
|
||||
'Internal' = 'Internal',
|
||||
'Deprecated' = 'Deprecated',
|
||||
}
|
||||
export class HistoryBuilder {
|
||||
private hasDeprecated = false;
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { ImmichHeader } from 'src/enum';
|
||||
import { isoDatetimeToDate } from 'src/validation';
|
||||
import { parseDictionary } from 'structured-headers';
|
||||
import z from 'zod';
|
||||
|
||||
export enum Header {
|
||||
ContentLength = 'content-length',
|
||||
ContentType = 'content-type',
|
||||
InteropVersion = 'upload-draft-interop-version',
|
||||
ReprDigest = 'repr-digest',
|
||||
UploadComplete = 'upload-complete',
|
||||
UploadIncomplete = 'upload-incomplete',
|
||||
UploadLength = 'upload-length',
|
||||
UploadOffset = 'upload-offset',
|
||||
}
|
||||
|
||||
const UploadAssetDataSchema = z.object({
|
||||
deviceAssetId: z.string().min(1),
|
||||
deviceId: z.string().min(1),
|
||||
fileCreatedAt: isoDatetimeToDate,
|
||||
fileModifiedAt: isoDatetimeToDate,
|
||||
filename: z.string().min(1),
|
||||
isFavorite: z.boolean().optional(),
|
||||
livePhotoVideoId: z.string().min(1).optional(),
|
||||
iCloudId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export class UploadAssetDataDto extends createZodDto(UploadAssetDataSchema) {}
|
||||
|
||||
const structuredDictionary = z.string().transform((value, ctx) => {
|
||||
try {
|
||||
return parseDictionary(value);
|
||||
} catch {
|
||||
ctx.addIssue({ code: 'custom', message: 'must be a valid structured dictionary' });
|
||||
return z.NEVER;
|
||||
}
|
||||
});
|
||||
|
||||
const assetData = structuredDictionary.transform((dict, ctx) => {
|
||||
const result = UploadAssetDataSchema.safeParse({
|
||||
deviceAssetId: dict.get('device-asset-id')?.[0],
|
||||
deviceId: dict.get('device-id')?.[0],
|
||||
filename: dict.get('filename')?.[0],
|
||||
fileCreatedAt: dict.get('file-created-at')?.[0],
|
||||
fileModifiedAt: dict.get('file-modified-at')?.[0],
|
||||
isFavorite: dict.get('is-favorite')?.[0],
|
||||
livePhotoVideoId: dict.get('live-photo-video-id')?.[0],
|
||||
iCloudId: dict.get('icloud-id')?.[0],
|
||||
});
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message });
|
||||
}
|
||||
return z.NEVER;
|
||||
}
|
||||
return result.data;
|
||||
});
|
||||
|
||||
const checksum = structuredDictionary.transform((dict, ctx) => {
|
||||
const value = dict.get('sha')?.[0];
|
||||
if (value instanceof ArrayBuffer && value.byteLength === 20) {
|
||||
return Buffer.from(value);
|
||||
}
|
||||
ctx.addIssue({ code: 'custom', message: `Invalid ${Header.ReprDigest} header` });
|
||||
return z.NEVER;
|
||||
});
|
||||
|
||||
const BaseUploadHeadersInputSchema = z.object({
|
||||
[Header.ContentLength]: z.coerce.number().int().min(0),
|
||||
});
|
||||
|
||||
const BaseUploadHeadersSchema = BaseUploadHeadersInputSchema.transform((headers) => ({
|
||||
contentLength: headers[Header.ContentLength],
|
||||
}));
|
||||
|
||||
export class BaseUploadHeadersDto extends createZodDto(BaseUploadHeadersSchema) {}
|
||||
|
||||
const StartUploadSchema = BaseUploadHeadersInputSchema.extend({
|
||||
[Header.InteropVersion]: z.coerce.number().int().min(3).optional(),
|
||||
[ImmichHeader.AssetData]: assetData,
|
||||
[Header.ReprDigest]: checksum,
|
||||
[Header.UploadLength]: z.coerce.number().int().min(1).optional(),
|
||||
[Header.UploadComplete]: z.string().optional(),
|
||||
[Header.UploadIncomplete]: z.string().optional(),
|
||||
})
|
||||
.transform((headers, ctx) => {
|
||||
const complete = parseUploadComplete(headers, ctx);
|
||||
const length = headers[Header.UploadLength] ?? (complete === false ? undefined : headers[Header.ContentLength]);
|
||||
if (!length) {
|
||||
ctx.addIssue({ code: 'custom', path: [Header.UploadLength], message: `Missing ${Header.UploadLength} header` });
|
||||
return z.NEVER;
|
||||
}
|
||||
return {
|
||||
contentLength: headers[Header.ContentLength],
|
||||
version: headers[Header.InteropVersion],
|
||||
assetData: headers[ImmichHeader.AssetData],
|
||||
checksum: headers[Header.ReprDigest],
|
||||
uploadLength: length,
|
||||
uploadComplete: complete,
|
||||
};
|
||||
})
|
||||
.meta({ id: 'StartUploadDto' });
|
||||
|
||||
export class StartUploadDto extends createZodDto(StartUploadSchema) {}
|
||||
|
||||
const ResumeUploadSchema = BaseUploadHeadersInputSchema.extend({
|
||||
[Header.InteropVersion]: z.coerce.number().int().min(3),
|
||||
[Header.ContentType]: z.string().optional(),
|
||||
[Header.UploadLength]: z.coerce.number().int().min(1).optional(),
|
||||
[Header.UploadOffset]: z.coerce.number().int().min(0),
|
||||
[Header.UploadComplete]: z.string().optional(),
|
||||
[Header.UploadIncomplete]: z.string().optional(),
|
||||
})
|
||||
.superRefine((headers, ctx) => {
|
||||
if (headers[Header.InteropVersion] >= 6 && headers[Header.ContentType] !== 'application/partial-upload') {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: [Header.ContentType],
|
||||
message: 'must be equal to application/partial-upload',
|
||||
});
|
||||
}
|
||||
})
|
||||
.transform((headers, ctx) => ({
|
||||
contentLength: headers[Header.ContentLength],
|
||||
version: headers[Header.InteropVersion],
|
||||
contentType: headers[Header.ContentType],
|
||||
uploadLength: headers[Header.UploadLength],
|
||||
uploadOffset: headers[Header.UploadOffset],
|
||||
uploadComplete: parseUploadComplete(headers, ctx, true),
|
||||
}))
|
||||
.meta({ id: 'ResumeUploadDto' });
|
||||
|
||||
export class ResumeUploadDto extends createZodDto(ResumeUploadSchema) {}
|
||||
|
||||
const GetUploadStatusSchema = z
|
||||
.object({ [Header.InteropVersion]: z.coerce.number().int().min(3) })
|
||||
.transform((headers) => ({ version: headers[Header.InteropVersion] }))
|
||||
.meta({ id: 'GetUploadStatusDto' });
|
||||
|
||||
export class GetUploadStatusDto extends createZodDto(GetUploadStatusSchema) {}
|
||||
|
||||
const UploadOkSchema = z.object({ id: z.string() }).meta({ id: 'UploadOkDto' });
|
||||
|
||||
export class UploadOkDto extends createZodDto(UploadOkSchema) {}
|
||||
|
||||
const STRUCTURED_TRUE = '?1';
|
||||
const STRUCTURED_FALSE = '?0';
|
||||
|
||||
function parseStructuredBoolean(value: string | undefined, header: Header, ctx: z.RefinementCtx) {
|
||||
if (value === STRUCTURED_TRUE) {
|
||||
return true;
|
||||
}
|
||||
if (value === STRUCTURED_FALSE) {
|
||||
return false;
|
||||
}
|
||||
if (value !== undefined) {
|
||||
ctx.addIssue({ code: 'custom', path: [header], message: `${header} must be a structured boolean value` });
|
||||
}
|
||||
}
|
||||
|
||||
type UploadCompleteHeaders = {
|
||||
[Header.UploadComplete]?: string;
|
||||
[Header.UploadIncomplete]?: string;
|
||||
};
|
||||
|
||||
function parseUploadComplete(headers: UploadCompleteHeaders, ctx: z.RefinementCtx, required: true): boolean;
|
||||
function parseUploadComplete(
|
||||
headers: UploadCompleteHeaders,
|
||||
ctx: z.RefinementCtx,
|
||||
required?: false,
|
||||
): boolean | undefined;
|
||||
function parseUploadComplete(
|
||||
headers: UploadCompleteHeaders,
|
||||
ctx: z.RefinementCtx,
|
||||
required = false,
|
||||
): boolean | undefined {
|
||||
const complete = parseStructuredBoolean(headers[Header.UploadComplete], Header.UploadComplete, ctx);
|
||||
if (complete !== undefined) {
|
||||
return complete;
|
||||
}
|
||||
|
||||
const incomplete = parseStructuredBoolean(headers[Header.UploadIncomplete], Header.UploadIncomplete, ctx);
|
||||
if (incomplete !== undefined) {
|
||||
return !incomplete;
|
||||
}
|
||||
if (required && headers[Header.UploadComplete] === undefined && headers[Header.UploadIncomplete] === undefined) {
|
||||
ctx.addIssue({ code: 'custom', path: [Header.UploadComplete], message: 'is required' });
|
||||
return z.NEVER;
|
||||
}
|
||||
}
|
||||
@@ -93,13 +93,7 @@ const SystemConfigIntegrityChecksSchema = z
|
||||
.describe('Integrity checks config')
|
||||
.meta({ id: 'SystemConfigIntegrityChecks' });
|
||||
|
||||
const UploadBackupSchema = z.object({
|
||||
maxAgeHours: z.int().min(1),
|
||||
});
|
||||
|
||||
const SystemConfigBackupsSchema = z
|
||||
.object({ database: DatabaseBackupSchema, upload: UploadBackupSchema })
|
||||
.meta({ id: 'SystemConfigBackupsDto' });
|
||||
const SystemConfigBackupsSchema = z.object({ database: DatabaseBackupSchema }).meta({ id: 'SystemConfigBackupsDto' });
|
||||
|
||||
const SystemConfigFFmpegSchema = z
|
||||
.object({
|
||||
@@ -228,7 +222,6 @@ const SystemConfigNightlyTasksSchema = z
|
||||
clusterNewFaces: configBool.describe('Cluster new faces'),
|
||||
generateMemories: configBool.describe('Generate memories'),
|
||||
syncQuotaUsage: configBool.describe('Sync quota usage'),
|
||||
removeStaleUploads: configBool.describe('Remove stale uploads'),
|
||||
})
|
||||
.meta({ id: 'SystemConfigNightlyTasksDto' });
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
|
||||
import { WorkflowResultSchema, WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
|
||||
import { isoDatetimeToDate } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const WorkflowTriggerResponseSchema = z
|
||||
@@ -17,6 +18,7 @@ const WorkflowSearchSchema = z
|
||||
name: z.string().optional().describe('Workflow name'),
|
||||
description: z.string().optional().describe('Workflow description'),
|
||||
enabled: z.boolean().optional().describe('Workflow enabled'),
|
||||
logging: z.boolean().optional().describe('Workflow logs run results'),
|
||||
})
|
||||
.meta({ id: 'WorkflowSearchDto' });
|
||||
|
||||
@@ -42,6 +44,7 @@ const WorkflowCreateSchema = z
|
||||
name: z.string().nullable().optional().describe('Workflow name'),
|
||||
description: z.string().nullable().optional().describe('Workflow description'),
|
||||
enabled: z.boolean().optional().describe('Workflow enabled'),
|
||||
logging: z.boolean().optional().describe('Workflow logs run results'),
|
||||
steps: z.array(WorkflowStepSchema).optional(),
|
||||
})
|
||||
.meta({ id: 'WorkflowCreateDto' });
|
||||
@@ -52,6 +55,7 @@ const WorkflowUpdateSchema = z
|
||||
name: z.string().nullable().optional().describe('Workflow name'),
|
||||
description: z.string().nullable().optional().describe('Workflow description'),
|
||||
enabled: z.boolean().optional().describe('Workflow enabled'),
|
||||
logging: z.boolean().optional().describe('Workflow logs run results'),
|
||||
steps: z.array(WorkflowStepSchema).optional(),
|
||||
})
|
||||
.meta({ id: 'WorkflowUpdateDto' });
|
||||
@@ -65,6 +69,7 @@ const WorkflowResponseSchema = z
|
||||
createdAt: z.string().describe('Creation date'),
|
||||
updatedAt: z.string().describe('Update date'),
|
||||
enabled: z.boolean().describe('Workflow enabled'),
|
||||
logging: z.boolean().describe('Workflow logs run results'),
|
||||
steps: z.array(WorkflowStepSchema).describe('Workflow steps'),
|
||||
})
|
||||
.meta({ id: 'WorkflowResponseDto' });
|
||||
@@ -78,12 +83,36 @@ const WorkflowShareResponseSchema = z
|
||||
})
|
||||
.meta({ id: 'WorkflowShareResponseDto' });
|
||||
|
||||
const WorkflowLogEntrySchema = z
|
||||
.object({
|
||||
id: z.uuidv4().describe('Workflow log entry ID'),
|
||||
at: z.string().describe('Workflow run date/time'),
|
||||
result: WorkflowResultSchema.describe('Workflow run result'),
|
||||
triggerDataId: z.uuid().optional().describe('Workflow trigger data ID'),
|
||||
lastStep: z
|
||||
.object({
|
||||
method: z.string().describe('Method of the step'),
|
||||
index: z.int().positive().describe('Index of the step in the workflow'),
|
||||
})
|
||||
.optional()
|
||||
.describe('Last step ran, if the workflow ended early'),
|
||||
})
|
||||
.meta({ id: 'WorkflowLogEntryDto' });
|
||||
|
||||
const WorkflowGetLogsSchema = z.object({
|
||||
result: WorkflowResultSchema.optional().describe('Filter by run result'),
|
||||
before: isoDatetimeToDate.optional().describe('Filter by runs before a date/time'),
|
||||
limit: z.coerce.number().int().positive().default(50).describe('Maximum number of logs'),
|
||||
});
|
||||
|
||||
export class WorkflowTriggerResponseDto extends createZodDto(WorkflowTriggerResponseSchema) {}
|
||||
export class WorkflowSearchDto extends createZodDto(WorkflowSearchSchema) {}
|
||||
export class WorkflowCreateDto extends createZodDto(WorkflowCreateSchema) {}
|
||||
export class WorkflowUpdateDto extends createZodDto(WorkflowUpdateSchema) {}
|
||||
export class WorkflowResponseDto extends createZodDto(WorkflowResponseSchema) {}
|
||||
export class WorkflowShareResponseDto extends createZodDto(WorkflowShareResponseSchema) {}
|
||||
export class WorkflowLogEntryDto extends createZodDto(WorkflowLogEntrySchema) {}
|
||||
export class WorkflowGetLogsDto extends createZodDto(WorkflowGetLogsSchema) {}
|
||||
|
||||
type Workflow = {
|
||||
id: string;
|
||||
@@ -93,6 +122,7 @@ type Workflow = {
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
logging: boolean;
|
||||
};
|
||||
|
||||
type WorkflowStep = {
|
||||
@@ -107,6 +137,7 @@ export const mapWorkflow = (workflow: Workflow & { steps: WorkflowStep[] }): Wor
|
||||
id: workflow.id,
|
||||
enabled: workflow.enabled,
|
||||
trigger: workflow.trigger,
|
||||
logging: workflow.logging,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
createdAt: workflow.createdAt.toISOString(),
|
||||
|
||||
@@ -26,7 +26,6 @@ export enum ImmichHeader {
|
||||
CorrelationId = 'X-Correlation-ID',
|
||||
HlsInitSegment = 'x-immich-hls-msn',
|
||||
HlsPosition = 'x-immich-hls-pos',
|
||||
AssetData = 'x-immich-asset-data',
|
||||
}
|
||||
|
||||
export enum ImmichQuery {
|
||||
@@ -299,6 +298,7 @@ export enum Permission {
|
||||
WorkflowRead = 'workflow.read',
|
||||
WorkflowUpdate = 'workflow.update',
|
||||
WorkflowDelete = 'workflow.delete',
|
||||
WorkflowLogs = 'workflow.logs',
|
||||
|
||||
AdminUserCreate = 'adminUser.create',
|
||||
AdminUserRead = 'adminUser.read',
|
||||
@@ -392,7 +392,6 @@ export enum AssetStatus {
|
||||
Active = 'active',
|
||||
Trashed = 'trashed',
|
||||
Deleted = 'deleted',
|
||||
Partial = 'partial',
|
||||
}
|
||||
|
||||
export enum SourceType {
|
||||
@@ -786,7 +785,6 @@ export enum BootstrapEventPriority {
|
||||
JobService = -190,
|
||||
// Initialize config after other bootstrap services, stop other services from using config on bootstrap
|
||||
SystemConfig = 100,
|
||||
UploadService = 180,
|
||||
PluginSync = 190,
|
||||
// Load plugins into memory after sync
|
||||
PluginLoad = 200,
|
||||
@@ -843,8 +841,6 @@ export enum JobName {
|
||||
AssetFileMigration = 'AssetFileMigration',
|
||||
AssetGenerateThumbnailsQueueAll = 'AssetGenerateThumbnailsQueueAll',
|
||||
AssetGenerateThumbnails = 'AssetGenerateThumbnails',
|
||||
PartialAssetCleanup = 'PartialAssetCleanup',
|
||||
PartialAssetCleanupQueueAll = 'PartialAssetCleanupQueueAll',
|
||||
|
||||
AuditTableCleanup = 'AuditTableCleanup',
|
||||
|
||||
@@ -1239,3 +1235,14 @@ export enum CalendarHeatmapType {
|
||||
Upload = 'Upload',
|
||||
Taken = 'Taken',
|
||||
}
|
||||
|
||||
export enum WorkflowResult {
|
||||
Completed = 'completed',
|
||||
Halted = 'halted',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export const WorkflowResultSchema = z
|
||||
.enum(WorkflowResult)
|
||||
.describe('Workflow run result')
|
||||
.meta({ id: 'WorkflowResult' });
|
||||
|
||||
@@ -14,7 +14,6 @@ from
|
||||
left join "smart_search" on "asset"."id" = "smart_search"."assetId"
|
||||
where
|
||||
"asset"."id" = $1::uuid
|
||||
and "asset"."status" != 'partial'
|
||||
limit
|
||||
$2
|
||||
|
||||
@@ -45,7 +44,6 @@ from
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."id" = $2::uuid
|
||||
and "asset"."status" != 'partial'
|
||||
limit
|
||||
$3
|
||||
|
||||
@@ -74,7 +72,6 @@ from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $2::uuid
|
||||
and "asset"."status" != 'partial'
|
||||
limit
|
||||
$3
|
||||
|
||||
@@ -86,8 +83,7 @@ from
|
||||
"asset"
|
||||
inner join "asset_job_status" on "asset_job_status"."assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
"asset"."deletedAt" is null
|
||||
and "asset"."visibility" != 'hidden'
|
||||
and (
|
||||
not exists (
|
||||
@@ -200,7 +196,6 @@ from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.getForGenerateThumbnailJob
|
||||
select
|
||||
@@ -308,7 +303,6 @@ from
|
||||
left join "asset_video" on "asset_video"."assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."id" = $4
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.getForMetadataExtraction
|
||||
select
|
||||
@@ -365,17 +359,14 @@ from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $3
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.getLockedPropertiesForMetadataExtraction
|
||||
select
|
||||
"asset_exif"."lockedProperties"
|
||||
from
|
||||
"asset_exif"
|
||||
inner join "asset" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset_exif"."assetId" = $1
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.getAlbumThumbnailFiles
|
||||
select
|
||||
@@ -385,10 +376,8 @@ select
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
inner join "asset" on "asset"."id" = "asset_file"."assetId"
|
||||
where
|
||||
"asset_file"."assetId" = $1
|
||||
and "asset"."status" != 'partial'
|
||||
and "asset_file"."type" = $2
|
||||
|
||||
-- AssetJobRepository.streamForSearchDuplicates
|
||||
@@ -399,8 +388,7 @@ from
|
||||
inner join "smart_search" on "asset"."id" = "smart_search"."assetId"
|
||||
inner join "asset_job_status" as "job_status" on "job_status"."assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
"asset"."deletedAt" is null
|
||||
and "asset"."visibility" in ('archive', 'timeline')
|
||||
and "job_status"."duplicatesDetectedAt" is null
|
||||
|
||||
@@ -412,7 +400,6 @@ from
|
||||
inner join "asset_job_status" as "job_status" on "assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."visibility" != $1
|
||||
and "asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
and exists (
|
||||
select
|
||||
@@ -455,7 +442,6 @@ from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $2
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.getForDetectFacesJob
|
||||
select
|
||||
@@ -497,7 +483,6 @@ from
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."id" = $2
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.getForOcr
|
||||
select
|
||||
@@ -516,7 +501,6 @@ from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.getForSyncAssets
|
||||
select
|
||||
@@ -530,7 +514,6 @@ from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = any ($1::uuid[])
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.getForAssetDeletion
|
||||
select
|
||||
@@ -589,7 +572,6 @@ from
|
||||
) as "stack_result" on true
|
||||
where
|
||||
"asset"."id" = $3
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.streamForVideoConversion
|
||||
select
|
||||
@@ -608,7 +590,6 @@ where
|
||||
and "asset_file"."type" = 'encoded_video'
|
||||
)
|
||||
and "asset"."visibility" != 'hidden'
|
||||
and "asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
|
||||
-- AssetJobRepository.getForVideoConversion
|
||||
@@ -716,7 +697,6 @@ from
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "asset"."type" = 'VIDEO'
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.streamForMetadataExtraction
|
||||
select
|
||||
@@ -729,7 +709,6 @@ where
|
||||
"asset_job_status"."metadataExtractedAt" is null
|
||||
or "asset_job_status"."assetId" is null
|
||||
)
|
||||
and "asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
|
||||
-- AssetJobRepository.getForStorageTemplateJob
|
||||
@@ -770,8 +749,7 @@ from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
"asset"."deletedAt" is null
|
||||
and "asset"."id" = $2
|
||||
and "asset"."visibility" != $3
|
||||
|
||||
@@ -813,8 +791,7 @@ from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
"asset"."deletedAt" is null
|
||||
and "asset"."visibility" != $2
|
||||
|
||||
-- AssetJobRepository.streamForDeletedJob
|
||||
@@ -825,7 +802,6 @@ from
|
||||
"asset"
|
||||
where
|
||||
"asset"."deletedAt" <= $1
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.streamForSidecar
|
||||
select
|
||||
@@ -842,7 +818,6 @@ where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $1
|
||||
)
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.streamForDetectFacesJob
|
||||
select
|
||||
@@ -852,7 +827,6 @@ from
|
||||
inner join "asset_job_status" as "job_status" on "assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."visibility" != $1
|
||||
and "asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
and exists (
|
||||
select
|
||||
@@ -862,7 +836,6 @@ where
|
||||
"assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $2
|
||||
)
|
||||
and "asset"."status" != 'partial'
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
|
||||
@@ -876,7 +849,6 @@ where
|
||||
"asset_job_status"."ocrAt" is null
|
||||
and "asset"."deletedAt" is null
|
||||
and "asset"."visibility" != $1
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.streamForMigrationJob
|
||||
select
|
||||
@@ -884,14 +856,4 @@ select
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
|
||||
-- AssetJobRepository.streamForPartialAssetCleanupJob
|
||||
select
|
||||
"id"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."status" = 'partial'
|
||||
and "asset"."createdAt" < $1
|
||||
"asset"."deletedAt" is null
|
||||
|
||||
@@ -101,87 +101,6 @@ where
|
||||
and "key" = $2
|
||||
commit
|
||||
|
||||
-- AssetRepository.getCompletionMetadata
|
||||
select
|
||||
"originalPath" as "path",
|
||||
"status",
|
||||
"fileModifiedAt",
|
||||
"createdAt",
|
||||
"checksum",
|
||||
"fileSizeInByte" as "size"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"id" = $1
|
||||
and "ownerId" = $2
|
||||
|
||||
-- AssetRepository.setComplete
|
||||
with
|
||||
"completed_asset" as (
|
||||
update "asset" as "complete_asset"
|
||||
set
|
||||
"status" = 'active',
|
||||
"visibility" = case
|
||||
when (
|
||||
"complete_asset"."type" = 'VIDEO'
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"complete_asset"."id" = "asset"."livePhotoVideoId"
|
||||
)
|
||||
) then 'hidden'::asset_visibility_enum
|
||||
else 'timeline'::asset_visibility_enum
|
||||
end
|
||||
where
|
||||
"id" = $1
|
||||
and "status" = 'partial'
|
||||
returning
|
||||
*
|
||||
),
|
||||
"shared_link" as (
|
||||
insert into
|
||||
"album_asset" ("albumId", "assetId")
|
||||
select
|
||||
$2 as "albumId",
|
||||
"completed_asset"."id"
|
||||
from
|
||||
"completed_asset"
|
||||
on conflict do nothing
|
||||
)
|
||||
select
|
||||
*
|
||||
from
|
||||
"completed_asset"
|
||||
|
||||
-- AssetRepository.removeAndDecrementQuota
|
||||
with
|
||||
"asset_exif" as (
|
||||
select
|
||||
"fileSizeInByte"
|
||||
from
|
||||
"asset_exif"
|
||||
where
|
||||
"assetId" = $1
|
||||
),
|
||||
"asset" as (
|
||||
delete from "asset"
|
||||
where
|
||||
"id" = $2
|
||||
returning
|
||||
"ownerId"
|
||||
)
|
||||
update "user"
|
||||
set
|
||||
"quotaUsageInBytes" = "quotaUsageInBytes" - "fileSizeInByte"
|
||||
from
|
||||
"asset_exif",
|
||||
"asset"
|
||||
where
|
||||
"user"."id" = "asset"."ownerId"
|
||||
|
||||
-- AssetRepository.getByDayOfYear
|
||||
with
|
||||
"res" as (
|
||||
@@ -410,9 +329,7 @@ where
|
||||
|
||||
-- AssetRepository.getUploadAssetIdByChecksum
|
||||
select
|
||||
"id",
|
||||
"status",
|
||||
"createdAt"
|
||||
"id"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
|
||||
@@ -9,6 +9,7 @@ select
|
||||
"workflow"."enabled",
|
||||
"workflow"."createdAt",
|
||||
"workflow"."updatedAt",
|
||||
"workflow"."logging",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -41,6 +42,7 @@ select
|
||||
"workflow"."enabled",
|
||||
"workflow"."createdAt",
|
||||
"workflow"."updatedAt",
|
||||
"workflow"."logging",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -69,6 +71,7 @@ select
|
||||
"workflow"."id",
|
||||
"workflow"."name",
|
||||
"workflow"."trigger",
|
||||
"workflow"."logging",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -96,6 +99,40 @@ where
|
||||
"id" = $2
|
||||
and "enabled" = $3
|
||||
|
||||
-- WorkflowRepository.getLogs
|
||||
select
|
||||
"workflow_log"."id",
|
||||
"workflow_log"."createdAt",
|
||||
"workflow_log"."halted",
|
||||
"workflow_log"."error",
|
||||
"workflow_log"."workflowId",
|
||||
"workflow_log"."workflowStepId",
|
||||
"workflow_log"."triggerDataId",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"plugin_method"."pluginId",
|
||||
"plugin_method"."name" as "methodName",
|
||||
"workflow_step"."order"
|
||||
from
|
||||
"workflow_step"
|
||||
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"
|
||||
where
|
||||
"workflow_step"."id" = "workflow_log"."workflowStepId"
|
||||
) as obj
|
||||
) as "step"
|
||||
from
|
||||
"workflow_log"
|
||||
where
|
||||
"workflow_log"."workflowId" = $1
|
||||
order by
|
||||
"workflow_log"."createdAt" desc
|
||||
limit
|
||||
$2
|
||||
|
||||
-- WorkflowRepository.delete
|
||||
delete from "workflow"
|
||||
where
|
||||
|
||||
@@ -31,7 +31,6 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.id', '=', asUuid(id))
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.leftJoin('smart_search', 'asset.id', 'smart_search.assetId')
|
||||
.select(['id', 'type', 'ownerId', 'duplicateId', 'stackId', 'visibility', 'smart_search.embedding'])
|
||||
.limit(1)
|
||||
@@ -43,7 +42,6 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.id', '=', asUuid(id))
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.select(['id', 'originalPath'])
|
||||
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
|
||||
.$call(withExifInner)
|
||||
@@ -56,7 +54,6 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.id', '=', asUuid(id))
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.select(['id', 'originalPath'])
|
||||
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
|
||||
.limit(1)
|
||||
@@ -68,7 +65,6 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.id', 'asset.isEdited'])
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.where('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden))
|
||||
.$if(!options.force, (qb) =>
|
||||
@@ -114,7 +110,6 @@ export class AssetJobRepository {
|
||||
.select(['asset.id', 'asset.ownerId'])
|
||||
.select(withFiles)
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -146,7 +141,6 @@ export class AssetJobRepository {
|
||||
.select((eb) => withVideoStream(eb).as('videoStream'))
|
||||
.select((eb) => withVideoFormat(eb).as('format'))
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -158,7 +152,6 @@ export class AssetJobRepository {
|
||||
.select(withFaces)
|
||||
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -166,10 +159,8 @@ export class AssetJobRepository {
|
||||
async getLockedPropertiesForMetadataExtraction(assetId: string) {
|
||||
return this.db
|
||||
.selectFrom('asset_exif')
|
||||
.innerJoin('asset', 'asset.id', 'asset_exif.assetId')
|
||||
.select('asset_exif.lockedProperties')
|
||||
.where('asset_exif.assetId', '=', assetId)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst()
|
||||
.then((row) => row?.lockedProperties ?? []);
|
||||
}
|
||||
@@ -178,10 +169,8 @@ export class AssetJobRepository {
|
||||
getAlbumThumbnailFiles(id: string, fileType?: AssetFileType) {
|
||||
return this.db
|
||||
.selectFrom('asset_file')
|
||||
.innerJoin('asset', 'asset.id', 'asset_file.assetId')
|
||||
.select(columns.assetFiles)
|
||||
.where('asset_file.assetId', '=', id)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.$if(!!fileType, (qb) => qb.where('asset_file.type', '=', fileType!))
|
||||
.execute();
|
||||
}
|
||||
@@ -190,7 +179,6 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.visibility', '!=', AssetVisibility.Hidden)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.innerJoin('asset_job_status as job_status', 'assetId', 'asset.id')
|
||||
.where((eb) =>
|
||||
@@ -208,7 +196,6 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.id'])
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.innerJoin('smart_search', 'asset.id', 'smart_search.assetId')
|
||||
.$call(withDefaultVisibility)
|
||||
@@ -237,7 +224,6 @@ export class AssetJobRepository {
|
||||
.select(['asset.id', 'asset.visibility'])
|
||||
.select((eb) => withFiles(eb, AssetFileType.Preview))
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -250,7 +236,6 @@ export class AssetJobRepository {
|
||||
.select((eb) => withFaces(eb, true, true))
|
||||
.select((eb) => withFiles(eb, AssetFileType.Preview))
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -260,7 +245,6 @@ export class AssetJobRepository {
|
||||
.selectFrom('asset')
|
||||
.select((eb) => ['asset.visibility', withFilePath(eb, AssetFileType.Preview).as('previewFile')])
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -277,7 +261,6 @@ export class AssetJobRepository {
|
||||
'asset.fileModifiedAt',
|
||||
])
|
||||
.where('asset.id', '=', anyUuid(ids))
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -324,7 +307,6 @@ export class AssetJobRepository {
|
||||
.as('stack'),
|
||||
)
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -349,7 +331,6 @@ export class AssetJobRepository {
|
||||
)
|
||||
.where('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden)),
|
||||
)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.stream();
|
||||
}
|
||||
@@ -368,7 +349,6 @@ export class AssetJobRepository {
|
||||
.select((eb) => withVideoFormat(eb).$notNull().as('format'))
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.type', '=', sql.lit(AssetType.Video))
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -384,7 +364,6 @@ export class AssetJobRepository {
|
||||
eb.or([eb('asset_job_status.metadataExtractedAt', 'is', null), eb('asset_job_status.assetId', 'is', null)]),
|
||||
),
|
||||
)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.stream();
|
||||
}
|
||||
@@ -411,7 +390,6 @@ export class AssetJobRepository {
|
||||
'asset_exif.lensModel',
|
||||
])
|
||||
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.where('asset.deletedAt', 'is', null);
|
||||
}
|
||||
|
||||
@@ -434,7 +412,6 @@ export class AssetJobRepository {
|
||||
.selectFrom('asset')
|
||||
.select(['id', 'isOffline'])
|
||||
.where('asset.deletedAt', '<=', trashedBefore)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.stream();
|
||||
}
|
||||
|
||||
@@ -456,7 +433,6 @@ export class AssetJobRepository {
|
||||
),
|
||||
),
|
||||
)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.stream();
|
||||
}
|
||||
|
||||
@@ -465,7 +441,6 @@ export class AssetJobRepository {
|
||||
return this.assetsWithPreviews()
|
||||
.$if(force === false, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null))
|
||||
.select(['asset.id'])
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.orderBy('asset.fileCreatedAt', 'desc')
|
||||
.stream();
|
||||
}
|
||||
@@ -482,37 +457,11 @@ export class AssetJobRepository {
|
||||
)
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.where('asset.visibility', '!=', AssetVisibility.Hidden)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.stream();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.DATE], stream: true })
|
||||
streamForMigrationJob() {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['id'])
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.stream();
|
||||
}
|
||||
|
||||
getForPartialAssetCleanupJob(assetId: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.select(['originalPath as path', 'fileSizeInByte as size', 'checksum', 'fileModifiedAt'])
|
||||
.where('id', '=', assetId)
|
||||
.where('status', '=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.DATE], stream: true })
|
||||
streamForPartialAssetCleanupJob(createdBefore: Date) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['id'])
|
||||
.where('asset.status', '=', sql.lit(AssetStatus.Partial))
|
||||
.where('asset.createdAt', '<', createdBefore)
|
||||
.stream();
|
||||
return this.db.selectFrom('asset').select(['id']).where('asset.deletedAt', 'is', null).stream();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,130 +446,6 @@ export class AssetRepository {
|
||||
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
createWithMetadata(
|
||||
asset: Insertable<AssetTable> & { id: string },
|
||||
size: number,
|
||||
metadata?: Omit<Insertable<AssetMetadataTable>, 'assetId'>[],
|
||||
) {
|
||||
let query = this.db;
|
||||
if (asset.livePhotoVideoId) {
|
||||
(query as any) = query.with('motion_asset', (qb) =>
|
||||
qb
|
||||
.updateTable('asset')
|
||||
.set({ visibility: AssetVisibility.Hidden })
|
||||
.where('id', '=', asset.livePhotoVideoId!)
|
||||
.where('type', '=', sql.lit(AssetType.Video))
|
||||
.where('ownerId', '=', asset.ownerId)
|
||||
.returning('id'),
|
||||
);
|
||||
}
|
||||
|
||||
(query as any) = query
|
||||
.with('asset', (qb) =>
|
||||
qb
|
||||
.insertInto('asset')
|
||||
.values(
|
||||
asset.livePhotoVideoId ? { ...asset, livePhotoVideoId: sql<string>`(select id from motion_asset)` } : asset,
|
||||
)
|
||||
.returning(['id', 'ownerId']),
|
||||
)
|
||||
.with('exif', (qb) =>
|
||||
qb
|
||||
.insertInto('asset_exif')
|
||||
.columns(['assetId', 'fileSizeInByte'])
|
||||
.expression((eb) => eb.selectFrom('asset').select(['asset.id', eb.val(size).as('fileSizeInByte')])),
|
||||
);
|
||||
|
||||
if (metadata && metadata.length > 0) {
|
||||
(query as any) = query.with('metadata', (qb) =>
|
||||
qb.insertInto('asset_metadata').values(metadata.map(({ key, value }) => ({ assetId: asset.id, key, value }))),
|
||||
);
|
||||
}
|
||||
|
||||
return query
|
||||
.updateTable('user')
|
||||
.from('asset')
|
||||
.set({ quotaUsageInBytes: sql`"quotaUsageInBytes" + ${size}` })
|
||||
.whereRef('user.id', '=', 'asset.ownerId')
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
||||
getCompletionMetadata(assetId: string, ownerId: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.select(['originalPath as path', 'status', 'fileModifiedAt', 'createdAt', 'checksum', 'fileSizeInByte as size'])
|
||||
.where('id', '=', assetId)
|
||||
.where('ownerId', '=', ownerId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, { albumId: DummyValue.UUID, id: DummyValue.UUID }] })
|
||||
setComplete(assetId: string, sharedLink?: { albumId?: string | null; id: string }) {
|
||||
const completedAsset = this.db
|
||||
.updateTable('asset as complete_asset')
|
||||
.set((eb) => ({
|
||||
status: sql.lit(AssetStatus.Active),
|
||||
visibility: eb
|
||||
.case()
|
||||
.when(
|
||||
eb.and([
|
||||
eb('complete_asset.type', '=', sql.lit(AssetType.Video)),
|
||||
eb.exists(eb.selectFrom('asset').whereRef('complete_asset.id', '=', 'asset.livePhotoVideoId')),
|
||||
]),
|
||||
)
|
||||
.then(sql<AssetVisibility>`'hidden'::asset_visibility_enum`)
|
||||
.else(sql<AssetVisibility>`'timeline'::asset_visibility_enum`)
|
||||
.end(),
|
||||
}))
|
||||
.where('id', '=', assetId)
|
||||
.where('status', '=', sql.lit(AssetStatus.Partial))
|
||||
.returningAll();
|
||||
if (!sharedLink) {
|
||||
return completedAsset.executeTakeFirst();
|
||||
}
|
||||
|
||||
return this.db
|
||||
.with('completed_asset', () => completedAsset)
|
||||
.with('shared_link', (qb) =>
|
||||
sharedLink?.albumId
|
||||
? qb
|
||||
.insertInto('album_asset')
|
||||
.columns(['albumId', 'assetId'])
|
||||
.expression((eb) =>
|
||||
eb
|
||||
.selectFrom('completed_asset')
|
||||
.select([eb.val(sharedLink.albumId).as('albumId'), 'completed_asset.id']),
|
||||
)
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
: qb
|
||||
.insertInto('shared_link_asset')
|
||||
.columns(['sharedLinkId', 'assetId'])
|
||||
.expression((eb) =>
|
||||
eb
|
||||
.selectFrom('completed_asset')
|
||||
.select([eb.val(sharedLink.id).as('sharedLinkId'), 'completed_asset.id']),
|
||||
)
|
||||
.onConflict((oc) => oc.doNothing()),
|
||||
)
|
||||
.selectFrom('completed_asset')
|
||||
.selectAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async removeAndDecrementQuota(id: string): Promise<void> {
|
||||
await this.db
|
||||
.with('asset_exif', (qb) => qb.selectFrom('asset_exif').where('assetId', '=', id).select('fileSizeInByte'))
|
||||
.with('asset', (qb) => qb.deleteFrom('asset').where('id', '=', id).returning('ownerId'))
|
||||
.updateTable('user')
|
||||
.from(['asset_exif', 'asset'])
|
||||
.set({ quotaUsageInBytes: sql`"quotaUsageInBytes" - "fileSizeInByte"` })
|
||||
.whereRef('user.id', '=', 'asset.ownerId')
|
||||
.execute();
|
||||
}
|
||||
|
||||
@ChunkedArray({ chunkSize: 4000 })
|
||||
async createAll(assets: Insertable<AssetTable>[]) {
|
||||
const ids = await this.db.insertInto('asset').values(assets).returning('id').execute();
|
||||
@@ -795,15 +671,17 @@ export class AssetRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] })
|
||||
getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer) {
|
||||
return this.db
|
||||
async getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise<string | undefined> {
|
||||
const asset = await this.db
|
||||
.selectFrom('asset')
|
||||
.select(['id', 'status', 'createdAt'])
|
||||
.select('id')
|
||||
.where('ownerId', '=', asUuid(ownerId))
|
||||
.where('checksum', '=', checksum)
|
||||
.where('libraryId', 'is', null)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
return asset?.id;
|
||||
}
|
||||
|
||||
findLivePhotoMatch(options: LivePhotoSearchOptions) {
|
||||
|
||||
@@ -449,20 +449,6 @@ export class DatabaseRepository {
|
||||
return res as R;
|
||||
}
|
||||
|
||||
async withUuidLock<R>(uuid: string, callback: () => Promise<R>): Promise<R> {
|
||||
let res;
|
||||
await this.db.connection().execute(async (connection) => {
|
||||
try {
|
||||
await this.acquireUuidLock(uuid, connection);
|
||||
res = await callback();
|
||||
} finally {
|
||||
await this.releaseUuidLock(uuid, connection);
|
||||
}
|
||||
});
|
||||
|
||||
return res as R;
|
||||
}
|
||||
|
||||
tryLock(lock: DatabaseLock): Promise<boolean> {
|
||||
return this.db.connection().execute(async (connection) => this.acquireTryLock(lock, connection));
|
||||
}
|
||||
@@ -479,10 +465,6 @@ export class DatabaseRepository {
|
||||
await sql`SELECT pg_advisory_lock(${lock})`.execute(connection);
|
||||
}
|
||||
|
||||
private async acquireUuidLock(uuid: string, connection: Kysely<DB>): Promise<void> {
|
||||
await sql`SELECT pg_advisory_lock(uuid_hash_extended(${uuid}, 0))`.execute(connection);
|
||||
}
|
||||
|
||||
private async acquireTryLock(lock: DatabaseLock, connection: Kysely<DB>): Promise<boolean> {
|
||||
const { rows } = await sql<{
|
||||
pg_try_advisory_lock: boolean;
|
||||
@@ -494,10 +476,6 @@ export class DatabaseRepository {
|
||||
await sql`SELECT pg_advisory_unlock(${lock})`.execute(connection);
|
||||
}
|
||||
|
||||
private async releaseUuidLock(uuid: string, connection: Kysely<DB>): Promise<void> {
|
||||
await sql`SELECT pg_advisory_unlock(uuid_hash_extended(${uuid}, 0))`.execute(connection);
|
||||
}
|
||||
|
||||
async revertLastMigration(): Promise<string | undefined> {
|
||||
this.logger.debug('Reverting last migration');
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ type EventMap = {
|
||||
AlbumInvite: [{ id: string; userId: string; senderName: string }];
|
||||
|
||||
// asset events
|
||||
AssetCreate: [{ asset: Asset; file?: UploadFile }];
|
||||
AssetCreate: [{ asset: Asset; file: UploadFile }];
|
||||
AssetTag: [{ assetId: string }];
|
||||
AssetUntag: [{ assetId: string }];
|
||||
AssetHide: [{ assetId: string; userId: string }];
|
||||
@@ -81,9 +81,6 @@ type EventMap = {
|
||||
// stack bulk events
|
||||
StackDeleteAll: [{ stackIds: string[]; userId: string }];
|
||||
|
||||
// upload events
|
||||
UploadAbort: [{ assetId: string; abortTime: Date }];
|
||||
|
||||
// user events
|
||||
UserSignup: [{ notify: boolean; id: string; password?: string }];
|
||||
UserCreate: [UserEvent];
|
||||
|
||||
@@ -154,7 +154,8 @@ export class IntegrityRepository {
|
||||
.select(['allPaths.path as path', 'allPaths.assetId', 'allPaths.fileAssetId', 'integrity_report.id as reportId'])
|
||||
.stream() as AsyncIterableIterator<
|
||||
{ path: string; reportId: string | null } & (
|
||||
{ assetId: string; fileAssetId: null } | { assetId: null; fileAssetId: string }
|
||||
| { assetId: string; fileAssetId: null }
|
||||
| { assetId: null; fileAssetId: string }
|
||||
)
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -75,14 +75,8 @@ export class StorageRepository {
|
||||
return fs.writeFile(filepath, buffer, { flag: 'wx' });
|
||||
}
|
||||
|
||||
createWriteStream(filepath: string, options?: { flush: boolean }): Writable {
|
||||
const flush = options?.flush ?? true;
|
||||
return createWriteStream(filepath, { flags: 'w', flush, highWaterMark: 1024 * 1024 });
|
||||
}
|
||||
|
||||
createOrAppendWriteStream(filepath: string, options?: { flush: boolean }): Writable {
|
||||
const flush = options?.flush ?? true;
|
||||
return createWriteStream(filepath, { flags: 'a', flush, highWaterMark: 1024 * 1024 });
|
||||
createWriteStream(filepath: string): Writable {
|
||||
return createWriteStream(filepath, { flags: 'w', flush: true });
|
||||
}
|
||||
|
||||
createOrOverwriteFile(filepath: string, buffer: Buffer) {
|
||||
@@ -205,13 +199,10 @@ export class StorageRepository {
|
||||
}
|
||||
}
|
||||
|
||||
mkdir(filepath: string): Promise<string | undefined> {
|
||||
return fs.mkdir(filepath, { recursive: true });
|
||||
}
|
||||
|
||||
mkdirSync(filepath: string): void {
|
||||
// does not throw an error if the folder already exists
|
||||
mkdirSync(filepath, { recursive: true });
|
||||
if (!existsSync(filepath)) {
|
||||
mkdirSync(filepath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
existsSync(filepath: string) {
|
||||
|
||||
@@ -25,9 +25,7 @@ export const serverEvents = [
|
||||
'HlsSessionRequest',
|
||||
'HlsSessionResult',
|
||||
'HlsSessionEnd',
|
||||
'UploadAbort',
|
||||
] as const;
|
||||
|
||||
export type ServerEvents = (typeof serverEvents)[number];
|
||||
|
||||
export interface ClientEventMap {
|
||||
|
||||
@@ -4,8 +4,10 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { WorkflowSearchDto } from 'src/dtos/workflow.dto';
|
||||
import { WorkflowGetLogsDto, WorkflowSearchDto } from 'src/dtos/workflow.dto';
|
||||
import { WorkflowResult } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { WorkflowLogTable } from 'src/schema/tables/workflow-log.table';
|
||||
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
|
||||
import { WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
|
||||
@@ -26,6 +28,7 @@ export class WorkflowRepository {
|
||||
'workflow.enabled',
|
||||
'workflow.createdAt',
|
||||
'workflow.updatedAt',
|
||||
'workflow.logging',
|
||||
])
|
||||
.select((eb) => [
|
||||
jsonArrayFrom(
|
||||
@@ -64,7 +67,7 @@ export class WorkflowRepository {
|
||||
getForWorkflowRun(id: string) {
|
||||
return this.db
|
||||
.selectFrom('workflow')
|
||||
.select(['workflow.id', 'workflow.name', 'workflow.trigger'])
|
||||
.select(['workflow.id', 'workflow.name', 'workflow.trigger', 'workflow.logging'])
|
||||
.select((eb) => [
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
@@ -97,6 +100,9 @@ export class WorkflowRepository {
|
||||
|
||||
update(id: string, dto: Updateable<WorkflowTable>, steps?: WorkflowStepUpsert[]) {
|
||||
return this.db.transaction().execute(async (tx) => {
|
||||
if (dto.logging === false) {
|
||||
await tx.deleteFrom('workflow_log').where('workflowId', '=', id).execute();
|
||||
}
|
||||
if (Object.values(dto).some((prop) => prop !== undefined)) {
|
||||
await tx.updateTable('workflow').set(dto).where('id', '=', id).executeTakeFirstOrThrow();
|
||||
}
|
||||
@@ -104,6 +110,44 @@ export class WorkflowRepository {
|
||||
});
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, { result: undefined }] })
|
||||
getLogs(id: string, dto: WorkflowGetLogsDto) {
|
||||
return this.db
|
||||
.selectFrom('workflow_log')
|
||||
.select([
|
||||
'workflow_log.id',
|
||||
'workflow_log.createdAt',
|
||||
'workflow_log.halted',
|
||||
'workflow_log.error',
|
||||
'workflow_log.workflowId',
|
||||
'workflow_log.workflowStepId',
|
||||
'workflow_log.triggerDataId',
|
||||
])
|
||||
.where('workflow_log.workflowId', '=', id)
|
||||
.select((eb) => [
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('workflow_step')
|
||||
.whereRef('workflow_step.id', '=', 'workflow_log.workflowStepId')
|
||||
.innerJoin('plugin_method', 'plugin_method.id', 'workflow_step.pluginMethodId')
|
||||
.select(['plugin_method.pluginId', 'plugin_method.name as methodName', 'workflow_step.order']),
|
||||
).as('step'),
|
||||
])
|
||||
.$if(dto.result === WorkflowResult.Error, (qb) => qb.where('workflow_log.error', '=', true))
|
||||
.$if(dto.result === WorkflowResult.Halted, (qb) => qb.where('workflow_log.halted', '=', true))
|
||||
.$if(dto.result === WorkflowResult.Completed, (qb) =>
|
||||
qb.where('workflow_log.halted', '=', false).where('workflow_log.error', '=', false),
|
||||
)
|
||||
.$if(dto.before !== undefined, (qb) => qb.where('workflow_log.createdAt', '<', dto.before!))
|
||||
.orderBy('workflow_log.createdAt', 'desc')
|
||||
.limit(dto.limit)
|
||||
.execute();
|
||||
}
|
||||
|
||||
log(dto: Insertable<WorkflowLogTable>) {
|
||||
return this.db.insertInto('workflow_log').values(dto).execute();
|
||||
}
|
||||
|
||||
async updateStep(id: string, dto: Updateable<WorkflowStepTable>) {
|
||||
await this.db.updateTable('workflow_step').where('workflow_step.id', '=', id).set(dto).execute();
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
VideoStreamSessionTable,
|
||||
VideoStreamVariantTable,
|
||||
} from 'src/schema/tables/video-stream.table';
|
||||
import { WorkflowLogTable } from 'src/schema/tables/workflow-log.table';
|
||||
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
|
||||
import { WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
|
||||
@@ -277,4 +278,5 @@ export interface DB {
|
||||
|
||||
workflow: WorkflowTable;
|
||||
workflow_step: WorkflowStepTable;
|
||||
workflow_log: WorkflowLogTable;
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TYPE "assets_status_enum" ADD VALUE IF NOT EXISTS 'partial'`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// Cannot remove enum values in PostgreSQL
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "workflow" ADD "logging" boolean NOT NULL DEFAULT false;`.execute(db);
|
||||
await sql`CREATE TABLE "workflow_log" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"workflowId" uuid NOT NULL,
|
||||
"error" boolean NOT NULL,
|
||||
"halted" boolean NOT NULL,
|
||||
"workflowStepId" uuid,
|
||||
"triggerDataId" uuid,
|
||||
CONSTRAINT "workflow_log_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "workflow_log_workflowStepId_fkey" FOREIGN KEY ("workflowStepId") REFERENCES "workflow_step" ("id") ON UPDATE CASCADE ON DELETE SET NULL,
|
||||
CONSTRAINT "workflow_log_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "workflow_log_workflowId_idx" ON "workflow_log" ("workflowId");`.execute(db);
|
||||
await sql`CREATE INDEX "workflow_log_workflowStepId_idx" ON "workflow_log" ("workflowStepId");`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "workflow" DROP COLUMN "logging";`.execute(db);
|
||||
await sql`DROP TABLE "workflow_log";`.execute(db);
|
||||
}
|
||||
35
server/src/schema/tables/workflow-log.table.ts
Normal file
35
server/src/schema/tables/workflow-log.table.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
Generated,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Timestamp,
|
||||
} from '@immich/sql-tools';
|
||||
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
|
||||
import { WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
|
||||
@Table('workflow_log')
|
||||
export class WorkflowLogTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
||||
@ForeignKeyColumn(() => WorkflowTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', index: true })
|
||||
workflowId!: string;
|
||||
|
||||
@Column({ type: 'boolean' })
|
||||
error!: boolean;
|
||||
|
||||
@Column({ type: 'boolean' })
|
||||
halted!: boolean;
|
||||
|
||||
@ForeignKeyColumn(() => WorkflowStepTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true })
|
||||
workflowStepId!: string | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
triggerDataId!: string | null;
|
||||
}
|
||||
@@ -41,4 +41,7 @@ export class WorkflowTable {
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled!: Generated<boolean>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
logging!: Generated<boolean>;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos
|
||||
import { AssetMediaCreateDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||
import { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum';
|
||||
import { AssetFileType, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { UploadBody } from 'src/types';
|
||||
@@ -188,11 +188,7 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should find an existing asset', async () => {
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue({
|
||||
id: 'asset-id',
|
||||
createdAt: new Date(),
|
||||
status: AssetStatus.Active,
|
||||
});
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({
|
||||
id: 'asset-id',
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
@@ -201,11 +197,7 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should find an existing asset by base64', async () => {
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue({
|
||||
id: 'asset-id',
|
||||
createdAt: new Date(),
|
||||
status: AssetStatus.Active,
|
||||
});
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({
|
||||
id: 'asset-id',
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
@@ -367,11 +359,7 @@ describe(AssetMediaService.name, () => {
|
||||
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
mocks.asset.create.mockRejectedValue(error);
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue({
|
||||
id: assetEntity.id,
|
||||
createdAt: new Date(),
|
||||
status: AssetStatus.Active,
|
||||
});
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id);
|
||||
|
||||
await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({
|
||||
id: 'id_1',
|
||||
|
||||
@@ -49,12 +49,12 @@ export class AssetMediaService extends BaseService {
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum));
|
||||
if (!asset) {
|
||||
const assetId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum));
|
||||
if (!assetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { id: asset.id, status: AssetMediaStatus.DUPLICATE };
|
||||
return { id: assetId, status: AssetMediaStatus.DUPLICATE };
|
||||
}
|
||||
|
||||
canUploadFile({ auth, fieldName, file, body }: UploadRequest): true {
|
||||
@@ -203,18 +203,18 @@ export class AssetMediaService extends BaseService {
|
||||
|
||||
// handle duplicates with a success response
|
||||
if (isAssetChecksumConstraint(error)) {
|
||||
const duplicate = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
|
||||
if (!duplicate) {
|
||||
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
|
||||
if (!duplicateId) {
|
||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.addToSharedLink(auth.sharedLink, duplicate.id);
|
||||
await this.addToSharedLink(auth.sharedLink, duplicateId);
|
||||
}
|
||||
|
||||
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicate.id}`);
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicate.id };
|
||||
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||
}
|
||||
|
||||
this.logger.error(`Error uploading file ${error}`, error?.stack);
|
||||
|
||||
@@ -1,455 +0,0 @@
|
||||
import { BadRequestException, InternalServerErrorException } from '@nestjs/common';
|
||||
import { AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
|
||||
import { AssetUploadService } from 'src/services/asset-upload.service';
|
||||
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(AssetUploadService.name, () => {
|
||||
let sut: AssetUploadService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(AssetUploadService));
|
||||
mocks.asset.setComplete.mockResolvedValue({ id: factory.uuid() } as any);
|
||||
});
|
||||
|
||||
describe('onStart', () => {
|
||||
const mockDto = {
|
||||
assetData: {
|
||||
filename: 'test.jpg',
|
||||
deviceAssetId: 'device-asset-1',
|
||||
deviceId: 'device-1',
|
||||
fileCreatedAt: new Date('2025-01-01T00:00:00Z'),
|
||||
fileModifiedAt: new Date('2025-01-01T12:00:00Z'),
|
||||
isFavorite: false,
|
||||
iCloudId: '',
|
||||
},
|
||||
checksum: Buffer.from('checksum'),
|
||||
uploadLength: 1024,
|
||||
uploadComplete: true,
|
||||
contentLength: 1024,
|
||||
isComplete: true,
|
||||
version: 8,
|
||||
};
|
||||
|
||||
it('should create a new asset and return upload metadata', async () => {
|
||||
const assetId = factory.uuid();
|
||||
mocks.crypto.randomUUID.mockReturnValue(assetId);
|
||||
|
||||
const result = await sut.onStart(authStub.user1, mockDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: assetId,
|
||||
path: expect.stringContaining(assetId),
|
||||
status: AssetStatus.Partial,
|
||||
isDuplicate: false,
|
||||
});
|
||||
|
||||
expect(mocks.asset.createWithMetadata).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: assetId,
|
||||
ownerId: authStub.user1.user.id,
|
||||
checksum: mockDto.checksum,
|
||||
fileCreatedAt: mockDto.assetData.fileCreatedAt,
|
||||
fileModifiedAt: mockDto.assetData.fileModifiedAt,
|
||||
type: AssetType.Image,
|
||||
isFavorite: false,
|
||||
status: AssetStatus.Partial,
|
||||
visibility: AssetVisibility.Hidden,
|
||||
originalFileName: 'test.jpg',
|
||||
}),
|
||||
1024,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should determine asset type from filename extension', async () => {
|
||||
const videoDto = { ...mockDto, assetData: { ...mockDto.assetData, filename: 'video.mp4' } };
|
||||
mocks.crypto.randomUUID.mockReturnValue(factory.uuid());
|
||||
|
||||
await sut.onStart(authStub.user1, videoDto);
|
||||
|
||||
expect(mocks.asset.createWithMetadata).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: AssetType.Video,
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for unsupported file types', async () => {
|
||||
const unsupportedDto = { ...mockDto, assetData: { ...mockDto.assetData, filename: 'document.xyz' } };
|
||||
|
||||
await expect(sut.onStart(authStub.user1, unsupportedDto)).rejects.toThrow(BadRequestException);
|
||||
await expect(sut.onStart(authStub.user1, unsupportedDto)).rejects.toThrow('unsupported file type');
|
||||
});
|
||||
|
||||
it('should validate quota before creating asset', async () => {
|
||||
const authWithQuota = {
|
||||
...authStub.user1,
|
||||
user: {
|
||||
...authStub.user1.user,
|
||||
quotaSizeInBytes: 2000,
|
||||
quotaUsageInBytes: 1500,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(sut.onStart(authWithQuota, mockDto)).rejects.toThrow(BadRequestException);
|
||||
await expect(sut.onStart(authWithQuota, mockDto)).rejects.toThrow('Quota has been exceeded');
|
||||
});
|
||||
|
||||
it('should allow upload when quota is null (unlimited)', async () => {
|
||||
const authWithUnlimitedQuota = {
|
||||
...authStub.user1,
|
||||
user: {
|
||||
...authStub.user1.user,
|
||||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 1000,
|
||||
},
|
||||
};
|
||||
|
||||
mocks.crypto.randomUUID.mockReturnValue(factory.uuid());
|
||||
|
||||
await expect(sut.onStart(authWithUnlimitedQuota, mockDto)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow upload when within quota', async () => {
|
||||
const authWithQuota = {
|
||||
...authStub.user1,
|
||||
user: {
|
||||
...authStub.user1.user,
|
||||
quotaSizeInBytes: 5000,
|
||||
quotaUsageInBytes: 1000,
|
||||
},
|
||||
};
|
||||
|
||||
mocks.crypto.randomUUID.mockReturnValue(factory.uuid());
|
||||
|
||||
const result = await sut.onStart(authWithQuota, mockDto);
|
||||
|
||||
expect(result.isDuplicate).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle duplicate detection via checksum constraint', async () => {
|
||||
const existingAssetId = factory.uuid();
|
||||
const checksumError = new Error('duplicate key value violates unique constraint');
|
||||
(checksumError as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
mocks.asset.createWithMetadata.mockRejectedValue(checksumError);
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue({
|
||||
id: existingAssetId,
|
||||
status: AssetStatus.Partial,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await sut.onStart(authStub.user1, mockDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: existingAssetId,
|
||||
path: expect.any(String),
|
||||
status: AssetStatus.Partial,
|
||||
isDuplicate: true,
|
||||
});
|
||||
|
||||
expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.user1.user.id, mockDto.checksum);
|
||||
});
|
||||
|
||||
it('should throw InternalServerErrorException if duplicate lookup fails', async () => {
|
||||
const checksumError = new Error('duplicate key value violates unique constraint');
|
||||
(checksumError as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
mocks.asset.createWithMetadata.mockRejectedValue(checksumError);
|
||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(undefined);
|
||||
|
||||
await expect(sut.onStart(authStub.user1, mockDto)).rejects.toThrow(InternalServerErrorException);
|
||||
});
|
||||
|
||||
it('should throw InternalServerErrorException for non-checksum errors', async () => {
|
||||
const genericError = new Error('database connection failed');
|
||||
mocks.asset.createWithMetadata.mockRejectedValue(genericError);
|
||||
|
||||
await expect(sut.onStart(authStub.user1, mockDto)).rejects.toThrow(InternalServerErrorException);
|
||||
});
|
||||
|
||||
it('should include iCloud metadata when provided', async () => {
|
||||
const dtoWithICloud = {
|
||||
...mockDto,
|
||||
assetData: {
|
||||
...mockDto.assetData,
|
||||
iCloudId: 'icloud-123',
|
||||
},
|
||||
};
|
||||
|
||||
mocks.crypto.randomUUID.mockReturnValue(factory.uuid());
|
||||
|
||||
await sut.onStart(authStub.user1, dtoWithICloud);
|
||||
|
||||
expect(mocks.asset.createWithMetadata).toHaveBeenCalledWith(expect.anything(), expect.anything(), [
|
||||
{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'icloud-123' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should set isFavorite when true', async () => {
|
||||
const favoriteDto = {
|
||||
...mockDto,
|
||||
assetData: {
|
||||
...mockDto.assetData,
|
||||
isFavorite: true,
|
||||
},
|
||||
};
|
||||
|
||||
mocks.crypto.randomUUID.mockReturnValue(factory.uuid());
|
||||
|
||||
await sut.onStart(authStub.user1, favoriteDto);
|
||||
|
||||
expect(mocks.asset.createWithMetadata).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isFavorite: true,
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onComplete', () => {
|
||||
const assetId = factory.uuid();
|
||||
const path = `/upload/${assetId}/file.jpg`;
|
||||
const fileModifiedAt = new Date('2025-01-01T12:00:00Z');
|
||||
|
||||
it('should mark asset as complete and queue metadata extraction job', async () => {
|
||||
await sut.onComplete({ id: assetId, path, fileModifiedAt });
|
||||
|
||||
expect(mocks.asset.setComplete).toHaveBeenCalledWith(assetId, undefined);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.AssetExtractMetadata,
|
||||
data: { id: assetId, source: 'upload' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should update file modification time', async () => {
|
||||
await sut.onComplete({ id: assetId, path, fileModifiedAt });
|
||||
|
||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(path, expect.any(Date), fileModifiedAt);
|
||||
});
|
||||
|
||||
it('should handle utimes failure gracefully', async () => {
|
||||
mocks.storage.utimes.mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
await expect(sut.onComplete({ id: assetId, path, fileModifiedAt })).resolves.toBeUndefined();
|
||||
|
||||
// Should still complete asset and queue job
|
||||
expect(mocks.asset.setComplete).toHaveBeenCalled();
|
||||
expect(mocks.job.queue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should retry setComplete on transient failures', async () => {
|
||||
mocks.asset.setComplete
|
||||
.mockRejectedValueOnce(new Error('Transient error'))
|
||||
.mockRejectedValueOnce(new Error('Transient error'))
|
||||
.mockResolvedValue(void 0);
|
||||
|
||||
await sut.onComplete({ id: assetId, path, fileModifiedAt });
|
||||
|
||||
expect(mocks.asset.setComplete).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should retry job queueing on transient failures', async () => {
|
||||
mocks.job.queue.mockRejectedValueOnce(new Error('Transient error')).mockResolvedValue();
|
||||
|
||||
await sut.onComplete({ id: assetId, path, fileModifiedAt });
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCancel', () => {
|
||||
const assetId = factory.uuid();
|
||||
const path = `/upload/${assetId}/file.jpg`;
|
||||
|
||||
it('should delete file and remove asset record', async () => {
|
||||
await sut.onCancel(assetId, path);
|
||||
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(path);
|
||||
expect(mocks.asset.removeAndDecrementQuota).toHaveBeenCalledWith(assetId);
|
||||
});
|
||||
|
||||
it('should retry unlink on transient failures', async () => {
|
||||
mocks.storage.unlink.mockRejectedValueOnce(new Error('Transient error')).mockResolvedValue();
|
||||
|
||||
await sut.onCancel(assetId, path);
|
||||
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry removeAndDecrementQuota on transient failures', async () => {
|
||||
mocks.asset.removeAndDecrementQuota.mockRejectedValueOnce(new Error('Transient error')).mockResolvedValue();
|
||||
|
||||
await sut.onCancel(assetId, path);
|
||||
|
||||
expect(mocks.asset.removeAndDecrementQuota).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeStaleUploads', () => {
|
||||
it('should queue cleanup jobs for stale partial assets', async () => {
|
||||
const staleAssets = [{ id: factory.uuid() }, { id: factory.uuid() }, { id: factory.uuid() }];
|
||||
|
||||
mocks.assetJob.streamForPartialAssetCleanupJob.mockReturnValue(
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
(async function* () {
|
||||
for (const asset of staleAssets) {
|
||||
yield asset;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
|
||||
await sut.removeStaleUploads();
|
||||
|
||||
expect(mocks.assetJob.streamForPartialAssetCleanupJob).toHaveBeenCalledWith(expect.any(Date));
|
||||
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{ name: JobName.PartialAssetCleanup, data: staleAssets[0] },
|
||||
{ name: JobName.PartialAssetCleanup, data: staleAssets[1] },
|
||||
{ name: JobName.PartialAssetCleanup, data: staleAssets[2] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should batch cleanup jobs', async () => {
|
||||
const assets = Array.from({ length: 1500 }, () => ({ id: factory.uuid() }));
|
||||
|
||||
mocks.assetJob.streamForPartialAssetCleanupJob.mockReturnValue(
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
(async function* () {
|
||||
for (const asset of assets) {
|
||||
yield asset;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
|
||||
await sut.removeStaleUploads();
|
||||
|
||||
// Should be called twice: once for 1000, once for 500
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle empty stream', async () => {
|
||||
mocks.assetJob.streamForPartialAssetCleanupJob.mockReturnValue((async function* () {})());
|
||||
|
||||
await sut.removeStaleUploads();
|
||||
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeStaleUpload', () => {
|
||||
const assetId = factory.uuid();
|
||||
const path = `/upload/${assetId}/file.jpg`;
|
||||
|
||||
it('should skip if asset not found', async () => {
|
||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
||||
mocks.assetJob.getForPartialAssetCleanupJob.mockResolvedValue(undefined);
|
||||
|
||||
const result = await sut.removeStaleUpload({ id: assetId });
|
||||
|
||||
expect(result).toBe(JobStatus.Skipped);
|
||||
expect(mocks.storage.stat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should complete asset if file matches expected state', async () => {
|
||||
const checksum = Buffer.from('checksum');
|
||||
const fileModifiedAt = new Date();
|
||||
|
||||
mocks.assetJob.getForPartialAssetCleanupJob.mockResolvedValue({
|
||||
path,
|
||||
checksum,
|
||||
fileModifiedAt,
|
||||
size: 1024,
|
||||
});
|
||||
|
||||
mocks.storage.stat.mockResolvedValue({ size: 1024 } as any);
|
||||
mocks.crypto.hashFile.mockResolvedValue(checksum);
|
||||
|
||||
const result = await sut.removeStaleUpload({ id: assetId });
|
||||
|
||||
expect(result).toBe(JobStatus.Success);
|
||||
expect(mocks.asset.setComplete).toHaveBeenCalledWith(assetId, undefined);
|
||||
expect(mocks.storage.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should cancel asset if file size does not match', async () => {
|
||||
mocks.assetJob.getForPartialAssetCleanupJob.mockResolvedValue({
|
||||
path,
|
||||
checksum: Buffer.from('checksum'),
|
||||
fileModifiedAt: new Date(),
|
||||
size: 1024,
|
||||
});
|
||||
|
||||
mocks.storage.stat.mockResolvedValue({ size: 512 } as any);
|
||||
|
||||
const result = await sut.removeStaleUpload({ id: assetId });
|
||||
|
||||
expect(result).toBe(JobStatus.Success);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(path);
|
||||
expect(mocks.asset.removeAndDecrementQuota).toHaveBeenCalledWith(assetId);
|
||||
});
|
||||
|
||||
it('should cancel asset if checksum does not match', async () => {
|
||||
mocks.assetJob.getForPartialAssetCleanupJob.mockResolvedValue({
|
||||
path,
|
||||
checksum: Buffer.from('expected-checksum'),
|
||||
fileModifiedAt: new Date(),
|
||||
size: 1024,
|
||||
});
|
||||
|
||||
mocks.storage.stat.mockResolvedValue({ size: 1024 } as any);
|
||||
mocks.crypto.hashFile.mockResolvedValue(Buffer.from('actual-checksum'));
|
||||
|
||||
const result = await sut.removeStaleUpload({ id: assetId });
|
||||
|
||||
expect(result).toBe(JobStatus.Success);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(path);
|
||||
expect(mocks.asset.removeAndDecrementQuota).toHaveBeenCalledWith(assetId);
|
||||
});
|
||||
|
||||
it('should cancel asset if file does not exist', async () => {
|
||||
mocks.assetJob.getForPartialAssetCleanupJob.mockResolvedValue({
|
||||
path,
|
||||
checksum: Buffer.from('checksum'),
|
||||
fileModifiedAt: new Date(),
|
||||
size: 1024,
|
||||
});
|
||||
|
||||
const error = new Error('File not found') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
mocks.storage.stat.mockRejectedValue(error);
|
||||
|
||||
const result = await sut.removeStaleUpload({ id: assetId });
|
||||
|
||||
expect(result).toBe(JobStatus.Success);
|
||||
expect(mocks.asset.removeAndDecrementQuota).toHaveBeenCalledWith(assetId);
|
||||
});
|
||||
|
||||
it('should cancel asset if stat fails with permission error', async () => {
|
||||
mocks.assetJob.getForPartialAssetCleanupJob.mockResolvedValue({
|
||||
path,
|
||||
checksum: Buffer.from('checksum'),
|
||||
fileModifiedAt: new Date(),
|
||||
size: 1024,
|
||||
});
|
||||
|
||||
const error = new Error('Permission denied') as NodeJS.ErrnoException;
|
||||
error.code = 'EACCES';
|
||||
mocks.storage.stat.mockRejectedValue(error);
|
||||
|
||||
const result = await sut.removeStaleUpload({ id: assetId });
|
||||
|
||||
expect(result).toBe(JobStatus.Success);
|
||||
expect(mocks.asset.removeAndDecrementQuota).toHaveBeenCalledWith(assetId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,465 +0,0 @@
|
||||
import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { DateTime } from 'luxon';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { dirname, extname, join } from 'node:path';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { AuthSharedLink } from 'src/database';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { GetUploadStatusDto, ResumeUploadDto, StartUploadDto } from 'src/dtos/asset-upload.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
AssetMetadataKey,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
ChecksumAlgorithm,
|
||||
ImmichWorker,
|
||||
JobName,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
StorageFolder,
|
||||
} from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobItem, JobOf } from 'src/types';
|
||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { withRetry } from 'src/utils/misc';
|
||||
|
||||
export const MAX_RUFH_INTEROP_VERSION = 8;
|
||||
type CompletionData = { id: string; path: string; fileModifiedAt: Date; sharedLink?: AuthSharedLink };
|
||||
|
||||
@Injectable()
|
||||
export class AssetUploadService extends BaseService {
|
||||
// This is used to proactively abort previous requests for the same asset
|
||||
// when a new one arrives. The previous request still holds the asset lock
|
||||
// and will prevent the new request from proceeding until the previous one
|
||||
// times out. As normal client behavior will not have concurrent requests,
|
||||
// we can assume the previous request has already failed on the client end.
|
||||
private activeRequests = new Map<string, { req: Readable; startTime: Date }>();
|
||||
|
||||
@OnEvent({ name: 'UploadAbort', workers: [ImmichWorker.Api], server: true })
|
||||
onUploadAbort({ assetId, abortTime }: ArgOf<'UploadAbort'>) {
|
||||
const entry = this.activeRequests.get(assetId);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
if (abortTime > entry.startTime) {
|
||||
entry.req.destroy();
|
||||
this.activeRequests.delete(assetId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async startUpload(auth: AuthDto, req: Readable, res: Response, dto: StartUploadDto): Promise<void> {
|
||||
this.logger.verboseFn(() => `Starting upload: ${JSON.stringify(dto)}`);
|
||||
const { uploadComplete, assetData, uploadLength, contentLength, version } = dto;
|
||||
const isComplete = uploadComplete !== false;
|
||||
const isResumable = version && uploadComplete !== undefined;
|
||||
const { backup } = await this.getConfig({ withCache: true });
|
||||
|
||||
const { id, path, status, isDuplicate } = await this.onStart(auth, dto);
|
||||
const location = `/api/upload/${id}`;
|
||||
if (isDuplicate) {
|
||||
if (status !== AssetStatus.Partial) {
|
||||
return this.sendAlreadyCompleted(res);
|
||||
}
|
||||
|
||||
if (isResumable) {
|
||||
this.sendInterimResponse(res, location, version, this.getUploadLimits(backup));
|
||||
// this is a 5xx to indicate the client should do offset retrieval and resume
|
||||
res.status(500).send('Incomplete asset already exists');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isComplete && uploadLength !== contentLength) {
|
||||
return this.sendInconsistentLength(res);
|
||||
}
|
||||
|
||||
if (isResumable) {
|
||||
this.sendInterimResponse(res, location, version, this.getUploadLimits(backup));
|
||||
}
|
||||
|
||||
this.addRequest(id, req);
|
||||
await this.databaseRepository.withUuidLock(id, async () => {
|
||||
// conventional upload, check status again with lock acquired before overwriting
|
||||
if (isDuplicate) {
|
||||
const existingAsset = await this.assetRepository.getCompletionMetadata(id, auth.user.id);
|
||||
if (existingAsset?.status !== AssetStatus.Partial) {
|
||||
return this.sendAlreadyCompleted(res);
|
||||
}
|
||||
}
|
||||
await this.storageRepository.mkdir(dirname(path));
|
||||
|
||||
let checksumBuffer: Buffer | undefined;
|
||||
const writeStream = isDuplicate
|
||||
? this.storageRepository.createWriteStream(path, { flush: isComplete })
|
||||
: this.storageRepository.createOrAppendWriteStream(path, { flush: isComplete });
|
||||
this.pipe(req, writeStream, contentLength);
|
||||
if (isComplete) {
|
||||
const hash = createHash('sha1');
|
||||
req.on('data', (data: Buffer) => hash.update(data));
|
||||
writeStream.on('finish', () => (checksumBuffer = hash.digest()));
|
||||
}
|
||||
await new Promise((resolve, reject) => writeStream.on('close', resolve).on('error', reject));
|
||||
if (isResumable) {
|
||||
this.setCompleteHeader(res, version, uploadComplete);
|
||||
}
|
||||
if (!isComplete) {
|
||||
res.status(201).set('Location', location).setHeader('Upload-Limit', this.getUploadLimits(backup)).send();
|
||||
return;
|
||||
}
|
||||
if (dto.checksum.compare(checksumBuffer!) !== 0) {
|
||||
return await this.sendChecksumMismatch(res, id, path);
|
||||
}
|
||||
|
||||
await this.onComplete({ id, path, fileModifiedAt: assetData.fileModifiedAt, sharedLink: auth.sharedLink });
|
||||
res.status(200).send({ id });
|
||||
});
|
||||
}
|
||||
|
||||
resumeUpload(auth: AuthDto, req: Readable, res: Response, id: string, dto: ResumeUploadDto): Promise<void> {
|
||||
this.logger.verboseFn(() => `Resuming upload for ${id}: ${JSON.stringify(dto)}`);
|
||||
const { uploadComplete, uploadLength, uploadOffset, contentLength, version } = dto;
|
||||
this.setCompleteHeader(res, version, false);
|
||||
this.addRequest(id, req);
|
||||
return this.databaseRepository.withUuidLock(id, async () => {
|
||||
const completionData = await this.assetRepository.getCompletionMetadata(id, auth.user.id);
|
||||
if (!completionData) {
|
||||
res.status(404).send('Asset not found');
|
||||
return;
|
||||
}
|
||||
const { fileModifiedAt, path, status, checksum: providedChecksum, size } = completionData;
|
||||
|
||||
if (status !== AssetStatus.Partial) {
|
||||
return this.sendAlreadyCompleted(res);
|
||||
}
|
||||
|
||||
if (uploadLength && size && size !== uploadLength) {
|
||||
return this.sendInconsistentLength(res);
|
||||
}
|
||||
|
||||
const expectedOffset = await this.getCurrentOffset(path);
|
||||
if (expectedOffset !== uploadOffset) {
|
||||
return this.sendOffsetMismatch(res, expectedOffset, uploadOffset);
|
||||
}
|
||||
|
||||
const newLength = uploadOffset + contentLength;
|
||||
if (uploadLength !== undefined && newLength > uploadLength) {
|
||||
res.status(400).send('Upload would exceed declared length');
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentLength === 0 && !uploadComplete) {
|
||||
res.status(204).setHeader('Upload-Offset', expectedOffset.toString()).send();
|
||||
return;
|
||||
}
|
||||
|
||||
const writeStream = this.storageRepository.createOrAppendWriteStream(path, { flush: uploadComplete });
|
||||
this.pipe(req, writeStream, contentLength);
|
||||
await new Promise((resolve, reject) => writeStream.on('close', resolve).on('error', reject));
|
||||
this.setCompleteHeader(res, version, uploadComplete);
|
||||
if (!uploadComplete) {
|
||||
try {
|
||||
const offset = await this.getCurrentOffset(path);
|
||||
res.status(204).setHeader('Upload-Offset', offset.toString()).send();
|
||||
} catch {
|
||||
this.logger.error(`Failed to get current offset for ${path} after write`);
|
||||
res.status(500).send();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const checksum = await this.cryptoRepository.hashFile(path);
|
||||
if (providedChecksum.compare(checksum) !== 0) {
|
||||
return await this.sendChecksumMismatch(res, id, path);
|
||||
}
|
||||
|
||||
await this.onComplete({ id, path, fileModifiedAt, sharedLink: auth.sharedLink });
|
||||
res.status(200).send({ id });
|
||||
});
|
||||
}
|
||||
|
||||
cancelUpload(auth: AuthDto, assetId: string, res: Response): Promise<void> {
|
||||
this.abortExistingRequest(assetId);
|
||||
return this.databaseRepository.withUuidLock(assetId, async () => {
|
||||
const asset = await this.assetRepository.getCompletionMetadata(assetId, auth.user.id);
|
||||
if (!asset) {
|
||||
res.status(404).send('Asset not found');
|
||||
return;
|
||||
}
|
||||
if (asset.status !== AssetStatus.Partial) {
|
||||
return this.sendAlreadyCompleted(res);
|
||||
}
|
||||
await this.onCancel(assetId, asset.path);
|
||||
res.status(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
async getUploadStatus(auth: AuthDto, res: Response, id: string, { version }: GetUploadStatusDto): Promise<void> {
|
||||
this.logger.verboseFn(() => `Getting upload status for ${id} with version ${version}`);
|
||||
const { backup } = await this.getConfig({ withCache: true });
|
||||
this.abortExistingRequest(id);
|
||||
return this.databaseRepository.withUuidLock(id, async () => {
|
||||
const asset = await this.assetRepository.getCompletionMetadata(id, auth.user.id);
|
||||
if (!asset) {
|
||||
res.status(404).send('Asset not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = await this.getCurrentOffset(asset.path);
|
||||
this.setCompleteHeader(res, version, asset.status !== AssetStatus.Partial);
|
||||
res.status(204).setHeader('Upload-Offset', offset.toString()).setHeader('Cache-Control', 'no-store');
|
||||
const { size } = asset;
|
||||
if (size !== null && size > 0) {
|
||||
res.setHeader('Upload-Length', size.toString());
|
||||
}
|
||||
res.setHeader('Upload-Limit', this.getUploadLimits(backup)).send();
|
||||
});
|
||||
}
|
||||
|
||||
async getUploadOptions(res: Response): Promise<void> {
|
||||
const { backup } = await this.getConfig({ withCache: true });
|
||||
res
|
||||
.status(204)
|
||||
.setHeader('Accept-Patch', 'application/partial-upload')
|
||||
.setHeader('Upload-Limit', this.getUploadLimits(backup))
|
||||
.send();
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.PartialAssetCleanupQueueAll, queue: QueueName.BackgroundTask })
|
||||
async removeStaleUploads(): Promise<void> {
|
||||
const config = await this.getConfig({ withCache: false });
|
||||
const createdBefore = DateTime.now().minus({ hours: config.backup.upload.maxAgeHours }).toJSDate();
|
||||
let jobs: JobItem[] = [];
|
||||
const assets = this.assetJobRepository.streamForPartialAssetCleanupJob(createdBefore);
|
||||
for await (const asset of assets) {
|
||||
jobs.push({ name: JobName.PartialAssetCleanup, data: asset });
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
jobs = [];
|
||||
}
|
||||
}
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.PartialAssetCleanup, queue: QueueName.BackgroundTask })
|
||||
removeStaleUpload({ id }: JobOf<JobName.PartialAssetCleanup>): Promise<JobStatus> {
|
||||
return this.databaseRepository.withUuidLock(id, async () => {
|
||||
const asset = await this.assetJobRepository.getForPartialAssetCleanupJob(id);
|
||||
if (!asset) {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
const { checksum, fileModifiedAt, path, size } = asset;
|
||||
try {
|
||||
const stat = await this.storageRepository.stat(path);
|
||||
if (size === stat.size && checksum === (await this.cryptoRepository.hashFile(path))) {
|
||||
await this.onComplete({ id, path, fileModifiedAt });
|
||||
return JobStatus.Success;
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.debugFn(() => `Failed to check upload file ${path}: ${error.message}`);
|
||||
}
|
||||
await this.onCancel(id, path);
|
||||
return JobStatus.Success;
|
||||
});
|
||||
}
|
||||
|
||||
async onStart(
|
||||
auth: AuthDto,
|
||||
{ assetData, checksum, uploadLength }: StartUploadDto,
|
||||
): Promise<{ id: string; path: string; status: AssetStatus; isDuplicate: boolean }> {
|
||||
const assetId = this.cryptoRepository.randomUUID();
|
||||
const folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, assetId);
|
||||
const extension = extname(assetData.filename);
|
||||
const path = join(folder, `${assetId}${extension}`);
|
||||
const type = mimeTypes.assetType(path);
|
||||
|
||||
if (type === AssetType.Other) {
|
||||
throw new BadRequestException(`${assetData.filename} is an unsupported file type`);
|
||||
}
|
||||
|
||||
this.validateQuota(auth, uploadLength);
|
||||
|
||||
try {
|
||||
await this.assetRepository.createWithMetadata(
|
||||
{
|
||||
id: assetId,
|
||||
ownerId: auth.user.id,
|
||||
libraryId: null,
|
||||
checksum,
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
originalPath: path,
|
||||
fileCreatedAt: assetData.fileCreatedAt,
|
||||
fileModifiedAt: assetData.fileModifiedAt,
|
||||
localDateTime: assetData.fileCreatedAt,
|
||||
type,
|
||||
isFavorite: assetData.isFavorite,
|
||||
livePhotoVideoId: assetData.livePhotoVideoId,
|
||||
visibility: AssetVisibility.Hidden,
|
||||
originalFileName: assetData.filename,
|
||||
status: AssetStatus.Partial,
|
||||
},
|
||||
uploadLength,
|
||||
assetData.iCloudId ? [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: assetData.iCloudId } }] : undefined,
|
||||
);
|
||||
} catch (error: any) {
|
||||
if (!isAssetChecksumConstraint(error)) {
|
||||
this.logger.error(`Error creating upload asset record: ${error.message}`);
|
||||
throw new InternalServerErrorException('Error creating asset');
|
||||
}
|
||||
|
||||
const duplicate = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, checksum);
|
||||
if (!duplicate) {
|
||||
throw new InternalServerErrorException('Error locating duplicate for checksum constraint');
|
||||
}
|
||||
|
||||
return { id: duplicate.id, path, status: duplicate.status, isDuplicate: true };
|
||||
}
|
||||
|
||||
return { id: assetId, path, status: AssetStatus.Partial, isDuplicate: false };
|
||||
}
|
||||
|
||||
async onComplete({ id, path, fileModifiedAt, sharedLink }: CompletionData) {
|
||||
this.logger.log('Completing upload for asset', id);
|
||||
const asset = await withRetry(() => this.assetRepository.setComplete(id, sharedLink));
|
||||
if (!asset) {
|
||||
this.logger.error(`Failed to mark asset ${id} as complete: not found or already completed`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await withRetry(() => this.storageRepository.utimes(path, new Date(), fileModifiedAt));
|
||||
await this.eventRepository.emit('AssetCreate', { asset });
|
||||
} catch (error: any) {
|
||||
this.logger.error(`onComplete error for ${path}: ${error.message}`);
|
||||
}
|
||||
const jobData = { name: JobName.AssetExtractMetadata, data: { id, source: 'upload' } } as const;
|
||||
await withRetry(() => this.jobRepository.queue(jobData));
|
||||
}
|
||||
|
||||
async onCancel(assetId: string, path: string): Promise<void> {
|
||||
this.logger.log('Cancelling upload for asset', assetId);
|
||||
await withRetry(() => this.storageRepository.unlink(path));
|
||||
await withRetry(() => this.assetRepository.removeAndDecrementQuota(assetId));
|
||||
}
|
||||
|
||||
private addRequest(assetId: string, req: Readable) {
|
||||
const addTime = new Date();
|
||||
const activeRequest = { req, startTime: addTime };
|
||||
this.abortExistingRequest(assetId, addTime);
|
||||
this.activeRequests.set(assetId, activeRequest);
|
||||
req.on('close', () => {
|
||||
if (this.activeRequests.get(assetId)?.req === req) {
|
||||
this.activeRequests.delete(assetId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private abortExistingRequest(assetId: string, abortTime = new Date()) {
|
||||
const abortEvent = { assetId, abortTime };
|
||||
// only emit if we didn't just abort it ourselves
|
||||
if (!this.onUploadAbort(abortEvent)) {
|
||||
this.websocketRepository.serverSend('UploadAbort', abortEvent);
|
||||
}
|
||||
}
|
||||
|
||||
private pipe(req: Readable, writeStream: Writable, size: number) {
|
||||
let receivedLength = 0;
|
||||
req.on('data', (data: Buffer) => {
|
||||
receivedLength += data.length;
|
||||
if (!writeStream.write(data)) {
|
||||
req.pause();
|
||||
writeStream.once('drain', () => req.resume());
|
||||
}
|
||||
});
|
||||
|
||||
req.on('close', () => {
|
||||
if (receivedLength < size) {
|
||||
writeStream.emit('error', new Error('Request closed before all data received'));
|
||||
}
|
||||
writeStream.end();
|
||||
});
|
||||
}
|
||||
|
||||
private sendInterimResponse({ socket }: Response, location: string, interopVersion: number, limits: string): void {
|
||||
if (socket && !socket.destroyed) {
|
||||
// Express doesn't understand interim responses, so write directly to socket
|
||||
socket.write(
|
||||
'HTTP/1.1 104 Upload Resumption Supported\r\n' +
|
||||
`Location: ${location}\r\n` +
|
||||
`Upload-Limit: ${limits}\r\n` +
|
||||
`Upload-Draft-Interop-Version: ${interopVersion}\r\n\r\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private sendInconsistentLength(res: Response): void {
|
||||
res.status(400).contentType('application/problem+json').send({
|
||||
type: 'https://iana.org/assignments/http-problem-types#inconsistent-upload-length',
|
||||
title: 'inconsistent length values for upload',
|
||||
});
|
||||
}
|
||||
|
||||
private sendAlreadyCompleted(res: Response): void {
|
||||
res.status(400).contentType('application/problem+json').send({
|
||||
type: 'https://iana.org/assignments/http-problem-types#completed-upload',
|
||||
title: 'upload is already completed',
|
||||
});
|
||||
}
|
||||
|
||||
private sendOffsetMismatch(res: Response, expected: number, actual: number): void {
|
||||
res.status(409).contentType('application/problem+json').setHeader('Upload-Offset', expected.toString()).send({
|
||||
type: 'https://iana.org/assignments/http-problem-types#mismatching-upload-offset',
|
||||
title: 'offset from request does not match offset of resource',
|
||||
'expected-offset': expected,
|
||||
'provided-offset': actual,
|
||||
});
|
||||
}
|
||||
|
||||
private sendChecksumMismatch(res: Response, assetId: string, path: string) {
|
||||
this.logger.warn(`Removing upload asset ${assetId} due to checksum mismatch`);
|
||||
res.status(460).send('File on server does not match provided checksum');
|
||||
return this.onCancel(assetId, path);
|
||||
}
|
||||
|
||||
private validateQuota(auth: AuthDto, size: number): void {
|
||||
const { quotaSizeInBytes: quotaLimit, quotaUsageInBytes: currentUsage } = auth.user;
|
||||
if (quotaLimit === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (quotaLimit < currentUsage + size) {
|
||||
throw new BadRequestException('Quota has been exceeded!');
|
||||
}
|
||||
}
|
||||
|
||||
private async getCurrentOffset(path: string): Promise<number> {
|
||||
try {
|
||||
const stat = await this.storageRepository.stat(path);
|
||||
return stat.size;
|
||||
} catch (error: any) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
return 0;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private setCompleteHeader(res: Response, interopVersion: number | undefined, isComplete: boolean): void {
|
||||
if (interopVersion === undefined || interopVersion > 3) {
|
||||
res.setHeader('Upload-Complete', isComplete ? '?1' : '?0');
|
||||
} else {
|
||||
res.setHeader('Upload-Incomplete', isComplete ? '?0' : '?1');
|
||||
}
|
||||
}
|
||||
|
||||
private getUploadLimits({ upload }: SystemConfig['backup']) {
|
||||
return `min-size=1, max-age=${upload.maxAgeHours * 3600}`;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { AlbumService } from 'src/services/album.service';
|
||||
import { ApiKeyService } from 'src/services/api-key.service';
|
||||
import { ApiService } from 'src/services/api.service';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { AssetUploadService } from 'src/services/asset-upload.service';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { AuthAdminService } from 'src/services/auth-admin.service';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
@@ -57,7 +56,6 @@ export const services = [
|
||||
AlbumService,
|
||||
ApiService,
|
||||
AssetMediaService,
|
||||
AssetUploadService,
|
||||
AssetService,
|
||||
AuthService,
|
||||
AuthAdminService,
|
||||
|
||||
@@ -52,9 +52,6 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
cronExpression: '0 02 * * *',
|
||||
keepLastAmount: 14,
|
||||
},
|
||||
upload: {
|
||||
maxAgeHours: 72,
|
||||
},
|
||||
},
|
||||
ffmpeg: {
|
||||
crf: 30,
|
||||
@@ -152,7 +149,6 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
missingThumbnails: true,
|
||||
generateMemories: true,
|
||||
syncQuotaUsage: true,
|
||||
removeStaleUploads: true,
|
||||
},
|
||||
reverseGeocoding: {
|
||||
enabled: true,
|
||||
|
||||
@@ -239,9 +239,7 @@ export class UserService extends BaseService {
|
||||
|
||||
@OnEvent({ name: 'AssetCreate' })
|
||||
async onAssetCreate({ asset, file }: ArgOf<'AssetCreate'>) {
|
||||
if (file) {
|
||||
await this.userRepository.updateUsage(asset.ownerId, file.size);
|
||||
}
|
||||
await this.userRepository.updateUsage(asset.ownerId, file.size);
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.UserSyncUsage, queue: QueueName.BackgroundTask })
|
||||
|
||||
@@ -36,7 +36,7 @@ const dummy = () => {
|
||||
};
|
||||
|
||||
type ExecuteOptions<T extends WorkflowType> = {
|
||||
read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T> }>;
|
||||
read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T>; entityId?: string }>;
|
||||
write: (auth: AuthDto, changes: WorkflowChanges<T>) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -331,6 +331,7 @@ export class WorkflowExecutionService extends BaseService {
|
||||
return {
|
||||
data: { asset } as any,
|
||||
authUserId: asset.ownerId,
|
||||
entityId: asset.id,
|
||||
};
|
||||
},
|
||||
write: async (auth, changes) => {
|
||||
@@ -398,11 +399,12 @@ export class WorkflowExecutionService extends BaseService {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { read, write } = handler;
|
||||
const readResult = await read(type);
|
||||
let data = readResult.data;
|
||||
for (const step of workflow.steps) {
|
||||
const { read, write } = handler;
|
||||
const readResult = await read(type);
|
||||
let data = readResult.data;
|
||||
|
||||
for (const step of workflow.steps) {
|
||||
try {
|
||||
const payload: WorkflowEventPayload<typeof type> = {
|
||||
trigger: workflow.trigger,
|
||||
type,
|
||||
@@ -453,14 +455,45 @@ export class WorkflowExecutionService extends BaseService {
|
||||
|
||||
const shouldContinue = result?.workflow?.continue ?? true;
|
||||
if (!shouldContinue) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (workflow.logging) {
|
||||
await this.workflowRepository.log({
|
||||
workflowId,
|
||||
error: false,
|
||||
halted: true,
|
||||
workflowStepId: step.id,
|
||||
triggerDataId: readResult.entityId,
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.debug(`Workflow ${workflowId} executed successfully`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error executing workflow ${workflowId}:`, error);
|
||||
return JobStatus.Failed;
|
||||
this.logger.debug(`Workflow ${workflowId} stopped on step ${step.id}`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error executing workflow ${workflowId}:`, error);
|
||||
|
||||
if (workflow.logging) {
|
||||
await this.workflowRepository.log({
|
||||
workflowId,
|
||||
error: true,
|
||||
halted: false,
|
||||
workflowStepId: step.id,
|
||||
triggerDataId: readResult.entityId,
|
||||
});
|
||||
}
|
||||
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
if (workflow.logging) {
|
||||
await this.workflowRepository.log({
|
||||
workflowId,
|
||||
error: false,
|
||||
halted: false,
|
||||
triggerDataId: readResult.entityId,
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.debug(`Workflow ${workflowId} executed successfully`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,15 @@ import {
|
||||
mapWorkflow,
|
||||
mapWorkflowShare,
|
||||
WorkflowCreateDto,
|
||||
WorkflowGetLogsDto,
|
||||
WorkflowLogEntryDto,
|
||||
WorkflowResponseDto,
|
||||
WorkflowSearchDto,
|
||||
WorkflowShareResponseDto,
|
||||
WorkflowTriggerResponseDto,
|
||||
WorkflowUpdateDto,
|
||||
} from 'src/dtos/workflow.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { Permission, WorkflowResult } from 'src/enum';
|
||||
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { getWorkflowTriggers, isMethodCompatible, resolveMethod } from 'src/utils/workflow';
|
||||
@@ -82,6 +84,23 @@ export class WorkflowService extends BaseService {
|
||||
await this.workflowRepository.delete(id);
|
||||
}
|
||||
|
||||
async getLogs(auth: AuthDto, id: string, dto: WorkflowGetLogsDto): Promise<WorkflowLogEntryDto[]> {
|
||||
await this.requireAccess({ auth, permission: Permission.WorkflowLogs, ids: [id] });
|
||||
const logs = await this.workflowRepository.getLogs(id, dto);
|
||||
return logs.map((entry) => ({
|
||||
id: entry.id,
|
||||
at: entry.createdAt.toISOString(),
|
||||
result: entry.error ? WorkflowResult.Error : entry.halted ? WorkflowResult.Halted : WorkflowResult.Completed,
|
||||
triggerDataId: entry.triggerDataId ?? undefined,
|
||||
lastStep: entry.step
|
||||
? {
|
||||
index: entry.step.order,
|
||||
method: `${entry.step.pluginId}#${entry.step.methodName}`,
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
private async resolveAndValidateSteps<T extends { method: string }>(steps: T[], trigger: WorkflowTrigger) {
|
||||
const methods = await this.pluginRepository.getForValidation();
|
||||
const results: Array<T & { pluginMethod: PluginMethodSearchResponse }> = [];
|
||||
|
||||
@@ -336,7 +336,8 @@ export interface IIntegrityUntrackedFilesJob {
|
||||
|
||||
export interface IIntegrityMissingFilesJob {
|
||||
items: ({ path: string; reportId: string | null } & (
|
||||
{ assetId: string; fileAssetId: null } | { assetId: null; fileAssetId: string }
|
||||
| { assetId: string; fileAssetId: null }
|
||||
| { assetId: null; fileAssetId: string }
|
||||
))[];
|
||||
}
|
||||
|
||||
@@ -432,8 +433,6 @@ export type JobItem =
|
||||
| { name: JobName.PersonCleanup; data?: IBaseJob }
|
||||
| { name: JobName.AssetDelete; data: IAssetDeleteJob }
|
||||
| { name: JobName.AssetDeleteCheck; data?: IBaseJob }
|
||||
| { name: JobName.PartialAssetCleanup; data: IEntityJob }
|
||||
| { name: JobName.PartialAssetCleanupQueueAll; data?: IBaseJob }
|
||||
|
||||
// Library Management
|
||||
| { name: JobName.LibrarySyncFiles; data: ILibraryFileJob }
|
||||
@@ -554,7 +553,8 @@ export interface MemoryData {
|
||||
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
|
||||
export type SystemFlags = { mountChecks: Record<StorageFolder, boolean> };
|
||||
export type MaintenanceModeState =
|
||||
{ isMaintenanceMode: true; secret: string; action?: SetMaintenanceModeDto } | { isMaintenanceMode: false };
|
||||
| { isMaintenanceMode: true; secret: string; action?: SetMaintenanceModeDto }
|
||||
| { isMaintenanceMode: false };
|
||||
export type MemoriesState = {
|
||||
/** memories have already been created through this date */
|
||||
lastOnThisDayDate: string;
|
||||
|
||||
@@ -324,7 +324,8 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
||||
|
||||
case Permission.WorkflowRead:
|
||||
case Permission.WorkflowUpdate:
|
||||
case Permission.WorkflowDelete: {
|
||||
case Permission.WorkflowDelete:
|
||||
case Permission.WorkflowLogs: {
|
||||
return access.workflow.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import _ from 'lodash';
|
||||
import { cleanupOpenApiDoc } from 'nestjs-zod';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import picomatch from 'picomatch';
|
||||
import parse from 'picomatch/lib/parse';
|
||||
import { SystemConfig } from 'src/config';
|
||||
@@ -350,18 +349,3 @@ export const globToSqlPattern = (glob: string) => {
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
export async function withRetry<T>(operation: () => Promise<T>, retries: number = 2, delay: number = 100): Promise<T> {
|
||||
let lastError: any;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
}
|
||||
if (attempt < retries) {
|
||||
await setTimeout(delay);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { ZodValidationException } from 'nestjs-zod';
|
||||
import { IncomingHttpHeaders } from 'node:http';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import z from 'zod';
|
||||
|
||||
export const fromChecksum = (checksum: string): Buffer => {
|
||||
return Buffer.from(checksum, checksum.length === 28 ? 'base64' : 'hex');
|
||||
@@ -25,11 +23,3 @@ export const getUserAgentDetails = (headers: IncomingHttpHeaders) => {
|
||||
appVersion,
|
||||
};
|
||||
};
|
||||
|
||||
export function validateSyncOrReject<T>(cls: { schema: z.ZodType<T> }, obj: unknown): T {
|
||||
const result = cls.schema.safeParse(obj);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
throw new ZodValidationException(result.error);
|
||||
}
|
||||
|
||||
@@ -46,10 +46,6 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
||||
upsertMetadata: vitest.fn(),
|
||||
upsertBulkMetadata: vitest.fn(),
|
||||
deleteMetadataByKey: vitest.fn(),
|
||||
getCompletionMetadata: vitest.fn(),
|
||||
createWithMetadata: vitest.fn(),
|
||||
removeAndDecrementQuota: vitest.fn(),
|
||||
setComplete: vitest.fn(),
|
||||
deleteBulkMetadata: vitest.fn(),
|
||||
getForOriginal: vitest.fn(),
|
||||
getForOriginals: vitest.fn(),
|
||||
|
||||
@@ -57,7 +57,6 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
|
||||
readdirWithTypes: vitest.fn(),
|
||||
createFile: vitest.fn(),
|
||||
createWriteStream: vitest.fn(),
|
||||
createOrAppendWriteStream: vitest.fn(),
|
||||
createOrOverwriteFile: vitest.fn(),
|
||||
existsSync: vitest.fn(),
|
||||
overwriteFile: vitest.fn(),
|
||||
@@ -65,7 +64,6 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
|
||||
unlinkDir: vitest.fn().mockResolvedValue(true),
|
||||
removeEmptyDirs: vitest.fn(),
|
||||
checkFileExists: vitest.fn(),
|
||||
mkdir: vitest.fn(),
|
||||
mkdirSync: vitest.fn(),
|
||||
checkDiskUsage: vitest.fn(),
|
||||
readdir: vitest.fn(),
|
||||
|
||||
@@ -293,7 +293,6 @@ export const getMocks = () => {
|
||||
const databaseMock = automock(DatabaseRepository, { args: [, loggerMock], strict: false });
|
||||
|
||||
databaseMock.withLock.mockImplementation((_type, fn) => fn());
|
||||
databaseMock.withUuidLock.mockImplementation((_type, fn) => fn());
|
||||
databaseMock.getPostgresVersion = vitest.fn().mockResolvedValue('14.10 (Debian 14.10-1.pgdg120+1)');
|
||||
databaseMock.getPostgresVersionRange = vitest.fn().mockReturnValue('>=14.0.0');
|
||||
databaseMock.createExtension = vitest.fn().mockResolvedValue(void 0);
|
||||
|
||||
@@ -8,7 +8,17 @@ import { normalizeTransformEdits } from '$lib/utils/editor';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
||||
export type CropAspectRatio =
|
||||
'1:1' | '16:9' | '4:3' | '3:2' | '7:5' | '9:16' | '3:4' | '2:3' | '5:7' | 'free' | 'reset';
|
||||
| '1:1'
|
||||
| '16:9'
|
||||
| '4:3'
|
||||
| '3:2'
|
||||
| '7:5'
|
||||
| '9:16'
|
||||
| '3:4'
|
||||
| '2:3'
|
||||
| '5:7'
|
||||
| 'free'
|
||||
| 'reset';
|
||||
|
||||
type Region = {
|
||||
x: number;
|
||||
|
||||
169
web/src/lib/modals/WorkflowLogsModal.svelte
Normal file
169
web/src/lib/modals/WorkflowLogsModal.svelte
Normal file
@@ -0,0 +1,169 @@
|
||||
<script lang="ts">
|
||||
import { type WorkflowLogEntryDto, type WorkflowResponseDto, WorkflowResult, getWorkflowLogs } from '@immich/sdk';
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableHeading,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
HStack,
|
||||
Icon,
|
||||
VStack,
|
||||
Button,
|
||||
Select,
|
||||
type SelectOption,
|
||||
} from '@immich/ui';
|
||||
import { mdiHistory, mdiOpenInNew } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Route } from '$lib/route';
|
||||
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
|
||||
|
||||
type Props = {
|
||||
workflow: WorkflowResponseDto;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { workflow, onClose }: Props = $props();
|
||||
|
||||
let entries: WorkflowLogEntryDto[] = $state([]);
|
||||
let placeholder: HTMLElement | undefined = $state();
|
||||
let filter: WorkflowResult | undefined = $state();
|
||||
let before: string | undefined = $state();
|
||||
let hasNext = $state(true);
|
||||
let loading = $state(false);
|
||||
|
||||
const setLogging = (logging: boolean) =>
|
||||
handleUpdateWorkflow(workflow.id, { logging }).then((success) => {
|
||||
if (success) {
|
||||
workflow = { ...workflow, logging };
|
||||
reset();
|
||||
}
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
entries = [];
|
||||
hasNext = true;
|
||||
before = undefined;
|
||||
};
|
||||
|
||||
const setFilter = (option: SelectOption<WorkflowResult>) => {
|
||||
reset();
|
||||
filter = option.value;
|
||||
void getLogs();
|
||||
};
|
||||
|
||||
const getLogs = async () => {
|
||||
if (!hasNext || loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
const limit = 50;
|
||||
const results = await getWorkflowLogs({ id: workflow.id, result: filter, before, limit });
|
||||
entries.push(...results);
|
||||
if (results.length < limit) {
|
||||
hasNext = false;
|
||||
} else {
|
||||
before = results.at(-1)?.at;
|
||||
}
|
||||
loading = false;
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const entry = entries.find((entry) => entry.target === placeholder);
|
||||
if (entry?.isIntersecting) {
|
||||
void getLogs();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (placeholder) {
|
||||
observer.disconnect();
|
||||
observer.observe(placeholder);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal title={$t('logs')} icon={mdiHistory} {onClose} size="medium">
|
||||
<ModalBody>
|
||||
{#if workflow.logging}
|
||||
<Table striped>
|
||||
<TableHeader>
|
||||
<TableHeading>{$t('date')}</TableHeading>
|
||||
<TableHeading>{$t('result')}</TableHeading>
|
||||
</TableHeader>
|
||||
<TableBody class="max-h-100">
|
||||
{#each entries as entry (entry.id)}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<HStack class="justify-center">
|
||||
<p>
|
||||
{DateTime.fromISO(entry.at).toLocaleString(DateTime.DATETIME_MED, {
|
||||
locale: $locale,
|
||||
})}
|
||||
</p>
|
||||
{#if entry.triggerDataId}
|
||||
<a href={Route.viewAsset({ id: entry.triggerDataId })} target="_blank">
|
||||
<Icon icon={mdiOpenInNew} size="20" class="text-primary" />
|
||||
</a>
|
||||
{/if}
|
||||
</HStack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HStack class="justify-center">
|
||||
{#if entry.result === WorkflowResult.Completed}
|
||||
<p class="rounded-full bg-green-700 px-3 py-1 text-xs text-white">
|
||||
{$t('workflow_logging_completed')}
|
||||
</p>
|
||||
{:else if entry.result === WorkflowResult.Halted}
|
||||
<p class="rounded-full bg-gray-600 px-3 py-1 text-xs text-white">
|
||||
{#if entry.lastStep}
|
||||
{$t('workflow_logging_halted_step', { values: { step: entry.lastStep.index + 1 } })}
|
||||
{:else}
|
||||
{$t('workflow_logging_halted')}
|
||||
{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="rounded-full bg-red-500 px-3 py-1 text-xs text-white">
|
||||
{#if entry.lastStep}
|
||||
{$t('workflow_logging_error_step', { values: { step: entry.lastStep.index + 1 } })}
|
||||
{:else}
|
||||
{$t('error')}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</HStack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
{#if hasNext}
|
||||
<TableRow><TableCell><div bind:this={placeholder}>...</div></TableCell></TableRow>
|
||||
{/if}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div class="mt-5 flex gap-5">
|
||||
<Select
|
||||
onSelect={setFilter}
|
||||
class="flex-1"
|
||||
placeholder={$t('filter')}
|
||||
options={[
|
||||
{ value: WorkflowResult.Completed, label: $t('workflow_logging_completed') },
|
||||
{ value: WorkflowResult.Halted, label: $t('workflow_logging_halted') },
|
||||
{ value: WorkflowResult.Error, label: $t('error') },
|
||||
]}
|
||||
/>
|
||||
<Button class="flex-1" onclick={() => setLogging(false)}>{$t('workflow_logging_disable')}</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<VStack class="gap-5 py-5">
|
||||
<p class="text-md text-center">{$t('workflow_logging_disabled_description')}</p>
|
||||
<Button onclick={() => setLogging(true)}>{$t('workflow_logging_enable')}</Button>
|
||||
</VStack>
|
||||
{/if}
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
mdiDeleteOutline,
|
||||
mdiDownload,
|
||||
mdiFileDocumentMultipleOutline,
|
||||
mdiHistory,
|
||||
mdiPause,
|
||||
mdiPencil,
|
||||
mdiPlay,
|
||||
@@ -26,6 +27,7 @@ import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import WorkflowDuplicateModal from '$lib/modals/WorkflowDuplicateModal.svelte';
|
||||
import WorkflowLogsModal from '$lib/modals/WorkflowLogsModal.svelte';
|
||||
import WorkflowTemplatePickerModal from '$lib/modals/WorkflowTemplatePickerModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { copyToClipboard, downloadJson } from '$lib/utils';
|
||||
@@ -117,7 +119,13 @@ export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowRespo
|
||||
onAction: () => handleDeleteWorkflow(workflow),
|
||||
};
|
||||
|
||||
return { CopyJson, Download, Duplicate, ToggleEnabled, Edit, Delete };
|
||||
const Logs: ActionItem = {
|
||||
title: $t('check_logs'),
|
||||
icon: mdiHistory,
|
||||
onAction: () => modalManager.show(WorkflowLogsModal, { workflow }),
|
||||
};
|
||||
|
||||
return { CopyJson, Download, Duplicate, ToggleEnabled, Edit, Delete, Logs };
|
||||
};
|
||||
|
||||
export const getWorkflowShowSchemaAction = (
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export const enum ByteUnit {
|
||||
B = 'B',
|
||||
KiB = 'KiB',
|
||||
MiB = 'MiB',
|
||||
GiB = 'GiB',
|
||||
TiB = 'TiB',
|
||||
PiB = 'PiB',
|
||||
EiB = 'EiB',
|
||||
'B' = 'B',
|
||||
'KiB' = 'KiB',
|
||||
'MiB' = 'MiB',
|
||||
'GiB' = 'GiB',
|
||||
'TiB' = 'TiB',
|
||||
'PiB' = 'PiB',
|
||||
'EiB' = 'EiB',
|
||||
}
|
||||
|
||||
const byteUnits = [ByteUnit.B, ByteUnit.KiB, ByteUnit.MiB, ByteUnit.GiB, ByteUnit.TiB, ByteUnit.PiB, ByteUnit.EiB];
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
{:else}
|
||||
<div class="my-6 flex flex-col gap-3">
|
||||
{#each workflows as workflow (workflow.id)}
|
||||
{@const { ToggleEnabled, Duplicate, Edit, Delete } = getWorkflowActions($t, workflow)}
|
||||
{@const { ToggleEnabled, Duplicate, Logs, Edit, Delete } = getWorkflowActions($t, workflow)}
|
||||
|
||||
<Card class="group shadow-none transition-colors hover:border-primary">
|
||||
<CardHeader>
|
||||
@@ -120,6 +120,7 @@
|
||||
ToggleEnabled,
|
||||
Edit,
|
||||
Duplicate,
|
||||
Logs,
|
||||
getWorkflowShowSchemaAction($t, expandedIds.has(workflow.id), () => onToggleExpand(workflow.id)),
|
||||
MenuItemType.Divider,
|
||||
Delete,
|
||||
|
||||
@@ -261,7 +261,7 @@
|
||||
|
||||
$effect(() => console.log(steps));
|
||||
|
||||
const { Download, Duplicate, CopyJson, Delete } = $derived(
|
||||
const { Download, Duplicate, CopyJson, Delete, Logs } = $derived(
|
||||
getWorkflowActions($t, { ...savedWorkflow, name, description, enabled, trigger, steps }),
|
||||
);
|
||||
</script>
|
||||
@@ -276,7 +276,7 @@
|
||||
{onClose}
|
||||
translations={{ close: $t('back') }}
|
||||
closeIcon={mdiArrowLeft}
|
||||
actions={[Duplicate, CopyJson, Download, Delete].map((item) => ({ ...item, color: undefined }))}
|
||||
actions={[Logs, Duplicate, CopyJson, Download, Delete].map((item) => ({ ...item, color: undefined }))}
|
||||
>
|
||||
<ControlBarHeader>
|
||||
<ControlBarTitle>{data.workflow.name}</ControlBarTitle>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Field, FormModal, HelperText, Select } from '@immich/ui';
|
||||
import { mdiFolderSync } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import { type PageData } from './$types';
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
|
||||
@@ -63,5 +63,6 @@
|
||||
{required}
|
||||
{value}
|
||||
oninput={handleInput}
|
||||
{disabled}></textarea>
|
||||
{disabled}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ const config = {
|
||||
// TODO pending `@immich/ui` to enable it
|
||||
// runes: true,
|
||||
},
|
||||
preprocess: vitePreprocess({ script: true }),
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
version: {
|
||||
name: process.env.IMMICH_BUILD || process.env.npm_package_version || 'local',
|
||||
|
||||
Reference in New Issue
Block a user