Compare commits

...

65 Commits

Author SHA1 Message Date
Ben McCann c06776d85d fix web build 2026-07-10 13:17:50 -07:00
Ben McCann 19587322c4 enable script preprocessing 2026-07-10 12:45:08 -07:00
Ben McCann d73f32ec2a check 2026-07-10 11:38:04 -07:00
Ben McCann c785bcc60b lint 2026-07-10 11:16:27 -07:00
Ben McCann 9df48be0ce merge main 2026-07-10 07:05:37 -07:00
Ben McCann 29852bb62b regenerate openapi 2026-07-10 07:02:23 -07:00
Ben McCann dbbba17af8 fix 2026-07-10 06:59:43 -07:00
Ben McCann e0d574998d fix 2026-07-09 21:58:27 -07:00
Ben McCann e089113778 Convert to zod 2026-07-09 21:24:05 -07:00
Ben McCann 73d4e720c5 format 2026-07-04 21:59:28 -07:00
Ben McCann 6a3199e7c2 fix merge 2026-07-04 21:53:19 -07:00
Ben McCann 6e34e64785 fix merge 2026-07-04 21:49:13 -07:00
Ben McCann 2d85382501 merge main 2026-07-04 21:09:30 -07:00
mertalev f633612427 update job queries 2026-04-03 02:13:49 -04:00
mertalev 1d713f4829 handle shared link 2026-04-03 01:27:41 -04:00
mertalev 0a587bd48d update to latest draft, apply old upload changes 2026-04-03 00:58:57 -04:00
Ben McCann 253143ed49 add a missing import 2026-03-29 11:34:50 -07:00
Ben McCann f7780883c4 format 2026-03-29 11:25:18 -07:00
Ben McCann 901d544efd run make open-api 2026-03-29 11:15:13 -07:00
Ben McCann c07e87a7e5 regenerate sql files 2026-03-29 11:13:12 -07:00
Ben McCann 5133ff1909 fix build 2026-03-29 10:34:29 -07:00
Ben McCann 0c7f2dd8d2 merge main 2026-03-29 09:33:19 -07:00
mertalev 7ba458668b remove upload-length from conventional upload e2e 2025-11-06 12:15:54 -05:00
mertalev ea034f21bc linting 2025-11-06 12:15:54 -05:00
mertalev a68513247d redundant check 2025-11-06 12:15:54 -05:00
mertalev 59f7f3c23e update api 2025-11-06 12:15:54 -05:00
mertalev c88bde3cab lint 2025-11-06 12:15:54 -05:00
mertalev 818bd51036 support conventional uploads 2025-11-06 12:15:54 -05:00
mertalev 3c72409712 require header for incomplete uploads 2025-11-06 12:15:54 -05:00
mertalev 8d1a8b9465 reject empty file 2025-11-06 12:15:54 -05:00
mertalev d880e7baed infer upload length when possible 2025-11-06 12:15:54 -05:00
mertalev 42801ace35 update api 2025-11-06 12:15:54 -05:00
mertalev 838b8e9126 set max-age limit 2025-11-06 12:15:19 -05:00
mertalev 9da5a48bdd add live photo e2e 2025-11-06 12:15:19 -05:00
mertalev 27f126bd58 better abort check 2025-11-06 12:15:19 -05:00
mertalev a238c6a70d unnecessary change 2025-11-06 12:15:19 -05:00
mertalev 7222d7af30 configurable cleanup 2025-11-06 12:15:19 -05:00
mertalev d660ab2218 handle live photos 2025-11-06 12:13:33 -05:00
mertalev 69ffbcd5cf tweak types 2025-11-06 12:13:33 -05:00
mertalev bc84486668 MUST NOT validation 2025-11-06 12:13:33 -05:00
mertalev 2666ee2b4f remove log 2025-11-06 12:12:27 -05:00
mertalev 72ea7799c0 lint 2025-11-06 12:12:27 -05:00
mertalev 98c8c28b62 test interruption + abort 2025-11-06 12:12:27 -05:00
mertalev 6b1d26d3a2 more content length test inputs 2025-11-06 12:12:27 -05:00
mertalev 5e07976288 fix abortion return 2025-11-06 12:12:27 -05:00
mertalev 3f1133f9b7 typo 2025-11-06 12:12:27 -05:00
mertalev 3a087ed2cd proactive abortion 2025-11-06 12:12:27 -05:00
mertalev c723a9ac78 better content length handling 2025-11-06 12:09:27 -05:00
mertalev 550460891d add timeout 2025-11-06 12:09:27 -05:00
mertalev e3e8da168f tidying 2025-11-06 12:09:27 -05:00
mertalev de117ebe7a listen to upload event in e2e
test resume with real image
2025-11-06 12:09:27 -05:00
mertalev 3d507015e0 add service tests 2025-11-06 12:09:26 -05:00
mertalev fe71662d24 add controller tests, move validation testing from e2e
revert unnecessary change

update mocks

add structured-headers to e2e deps
2025-11-06 12:09:26 -05:00
mertalev 81a66350f6 add note about RFC 9651
authdto

remove excess logs

use structured dictionary
2025-11-06 12:07:34 -05:00
mertalev c33e65362a clean up stale uploads
stale upload cleanup

try/catch file check
2025-11-06 12:07:34 -05:00
mertalev bb5519036a unnecessary quota check 2025-11-06 12:07:34 -05:00
mertalev 177c997d96 interim+500
interim+500

interim+500
2025-11-06 12:07:34 -05:00
mertalev 2d6a2dc77b more e2e tests
consistent e2e sections

decrement quota on cancel
2025-11-06 12:07:34 -05:00
mertalev e193cb3a5b tweaks
shared pipe method

shared pipe method

require size upfront

make length optional for patch requests
2025-11-06 12:07:34 -05:00
mertalev 4b63d3d055 ensure stream is closed before releasing lock 2025-11-06 12:07:34 -05:00
mertalev 4ed92f5df5 dto refactor
add logging

handle metadata
2025-11-06 12:07:34 -05:00
mertalev 6f61bf04e4 backward compatibility 2025-11-06 12:07:34 -05:00
mertalev b21d0a1c53 working e2e 2025-11-06 12:07:34 -05:00
mertalev f80326872e interop v8 compliance 2025-11-06 12:07:34 -05:00
mertalev 7561c5e1c4 chunked upload controller 2025-11-06 12:05:56 -05:00
52 changed files with 6558 additions and 2716 deletions
+1
View File
@@ -50,6 +50,7 @@
"prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"socket.io-client": "^4.7.4", "socket.io-client": "^4.7.4",
"structured-headers": "^2.0.2",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"typescript": "^6.0.0", "typescript": "^6.0.0",
"typescript-eslint": "^8.28.0", "typescript-eslint": "^8.28.0",
File diff suppressed because it is too large Load Diff
+10
View File
@@ -704,6 +704,16 @@ 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(); utils.initSdk();
+7
View File
@@ -308,6 +308,11 @@ Class | Method | HTTP request | Description
*TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty | Empty trash *TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty | Empty trash
*TrashApi* | [**restoreAssets**](doc//TrashApi.md#restoreassets) | **POST** /trash/restore/assets | Restore assets *TrashApi* | [**restoreAssets**](doc//TrashApi.md#restoreassets) | **POST** /trash/restore/assets | Restore assets
*TrashApi* | [**restoreTrash**](doc//TrashApi.md#restoretrash) | **POST** /trash/restore | Restore trash *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* | [**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* | [**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 *UsersApi* | [**deleteUserLicense**](doc//UsersApi.md#deleteuserlicense) | **DELETE** /users/me/license | Delete user product key
@@ -635,6 +640,7 @@ Class | Method | HTTP request | Description
- [SyncUserMetadataV1](doc//SyncUserMetadataV1.md) - [SyncUserMetadataV1](doc//SyncUserMetadataV1.md)
- [SyncUserV1](doc//SyncUserV1.md) - [SyncUserV1](doc//SyncUserV1.md)
- [SystemConfigBackupsDto](doc//SystemConfigBackupsDto.md) - [SystemConfigBackupsDto](doc//SystemConfigBackupsDto.md)
- [SystemConfigBackupsDtoUpload](doc//SystemConfigBackupsDtoUpload.md)
- [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigDto](doc//SystemConfigDto.md)
- [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md)
- [SystemConfigFFmpegRealtimeDto](doc//SystemConfigFFmpegRealtimeDto.md) - [SystemConfigFFmpegRealtimeDto](doc//SystemConfigFFmpegRealtimeDto.md)
@@ -690,6 +696,7 @@ Class | Method | HTTP request | Description
- [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md) - [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md)
- [UpdateAssetDto](doc//UpdateAssetDto.md) - [UpdateAssetDto](doc//UpdateAssetDto.md)
- [UpdateLibraryDto](doc//UpdateLibraryDto.md) - [UpdateLibraryDto](doc//UpdateLibraryDto.md)
- [UploadOkDto](doc//UploadOkDto.md)
- [UsageByUserDto](doc//UsageByUserDto.md) - [UsageByUserDto](doc//UsageByUserDto.md)
- [UserAdminCreateDto](doc//UserAdminCreateDto.md) - [UserAdminCreateDto](doc//UserAdminCreateDto.md)
- [UserAdminDeleteDto](doc//UserAdminDeleteDto.md) - [UserAdminDeleteDto](doc//UserAdminDeleteDto.md)
+3
View File
@@ -64,6 +64,7 @@ part 'api/system_metadata_api.dart';
part 'api/tags_api.dart'; part 'api/tags_api.dart';
part 'api/timeline_api.dart'; part 'api/timeline_api.dart';
part 'api/trash_api.dart'; part 'api/trash_api.dart';
part 'api/upload_api.dart';
part 'api/users_api.dart'; part 'api/users_api.dart';
part 'api/users_admin_api.dart'; part 'api/users_admin_api.dart';
part 'api/views_api.dart'; part 'api/views_api.dart';
@@ -356,6 +357,7 @@ part 'model/sync_user_metadata_delete_v1.dart';
part 'model/sync_user_metadata_v1.dart'; part 'model/sync_user_metadata_v1.dart';
part 'model/sync_user_v1.dart'; part 'model/sync_user_v1.dart';
part 'model/system_config_backups_dto.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_dto.dart';
part 'model/system_config_f_fmpeg_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart';
part 'model/system_config_f_fmpeg_realtime_dto.dart'; part 'model/system_config_f_fmpeg_realtime_dto.dart';
@@ -411,6 +413,7 @@ part 'model/update_album_dto.dart';
part 'model/update_album_user_dto.dart'; part 'model/update_album_user_dto.dart';
part 'model/update_asset_dto.dart'; part 'model/update_asset_dto.dart';
part 'model/update_library_dto.dart'; part 'model/update_library_dto.dart';
part 'model/upload_ok_dto.dart';
part 'model/usage_by_user_dto.dart'; part 'model/usage_by_user_dto.dart';
part 'model/user_admin_create_dto.dart'; part 'model/user_admin_create_dto.dart';
part 'model/user_admin_delete_dto.dart'; part 'model/user_admin_delete_dto.dart';
+364
View File
@@ -0,0 +1,364 @@
//
// 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;
}
}
+4
View File
@@ -757,6 +757,8 @@ class ApiClient {
return SyncUserV1.fromJson(value); return SyncUserV1.fromJson(value);
case 'SystemConfigBackupsDto': case 'SystemConfigBackupsDto':
return SystemConfigBackupsDto.fromJson(value); return SystemConfigBackupsDto.fromJson(value);
case 'SystemConfigBackupsDtoUpload':
return SystemConfigBackupsDtoUpload.fromJson(value);
case 'SystemConfigDto': case 'SystemConfigDto':
return SystemConfigDto.fromJson(value); return SystemConfigDto.fromJson(value);
case 'SystemConfigFFmpegDto': case 'SystemConfigFFmpegDto':
@@ -867,6 +869,8 @@ class ApiClient {
return UpdateAssetDto.fromJson(value); return UpdateAssetDto.fromJson(value);
case 'UpdateLibraryDto': case 'UpdateLibraryDto':
return UpdateLibraryDto.fromJson(value); return UpdateLibraryDto.fromJson(value);
case 'UploadOkDto':
return UploadOkDto.fromJson(value);
case 'UsageByUserDto': case 'UsageByUserDto':
return UsageByUserDto.fromJson(value); return UsageByUserDto.fromJson(value);
case 'UserAdminCreateDto': case 'UserAdminCreateDto':
+6
View File
@@ -38,6 +38,8 @@ class JobName {
static const assetFileMigration = JobName._(r'AssetFileMigration'); static const assetFileMigration = JobName._(r'AssetFileMigration');
static const assetGenerateThumbnailsQueueAll = JobName._(r'AssetGenerateThumbnailsQueueAll'); static const assetGenerateThumbnailsQueueAll = JobName._(r'AssetGenerateThumbnailsQueueAll');
static const assetGenerateThumbnails = JobName._(r'AssetGenerateThumbnails'); 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 auditTableCleanup = JobName._(r'AuditTableCleanup');
static const databaseBackup = JobName._(r'DatabaseBackup'); static const databaseBackup = JobName._(r'DatabaseBackup');
static const facialRecognitionQueueAll = JobName._(r'FacialRecognitionQueueAll'); static const facialRecognitionQueueAll = JobName._(r'FacialRecognitionQueueAll');
@@ -107,6 +109,8 @@ class JobName {
assetFileMigration, assetFileMigration,
assetGenerateThumbnailsQueueAll, assetGenerateThumbnailsQueueAll,
assetGenerateThumbnails, assetGenerateThumbnails,
partialAssetCleanup,
partialAssetCleanupQueueAll,
auditTableCleanup, auditTableCleanup,
databaseBackup, databaseBackup,
facialRecognitionQueueAll, facialRecognitionQueueAll,
@@ -211,6 +215,8 @@ class JobNameTypeTransformer {
case r'AssetFileMigration': return JobName.assetFileMigration; case r'AssetFileMigration': return JobName.assetFileMigration;
case r'AssetGenerateThumbnailsQueueAll': return JobName.assetGenerateThumbnailsQueueAll; case r'AssetGenerateThumbnailsQueueAll': return JobName.assetGenerateThumbnailsQueueAll;
case r'AssetGenerateThumbnails': return JobName.assetGenerateThumbnails; 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'AuditTableCleanup': return JobName.auditTableCleanup;
case r'DatabaseBackup': return JobName.databaseBackup; case r'DatabaseBackup': return JobName.databaseBackup;
case r'FacialRecognitionQueueAll': return JobName.facialRecognitionQueueAll; case r'FacialRecognitionQueueAll': return JobName.facialRecognitionQueueAll;
+11 -3
View File
@@ -14,25 +14,31 @@ class SystemConfigBackupsDto {
/// Returns a new [SystemConfigBackupsDto] instance. /// Returns a new [SystemConfigBackupsDto] instance.
SystemConfigBackupsDto({ SystemConfigBackupsDto({
required this.database, required this.database,
required this.upload,
}); });
DatabaseBackupConfig database; DatabaseBackupConfig database;
SystemConfigBackupsDtoUpload upload;
@override @override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigBackupsDto && bool operator ==(Object other) => identical(this, other) || other is SystemConfigBackupsDto &&
other.database == database; other.database == database &&
other.upload == upload;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(database.hashCode); (database.hashCode) +
(upload.hashCode);
@override @override
String toString() => 'SystemConfigBackupsDto[database=$database]'; String toString() => 'SystemConfigBackupsDto[database=$database, upload=$upload]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'database'] = this.database; json[r'database'] = this.database;
json[r'upload'] = this.upload;
return json; return json;
} }
@@ -46,6 +52,7 @@ class SystemConfigBackupsDto {
return SystemConfigBackupsDto( return SystemConfigBackupsDto(
database: DatabaseBackupConfig.fromJson(json[r'database'])!, database: DatabaseBackupConfig.fromJson(json[r'database'])!,
upload: SystemConfigBackupsDtoUpload.fromJson(json[r'upload'])!,
); );
} }
return null; return null;
@@ -94,6 +101,7 @@ class SystemConfigBackupsDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'database', 'database',
'upload',
}; };
} }
@@ -0,0 +1,101 @@
//
// 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',
};
}
+10 -1
View File
@@ -17,6 +17,7 @@ class SystemConfigNightlyTasksDto {
required this.databaseCleanup, required this.databaseCleanup,
required this.generateMemories, required this.generateMemories,
required this.missingThumbnails, required this.missingThumbnails,
required this.removeStaleUploads,
required this.startTime, required this.startTime,
required this.syncQuotaUsage, required this.syncQuotaUsage,
}); });
@@ -33,6 +34,9 @@ class SystemConfigNightlyTasksDto {
/// Missing thumbnails /// Missing thumbnails
bool missingThumbnails; bool missingThumbnails;
/// Remove stale uploads
bool removeStaleUploads;
/// Start time (HH:MM) /// Start time (HH:MM)
String startTime; String startTime;
@@ -45,6 +49,7 @@ class SystemConfigNightlyTasksDto {
other.databaseCleanup == databaseCleanup && other.databaseCleanup == databaseCleanup &&
other.generateMemories == generateMemories && other.generateMemories == generateMemories &&
other.missingThumbnails == missingThumbnails && other.missingThumbnails == missingThumbnails &&
other.removeStaleUploads == removeStaleUploads &&
other.startTime == startTime && other.startTime == startTime &&
other.syncQuotaUsage == syncQuotaUsage; other.syncQuotaUsage == syncQuotaUsage;
@@ -55,11 +60,12 @@ class SystemConfigNightlyTasksDto {
(databaseCleanup.hashCode) + (databaseCleanup.hashCode) +
(generateMemories.hashCode) + (generateMemories.hashCode) +
(missingThumbnails.hashCode) + (missingThumbnails.hashCode) +
(removeStaleUploads.hashCode) +
(startTime.hashCode) + (startTime.hashCode) +
(syncQuotaUsage.hashCode); (syncQuotaUsage.hashCode);
@override @override
String toString() => 'SystemConfigNightlyTasksDto[clusterNewFaces=$clusterNewFaces, databaseCleanup=$databaseCleanup, generateMemories=$generateMemories, missingThumbnails=$missingThumbnails, startTime=$startTime, syncQuotaUsage=$syncQuotaUsage]'; String toString() => 'SystemConfigNightlyTasksDto[clusterNewFaces=$clusterNewFaces, databaseCleanup=$databaseCleanup, generateMemories=$generateMemories, missingThumbnails=$missingThumbnails, removeStaleUploads=$removeStaleUploads, startTime=$startTime, syncQuotaUsage=$syncQuotaUsage]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -67,6 +73,7 @@ class SystemConfigNightlyTasksDto {
json[r'databaseCleanup'] = this.databaseCleanup; json[r'databaseCleanup'] = this.databaseCleanup;
json[r'generateMemories'] = this.generateMemories; json[r'generateMemories'] = this.generateMemories;
json[r'missingThumbnails'] = this.missingThumbnails; json[r'missingThumbnails'] = this.missingThumbnails;
json[r'removeStaleUploads'] = this.removeStaleUploads;
json[r'startTime'] = this.startTime; json[r'startTime'] = this.startTime;
json[r'syncQuotaUsage'] = this.syncQuotaUsage; json[r'syncQuotaUsage'] = this.syncQuotaUsage;
return json; return json;
@@ -85,6 +92,7 @@ class SystemConfigNightlyTasksDto {
databaseCleanup: mapValueOfType<bool>(json, r'databaseCleanup')!, databaseCleanup: mapValueOfType<bool>(json, r'databaseCleanup')!,
generateMemories: mapValueOfType<bool>(json, r'generateMemories')!, generateMemories: mapValueOfType<bool>(json, r'generateMemories')!,
missingThumbnails: mapValueOfType<bool>(json, r'missingThumbnails')!, missingThumbnails: mapValueOfType<bool>(json, r'missingThumbnails')!,
removeStaleUploads: mapValueOfType<bool>(json, r'removeStaleUploads')!,
startTime: mapValueOfType<String>(json, r'startTime')!, startTime: mapValueOfType<String>(json, r'startTime')!,
syncQuotaUsage: mapValueOfType<bool>(json, r'syncQuotaUsage')!, syncQuotaUsage: mapValueOfType<bool>(json, r'syncQuotaUsage')!,
); );
@@ -138,6 +146,7 @@ class SystemConfigNightlyTasksDto {
'databaseCleanup', 'databaseCleanup',
'generateMemories', 'generateMemories',
'missingThumbnails', 'missingThumbnails',
'removeStaleUploads',
'startTime', 'startTime',
'syncQuotaUsage', 'syncQuotaUsage',
}; };
+99
View File
@@ -0,0 +1,99 @@
//
// 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',
};
}
+348 -1
View File
@@ -14819,6 +14819,321 @@
"x-immich-state": "Stable" "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": { "/users": {
"get": { "get": {
"description": "Retrieve a list of all users on the server.", "description": "Retrieve a list of all users on the server.",
@@ -19256,6 +19571,8 @@
"AssetFileMigration", "AssetFileMigration",
"AssetGenerateThumbnailsQueueAll", "AssetGenerateThumbnailsQueueAll",
"AssetGenerateThumbnails", "AssetGenerateThumbnails",
"PartialAssetCleanup",
"PartialAssetCleanupQueueAll",
"AuditTableCleanup", "AuditTableCleanup",
"DatabaseBackup", "DatabaseBackup",
"FacialRecognitionQueueAll", "FacialRecognitionQueueAll",
@@ -25545,10 +25862,24 @@
"properties": { "properties": {
"database": { "database": {
"$ref": "#/components/schemas/DatabaseBackupConfig" "$ref": "#/components/schemas/DatabaseBackupConfig"
},
"upload": {
"properties": {
"maxAgeHours": {
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer"
} }
}, },
"required": [ "required": [
"database" "maxAgeHours"
],
"type": "object"
}
},
"required": [
"database",
"upload"
], ],
"type": "object" "type": "object"
}, },
@@ -26209,6 +26540,10 @@
"description": "Missing thumbnails", "description": "Missing thumbnails",
"type": "boolean" "type": "boolean"
}, },
"removeStaleUploads": {
"description": "Remove stale uploads",
"type": "boolean"
},
"startTime": { "startTime": {
"description": "Start time (HH:MM)", "description": "Start time (HH:MM)",
"pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d$", "pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d$",
@@ -26224,6 +26559,7 @@
"databaseCleanup", "databaseCleanup",
"generateMemories", "generateMemories",
"missingThumbnails", "missingThumbnails",
"removeStaleUploads",
"startTime", "startTime",
"syncQuotaUsage" "syncQuotaUsage"
], ],
@@ -27207,6 +27543,17 @@
}, },
"type": "object" "type": "object"
}, },
"UploadOkDto": {
"properties": {
"id": {
"type": "string"
}
},
"required": [
"id"
],
"type": "object"
},
"UsageByUserDto": { "UsageByUserDto": {
"properties": { "properties": {
"photos": { "photos": {
+99
View File
@@ -2298,6 +2298,9 @@ export type DatabaseBackupConfig = {
}; };
export type SystemConfigBackupsDto = { export type SystemConfigBackupsDto = {
database: DatabaseBackupConfig; database: DatabaseBackupConfig;
upload: {
maxAgeHours: number;
};
}; };
export type SystemConfigFFmpegRealtimeDto = { export type SystemConfigFFmpegRealtimeDto = {
/** Enable real-time HLS transcoding (alpha) */ /** Enable real-time HLS transcoding (alpha) */
@@ -2515,6 +2518,8 @@ export type SystemConfigNightlyTasksDto = {
generateMemories: boolean; generateMemories: boolean;
/** Missing thumbnails */ /** Missing thumbnails */
missingThumbnails: boolean; missingThumbnails: boolean;
/** Remove stale uploads */
removeStaleUploads: boolean;
/** Start time (HH:MM) */ /** Start time (HH:MM) */
startTime: string; startTime: string;
/** Sync quota usage */ /** Sync quota usage */
@@ -2743,6 +2748,9 @@ export type TrashResponseDto = {
/** Number of items in trash */ /** Number of items in trash */
count: number; count: number;
}; };
export type UploadOkDto = {
id: string;
};
export type UserUpdateMeDto = { export type UserUpdateMeDto = {
avatarColor?: (UserAvatarColor) | null; avatarColor?: (UserAvatarColor) | null;
/** User email */ /** User email */
@@ -6746,6 +6754,95 @@ export function restoreAssets({ bulkIdsDto }: {
body: 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 * Get all users
*/ */
@@ -7449,6 +7546,8 @@ export enum JobName {
AssetFileMigration = "AssetFileMigration", AssetFileMigration = "AssetFileMigration",
AssetGenerateThumbnailsQueueAll = "AssetGenerateThumbnailsQueueAll", AssetGenerateThumbnailsQueueAll = "AssetGenerateThumbnailsQueueAll",
AssetGenerateThumbnails = "AssetGenerateThumbnails", AssetGenerateThumbnails = "AssetGenerateThumbnails",
PartialAssetCleanup = "PartialAssetCleanup",
PartialAssetCleanupQueueAll = "PartialAssetCleanupQueueAll",
AuditTableCleanup = "AuditTableCleanup", AuditTableCleanup = "AuditTableCleanup",
DatabaseBackup = "DatabaseBackup", DatabaseBackup = "DatabaseBackup",
FacialRecognitionQueueAll = "FacialRecognitionQueueAll", FacialRecognitionQueueAll = "FacialRecognitionQueueAll",
+2262 -2646
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -110,6 +110,7 @@
"sharp": "^0.34.5", "sharp": "^0.34.5",
"sirv": "^3.0.0", "sirv": "^3.0.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"structured-headers": "^2.0.2",
"tailwindcss-preset-email": "^1.4.0", "tailwindcss-preset-email": "^1.4.0",
"thumbhash": "^0.1.1", "thumbhash": "^0.1.1",
"transformation-matrix": "^3.1.0", "transformation-matrix": "^3.1.0",
+8
View File
@@ -24,6 +24,9 @@ export type SystemConfig = {
cronExpression: string; cronExpression: string;
keepLastAmount: number; keepLastAmount: number;
}; };
upload: {
maxAgeHours: number;
};
}; };
ffmpeg: { ffmpeg: {
crf: number; crf: number;
@@ -167,6 +170,7 @@ export type SystemConfig = {
clusterNewFaces: boolean; clusterNewFaces: boolean;
generateMemories: boolean; generateMemories: boolean;
syncQuotaUsage: boolean; syncQuotaUsage: boolean;
removeStaleUploads: boolean;
}; };
trash: { trash: {
enabled: boolean; enabled: boolean;
@@ -225,6 +229,9 @@ export const defaults = Object.freeze<SystemConfig>({
cronExpression: CronExpression.EVERY_DAY_AT_2AM, cronExpression: CronExpression.EVERY_DAY_AT_2AM,
keepLastAmount: 14, keepLastAmount: 14,
}, },
upload: {
maxAgeHours: 72,
},
}, },
ffmpeg: { ffmpeg: {
crf: 23, crf: 23,
@@ -399,6 +406,7 @@ export const defaults = Object.freeze<SystemConfig>({
syncQuotaUsage: true, syncQuotaUsage: true,
missingThumbnails: true, missingThumbnails: true,
clusterNewFaces: true, clusterNewFaces: true,
removeStaleUploads: true,
}, },
trash: { trash: {
enabled: true, enabled: true,
@@ -0,0 +1,445 @@
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,
});
};
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);
expect(body).toEqual(
expect.objectContaining({
message: expect.arrayContaining(['version must not be less than 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);
expect(body).toEqual(expect.objectContaining({ message: 'x-immich-asset-data header is required' }));
});
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);
expect(body).toEqual(expect.objectContaining({ message: 'Missing repr-digest header' }));
});
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);
expect(body).toEqual(expect.objectContaining({ message: '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);
expect(body).toEqual(expect.objectContaining({ message: '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);
expect(body).toEqual(
expect.objectContaining({
message: expect.arrayContaining([expect.stringContaining('deviceAssetId')]),
}),
);
});
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);
expect(body).toEqual(
expect.objectContaining({
message: expect.arrayContaining([expect.stringContaining('deviceId')]),
}),
);
});
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);
expect(body).toEqual(
expect.objectContaining({
message: expect.arrayContaining([expect.stringContaining('filename')]),
}),
);
});
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);
expect(body).toEqual(expect.objectContaining({ message: '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);
expect(body).toEqual(
expect.objectContaining({
message: expect.arrayContaining(['uploadLength must not be less than 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);
expect(body).toEqual(
expect.objectContaining({
message: expect.arrayContaining(['version must be an integer number', 'version must not be less than 3']),
}),
);
});
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);
expect(body).toEqual(
expect.objectContaining({
message: expect.arrayContaining([
'uploadOffset must be an integer number',
'uploadOffset must not be less than 0',
]),
}),
);
});
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);
expect(body).toEqual(expect.objectContaining({ message: ['uploadComplete must be a boolean value'] }));
});
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({ message: ['id must be a UUID'] }));
});
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);
expect(body).toEqual(
expect.objectContaining({
message: expect.arrayContaining(['uploadOffset must not be less than 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);
expect(body).toEqual(
expect.objectContaining({
message: ['contentType 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({ message: ['id must be a UUID'] }));
});
});
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);
});
});
});
@@ -0,0 +1,108 @@
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);
}
}
+2
View File
@@ -3,6 +3,7 @@ import { AlbumController } from 'src/controllers/album.controller';
import { ApiKeyController } from 'src/controllers/api-key.controller'; import { ApiKeyController } from 'src/controllers/api-key.controller';
import { AppController } from 'src/controllers/app.controller'; import { AppController } from 'src/controllers/app.controller';
import { AssetMediaController } from 'src/controllers/asset-media.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 { AssetController } from 'src/controllers/asset.controller';
import { AuthAdminController } from 'src/controllers/auth-admin.controller'; import { AuthAdminController } from 'src/controllers/auth-admin.controller';
import { AuthController } from 'src/controllers/auth.controller'; import { AuthController } from 'src/controllers/auth.controller';
@@ -47,6 +48,7 @@ export const controllers = [
AppController, AppController,
AssetController, AssetController,
AssetMediaController, AssetMediaController,
AssetUploadController,
AuthController, AuthController,
AuthAdminController, AuthAdminController,
DatabaseBackupController, DatabaseBackupController,
+5 -5
View File
@@ -190,11 +190,11 @@ type CustomExtensions = {
}; };
enum ApiState { enum ApiState {
'Stable' = 'Stable', Stable = 'Stable',
'Alpha' = 'Alpha', Alpha = 'Alpha',
'Beta' = 'Beta', Beta = 'Beta',
'Internal' = 'Internal', Internal = 'Internal',
'Deprecated' = 'Deprecated', Deprecated = 'Deprecated',
} }
export class HistoryBuilder { export class HistoryBuilder {
private hasDeprecated = false; private hasDeprecated = false;
+191
View File
@@ -0,0 +1,191 @@
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', 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', message: `${Header.UploadComplete} is required` });
return z.NEVER;
}
}
+8 -1
View File
@@ -93,7 +93,13 @@ const SystemConfigIntegrityChecksSchema = z
.describe('Integrity checks config') .describe('Integrity checks config')
.meta({ id: 'SystemConfigIntegrityChecks' }); .meta({ id: 'SystemConfigIntegrityChecks' });
const SystemConfigBackupsSchema = z.object({ database: DatabaseBackupSchema }).meta({ id: 'SystemConfigBackupsDto' }); const UploadBackupSchema = z.object({
maxAgeHours: z.int().min(1),
});
const SystemConfigBackupsSchema = z
.object({ database: DatabaseBackupSchema, upload: UploadBackupSchema })
.meta({ id: 'SystemConfigBackupsDto' });
const SystemConfigFFmpegSchema = z const SystemConfigFFmpegSchema = z
.object({ .object({
@@ -222,6 +228,7 @@ const SystemConfigNightlyTasksSchema = z
clusterNewFaces: configBool.describe('Cluster new faces'), clusterNewFaces: configBool.describe('Cluster new faces'),
generateMemories: configBool.describe('Generate memories'), generateMemories: configBool.describe('Generate memories'),
syncQuotaUsage: configBool.describe('Sync quota usage'), syncQuotaUsage: configBool.describe('Sync quota usage'),
removeStaleUploads: configBool.describe('Remove stale uploads'),
}) })
.meta({ id: 'SystemConfigNightlyTasksDto' }); .meta({ id: 'SystemConfigNightlyTasksDto' });
+5
View File
@@ -26,6 +26,7 @@ export enum ImmichHeader {
CorrelationId = 'X-Correlation-ID', CorrelationId = 'X-Correlation-ID',
HlsInitSegment = 'x-immich-hls-msn', HlsInitSegment = 'x-immich-hls-msn',
HlsPosition = 'x-immich-hls-pos', HlsPosition = 'x-immich-hls-pos',
AssetData = 'x-immich-asset-data',
} }
export enum ImmichQuery { export enum ImmichQuery {
@@ -391,6 +392,7 @@ export enum AssetStatus {
Active = 'active', Active = 'active',
Trashed = 'trashed', Trashed = 'trashed',
Deleted = 'deleted', Deleted = 'deleted',
Partial = 'partial',
} }
export enum SourceType { export enum SourceType {
@@ -784,6 +786,7 @@ export enum BootstrapEventPriority {
JobService = -190, JobService = -190,
// Initialize config after other bootstrap services, stop other services from using config on bootstrap // Initialize config after other bootstrap services, stop other services from using config on bootstrap
SystemConfig = 100, SystemConfig = 100,
UploadService = 180,
PluginSync = 190, PluginSync = 190,
// Load plugins into memory after sync // Load plugins into memory after sync
PluginLoad = 200, PluginLoad = 200,
@@ -840,6 +843,8 @@ export enum JobName {
AssetFileMigration = 'AssetFileMigration', AssetFileMigration = 'AssetFileMigration',
AssetGenerateThumbnailsQueueAll = 'AssetGenerateThumbnailsQueueAll', AssetGenerateThumbnailsQueueAll = 'AssetGenerateThumbnailsQueueAll',
AssetGenerateThumbnails = 'AssetGenerateThumbnails', AssetGenerateThumbnails = 'AssetGenerateThumbnails',
PartialAssetCleanup = 'PartialAssetCleanup',
PartialAssetCleanupQueueAll = 'PartialAssetCleanupQueueAll',
AuditTableCleanup = 'AuditTableCleanup', AuditTableCleanup = 'AuditTableCleanup',
+43 -5
View File
@@ -14,6 +14,7 @@ from
left join "smart_search" on "asset"."id" = "smart_search"."assetId" left join "smart_search" on "asset"."id" = "smart_search"."assetId"
where where
"asset"."id" = $1::uuid "asset"."id" = $1::uuid
and "asset"."status" != 'partial'
limit limit
$2 $2
@@ -44,6 +45,7 @@ from
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where where
"asset"."id" = $2::uuid "asset"."id" = $2::uuid
and "asset"."status" != 'partial'
limit limit
$3 $3
@@ -72,6 +74,7 @@ from
"asset" "asset"
where where
"asset"."id" = $2::uuid "asset"."id" = $2::uuid
and "asset"."status" != 'partial'
limit limit
$3 $3
@@ -83,7 +86,8 @@ from
"asset" "asset"
inner join "asset_job_status" on "asset_job_status"."assetId" = "asset"."id" inner join "asset_job_status" on "asset_job_status"."assetId" = "asset"."id"
where where
"asset"."deletedAt" is null "asset"."status" != 'partial'
and "asset"."deletedAt" is null
and "asset"."visibility" != 'hidden' and "asset"."visibility" != 'hidden'
and ( and (
not exists ( not exists (
@@ -196,6 +200,7 @@ from
"asset" "asset"
where where
"asset"."id" = $1 "asset"."id" = $1
and "asset"."status" != 'partial'
-- AssetJobRepository.getForGenerateThumbnailJob -- AssetJobRepository.getForGenerateThumbnailJob
select select
@@ -303,6 +308,7 @@ from
left join "asset_video" on "asset_video"."assetId" = "asset"."id" left join "asset_video" on "asset_video"."assetId" = "asset"."id"
where where
"asset"."id" = $4 "asset"."id" = $4
and "asset"."status" != 'partial'
-- AssetJobRepository.getForMetadataExtraction -- AssetJobRepository.getForMetadataExtraction
select select
@@ -359,14 +365,17 @@ from
"asset" "asset"
where where
"asset"."id" = $3 "asset"."id" = $3
and "asset"."status" != 'partial'
-- AssetJobRepository.getLockedPropertiesForMetadataExtraction -- AssetJobRepository.getLockedPropertiesForMetadataExtraction
select select
"asset_exif"."lockedProperties" "asset_exif"."lockedProperties"
from from
"asset_exif" "asset_exif"
inner join "asset" on "asset"."id" = "asset_exif"."assetId"
where where
"asset_exif"."assetId" = $1 "asset_exif"."assetId" = $1
and "asset"."status" != 'partial'
-- AssetJobRepository.getAlbumThumbnailFiles -- AssetJobRepository.getAlbumThumbnailFiles
select select
@@ -376,8 +385,10 @@ select
"asset_file"."isEdited" "asset_file"."isEdited"
from from
"asset_file" "asset_file"
inner join "asset" on "asset"."id" = "asset_file"."assetId"
where where
"asset_file"."assetId" = $1 "asset_file"."assetId" = $1
and "asset"."status" != 'partial'
and "asset_file"."type" = $2 and "asset_file"."type" = $2
-- AssetJobRepository.streamForSearchDuplicates -- AssetJobRepository.streamForSearchDuplicates
@@ -388,7 +399,8 @@ from
inner join "smart_search" on "asset"."id" = "smart_search"."assetId" inner join "smart_search" on "asset"."id" = "smart_search"."assetId"
inner join "asset_job_status" as "job_status" on "job_status"."assetId" = "asset"."id" inner join "asset_job_status" as "job_status" on "job_status"."assetId" = "asset"."id"
where where
"asset"."deletedAt" is null "asset"."status" != 'partial'
and "asset"."deletedAt" is null
and "asset"."visibility" in ('archive', 'timeline') and "asset"."visibility" in ('archive', 'timeline')
and "job_status"."duplicatesDetectedAt" is null and "job_status"."duplicatesDetectedAt" is null
@@ -400,6 +412,7 @@ from
inner join "asset_job_status" as "job_status" on "assetId" = "asset"."id" inner join "asset_job_status" as "job_status" on "assetId" = "asset"."id"
where where
"asset"."visibility" != $1 "asset"."visibility" != $1
and "asset"."status" != 'partial'
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
and exists ( and exists (
select select
@@ -442,6 +455,7 @@ from
"asset" "asset"
where where
"asset"."id" = $2 "asset"."id" = $2
and "asset"."status" != 'partial'
-- AssetJobRepository.getForDetectFacesJob -- AssetJobRepository.getForDetectFacesJob
select select
@@ -483,6 +497,7 @@ from
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where where
"asset"."id" = $2 "asset"."id" = $2
and "asset"."status" != 'partial'
-- AssetJobRepository.getForOcr -- AssetJobRepository.getForOcr
select select
@@ -501,6 +516,7 @@ from
"asset" "asset"
where where
"asset"."id" = $1 "asset"."id" = $1
and "asset"."status" != 'partial'
-- AssetJobRepository.getForSyncAssets -- AssetJobRepository.getForSyncAssets
select select
@@ -514,6 +530,7 @@ from
"asset" "asset"
where where
"asset"."id" = any ($1::uuid[]) "asset"."id" = any ($1::uuid[])
and "asset"."status" != 'partial'
-- AssetJobRepository.getForAssetDeletion -- AssetJobRepository.getForAssetDeletion
select select
@@ -572,6 +589,7 @@ from
) as "stack_result" on true ) as "stack_result" on true
where where
"asset"."id" = $3 "asset"."id" = $3
and "asset"."status" != 'partial'
-- AssetJobRepository.streamForVideoConversion -- AssetJobRepository.streamForVideoConversion
select select
@@ -590,6 +608,7 @@ where
and "asset_file"."type" = 'encoded_video' and "asset_file"."type" = 'encoded_video'
) )
and "asset"."visibility" != 'hidden' and "asset"."visibility" != 'hidden'
and "asset"."status" != 'partial'
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
-- AssetJobRepository.getForVideoConversion -- AssetJobRepository.getForVideoConversion
@@ -697,6 +716,7 @@ from
where where
"asset"."id" = $1 "asset"."id" = $1
and "asset"."type" = 'VIDEO' and "asset"."type" = 'VIDEO'
and "asset"."status" != 'partial'
-- AssetJobRepository.streamForMetadataExtraction -- AssetJobRepository.streamForMetadataExtraction
select select
@@ -709,6 +729,7 @@ where
"asset_job_status"."metadataExtractedAt" is null "asset_job_status"."metadataExtractedAt" is null
or "asset_job_status"."assetId" is null or "asset_job_status"."assetId" is null
) )
and "asset"."status" != 'partial'
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
-- AssetJobRepository.getForStorageTemplateJob -- AssetJobRepository.getForStorageTemplateJob
@@ -749,7 +770,8 @@ from
"asset" "asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where where
"asset"."deletedAt" is null "asset"."status" != 'partial'
and "asset"."deletedAt" is null
and "asset"."id" = $2 and "asset"."id" = $2
and "asset"."visibility" != $3 and "asset"."visibility" != $3
@@ -791,7 +813,8 @@ from
"asset" "asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where where
"asset"."deletedAt" is null "asset"."status" != 'partial'
and "asset"."deletedAt" is null
and "asset"."visibility" != $2 and "asset"."visibility" != $2
-- AssetJobRepository.streamForDeletedJob -- AssetJobRepository.streamForDeletedJob
@@ -802,6 +825,7 @@ from
"asset" "asset"
where where
"asset"."deletedAt" <= $1 "asset"."deletedAt" <= $1
and "asset"."status" != 'partial'
-- AssetJobRepository.streamForSidecar -- AssetJobRepository.streamForSidecar
select select
@@ -818,6 +842,7 @@ where
"asset_file"."assetId" = "asset"."id" "asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1 and "asset_file"."type" = $1
) )
and "asset"."status" != 'partial'
-- AssetJobRepository.streamForDetectFacesJob -- AssetJobRepository.streamForDetectFacesJob
select select
@@ -827,6 +852,7 @@ from
inner join "asset_job_status" as "job_status" on "assetId" = "asset"."id" inner join "asset_job_status" as "job_status" on "assetId" = "asset"."id"
where where
"asset"."visibility" != $1 "asset"."visibility" != $1
and "asset"."status" != 'partial'
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
and exists ( and exists (
select select
@@ -836,6 +862,7 @@ where
"assetId" = "asset"."id" "assetId" = "asset"."id"
and "asset_file"."type" = $2 and "asset_file"."type" = $2
) )
and "asset"."status" != 'partial'
order by order by
"asset"."fileCreatedAt" desc "asset"."fileCreatedAt" desc
@@ -849,6 +876,7 @@ where
"asset_job_status"."ocrAt" is null "asset_job_status"."ocrAt" is null
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
and "asset"."visibility" != $1 and "asset"."visibility" != $1
and "asset"."status" != 'partial'
-- AssetJobRepository.streamForMigrationJob -- AssetJobRepository.streamForMigrationJob
select select
@@ -856,4 +884,14 @@ select
from from
"asset" "asset"
where where
"asset"."deletedAt" is null "asset"."status" != 'partial'
and "asset"."deletedAt" is null
-- AssetJobRepository.streamForPartialAssetCleanupJob
select
"id"
from
"asset"
where
"asset"."status" = 'partial'
and "asset"."createdAt" < $1
+84 -1
View File
@@ -101,6 +101,87 @@ where
and "key" = $2 and "key" = $2
commit 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 -- AssetRepository.getByDayOfYear
with with
"res" as ( "res" as (
@@ -329,7 +410,9 @@ where
-- AssetRepository.getUploadAssetIdByChecksum -- AssetRepository.getUploadAssetIdByChecksum
select select
"id" "id",
"status",
"createdAt"
from from
"asset" "asset"
where where
@@ -31,6 +31,7 @@ export class AssetJobRepository {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.where('asset.id', '=', asUuid(id)) .where('asset.id', '=', asUuid(id))
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.leftJoin('smart_search', 'asset.id', 'smart_search.assetId') .leftJoin('smart_search', 'asset.id', 'smart_search.assetId')
.select(['id', 'type', 'ownerId', 'duplicateId', 'stackId', 'visibility', 'smart_search.embedding']) .select(['id', 'type', 'ownerId', 'duplicateId', 'stackId', 'visibility', 'smart_search.embedding'])
.limit(1) .limit(1)
@@ -42,6 +43,7 @@ export class AssetJobRepository {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.where('asset.id', '=', asUuid(id)) .where('asset.id', '=', asUuid(id))
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.select(['id', 'originalPath']) .select(['id', 'originalPath'])
.select((eb) => withFiles(eb, AssetFileType.Sidecar)) .select((eb) => withFiles(eb, AssetFileType.Sidecar))
.$call(withExifInner) .$call(withExifInner)
@@ -54,6 +56,7 @@ export class AssetJobRepository {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.where('asset.id', '=', asUuid(id)) .where('asset.id', '=', asUuid(id))
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.select(['id', 'originalPath']) .select(['id', 'originalPath'])
.select((eb) => withFiles(eb, AssetFileType.Sidecar)) .select((eb) => withFiles(eb, AssetFileType.Sidecar))
.limit(1) .limit(1)
@@ -65,6 +68,7 @@ export class AssetJobRepository {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.select(['asset.id', 'asset.isEdited']) .select(['asset.id', 'asset.isEdited'])
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.where('asset.deletedAt', 'is', null) .where('asset.deletedAt', 'is', null)
.where('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden)) .where('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden))
.$if(!options.force, (qb) => .$if(!options.force, (qb) =>
@@ -110,6 +114,7 @@ export class AssetJobRepository {
.select(['asset.id', 'asset.ownerId']) .select(['asset.id', 'asset.ownerId'])
.select(withFiles) .select(withFiles)
.where('asset.id', '=', id) .where('asset.id', '=', id)
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -141,6 +146,7 @@ export class AssetJobRepository {
.select((eb) => withVideoStream(eb).as('videoStream')) .select((eb) => withVideoStream(eb).as('videoStream'))
.select((eb) => withVideoFormat(eb).as('format')) .select((eb) => withVideoFormat(eb).as('format'))
.where('asset.id', '=', id) .where('asset.id', '=', id)
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -152,6 +158,7 @@ export class AssetJobRepository {
.select(withFaces) .select(withFaces)
.select((eb) => withFiles(eb, AssetFileType.Sidecar)) .select((eb) => withFiles(eb, AssetFileType.Sidecar))
.where('asset.id', '=', id) .where('asset.id', '=', id)
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -159,8 +166,10 @@ export class AssetJobRepository {
async getLockedPropertiesForMetadataExtraction(assetId: string) { async getLockedPropertiesForMetadataExtraction(assetId: string) {
return this.db return this.db
.selectFrom('asset_exif') .selectFrom('asset_exif')
.innerJoin('asset', 'asset.id', 'asset_exif.assetId')
.select('asset_exif.lockedProperties') .select('asset_exif.lockedProperties')
.where('asset_exif.assetId', '=', assetId) .where('asset_exif.assetId', '=', assetId)
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.executeTakeFirst() .executeTakeFirst()
.then((row) => row?.lockedProperties ?? []); .then((row) => row?.lockedProperties ?? []);
} }
@@ -169,8 +178,10 @@ export class AssetJobRepository {
getAlbumThumbnailFiles(id: string, fileType?: AssetFileType) { getAlbumThumbnailFiles(id: string, fileType?: AssetFileType) {
return this.db return this.db
.selectFrom('asset_file') .selectFrom('asset_file')
.innerJoin('asset', 'asset.id', 'asset_file.assetId')
.select(columns.assetFiles) .select(columns.assetFiles)
.where('asset_file.assetId', '=', id) .where('asset_file.assetId', '=', id)
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.$if(!!fileType, (qb) => qb.where('asset_file.type', '=', fileType!)) .$if(!!fileType, (qb) => qb.where('asset_file.type', '=', fileType!))
.execute(); .execute();
} }
@@ -179,6 +190,7 @@ export class AssetJobRepository {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.where('asset.visibility', '!=', AssetVisibility.Hidden) .where('asset.visibility', '!=', AssetVisibility.Hidden)
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.where('asset.deletedAt', 'is', null) .where('asset.deletedAt', 'is', null)
.innerJoin('asset_job_status as job_status', 'assetId', 'asset.id') .innerJoin('asset_job_status as job_status', 'assetId', 'asset.id')
.where((eb) => .where((eb) =>
@@ -196,6 +208,7 @@ export class AssetJobRepository {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.select(['asset.id']) .select(['asset.id'])
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.where('asset.deletedAt', 'is', null) .where('asset.deletedAt', 'is', null)
.innerJoin('smart_search', 'asset.id', 'smart_search.assetId') .innerJoin('smart_search', 'asset.id', 'smart_search.assetId')
.$call(withDefaultVisibility) .$call(withDefaultVisibility)
@@ -224,6 +237,7 @@ export class AssetJobRepository {
.select(['asset.id', 'asset.visibility']) .select(['asset.id', 'asset.visibility'])
.select((eb) => withFiles(eb, AssetFileType.Preview)) .select((eb) => withFiles(eb, AssetFileType.Preview))
.where('asset.id', '=', id) .where('asset.id', '=', id)
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -236,6 +250,7 @@ export class AssetJobRepository {
.select((eb) => withFaces(eb, true, true)) .select((eb) => withFaces(eb, true, true))
.select((eb) => withFiles(eb, AssetFileType.Preview)) .select((eb) => withFiles(eb, AssetFileType.Preview))
.where('asset.id', '=', id) .where('asset.id', '=', id)
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -245,6 +260,7 @@ export class AssetJobRepository {
.selectFrom('asset') .selectFrom('asset')
.select((eb) => ['asset.visibility', withFilePath(eb, AssetFileType.Preview).as('previewFile')]) .select((eb) => ['asset.visibility', withFilePath(eb, AssetFileType.Preview).as('previewFile')])
.where('asset.id', '=', id) .where('asset.id', '=', id)
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -261,6 +277,7 @@ export class AssetJobRepository {
'asset.fileModifiedAt', 'asset.fileModifiedAt',
]) ])
.where('asset.id', '=', anyUuid(ids)) .where('asset.id', '=', anyUuid(ids))
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.execute(); .execute();
} }
@@ -307,6 +324,7 @@ export class AssetJobRepository {
.as('stack'), .as('stack'),
) )
.where('asset.id', '=', id) .where('asset.id', '=', id)
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -331,6 +349,7 @@ export class AssetJobRepository {
) )
.where('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden)), .where('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden)),
) )
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.where('asset.deletedAt', 'is', null) .where('asset.deletedAt', 'is', null)
.stream(); .stream();
} }
@@ -349,6 +368,7 @@ export class AssetJobRepository {
.select((eb) => withVideoFormat(eb).$notNull().as('format')) .select((eb) => withVideoFormat(eb).$notNull().as('format'))
.where('asset.id', '=', id) .where('asset.id', '=', id)
.where('asset.type', '=', sql.lit(AssetType.Video)) .where('asset.type', '=', sql.lit(AssetType.Video))
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -364,6 +384,7 @@ export class AssetJobRepository {
eb.or([eb('asset_job_status.metadataExtractedAt', 'is', null), eb('asset_job_status.assetId', 'is', null)]), 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) .where('asset.deletedAt', 'is', null)
.stream(); .stream();
} }
@@ -390,6 +411,7 @@ export class AssetJobRepository {
'asset_exif.lensModel', 'asset_exif.lensModel',
]) ])
.select((eb) => withFiles(eb, AssetFileType.Sidecar)) .select((eb) => withFiles(eb, AssetFileType.Sidecar))
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.where('asset.deletedAt', 'is', null); .where('asset.deletedAt', 'is', null);
} }
@@ -412,6 +434,7 @@ export class AssetJobRepository {
.selectFrom('asset') .selectFrom('asset')
.select(['id', 'isOffline']) .select(['id', 'isOffline'])
.where('asset.deletedAt', '<=', trashedBefore) .where('asset.deletedAt', '<=', trashedBefore)
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.stream(); .stream();
} }
@@ -433,6 +456,7 @@ export class AssetJobRepository {
), ),
), ),
) )
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.stream(); .stream();
} }
@@ -441,6 +465,7 @@ export class AssetJobRepository {
return this.assetsWithPreviews() return this.assetsWithPreviews()
.$if(force === false, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null)) .$if(force === false, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null))
.select(['asset.id']) .select(['asset.id'])
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.orderBy('asset.fileCreatedAt', 'desc') .orderBy('asset.fileCreatedAt', 'desc')
.stream(); .stream();
} }
@@ -457,11 +482,37 @@ export class AssetJobRepository {
) )
.where('asset.deletedAt', 'is', null) .where('asset.deletedAt', 'is', null)
.where('asset.visibility', '!=', AssetVisibility.Hidden) .where('asset.visibility', '!=', AssetVisibility.Hidden)
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
.stream(); .stream();
} }
@GenerateSql({ params: [DummyValue.DATE], stream: true }) @GenerateSql({ params: [DummyValue.DATE], stream: true })
streamForMigrationJob() { streamForMigrationJob() {
return this.db.selectFrom('asset').select(['id']).where('asset.deletedAt', 'is', null).stream(); 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();
} }
} }
+127 -5
View File
@@ -446,6 +446,130 @@ export class AssetRepository {
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow(); 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 }) @ChunkedArray({ chunkSize: 4000 })
async createAll(assets: Insertable<AssetTable>[]) { async createAll(assets: Insertable<AssetTable>[]) {
const ids = await this.db.insertInto('asset').values(assets).returning('id').execute(); const ids = await this.db.insertInto('asset').values(assets).returning('id').execute();
@@ -671,17 +795,15 @@ export class AssetRepository {
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] })
async getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise<string | undefined> { getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer) {
const asset = await this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.select('id') .select(['id', 'status', 'createdAt'])
.where('ownerId', '=', asUuid(ownerId)) .where('ownerId', '=', asUuid(ownerId))
.where('checksum', '=', checksum) .where('checksum', '=', checksum)
.where('libraryId', 'is', null) .where('libraryId', 'is', null)
.limit(1) .limit(1)
.executeTakeFirst(); .executeTakeFirst();
return asset?.id;
} }
findLivePhotoMatch(options: LivePhotoSearchOptions) { findLivePhotoMatch(options: LivePhotoSearchOptions) {
@@ -449,6 +449,20 @@ export class DatabaseRepository {
return res as R; 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> { tryLock(lock: DatabaseLock): Promise<boolean> {
return this.db.connection().execute(async (connection) => this.acquireTryLock(lock, connection)); return this.db.connection().execute(async (connection) => this.acquireTryLock(lock, connection));
} }
@@ -465,6 +479,10 @@ export class DatabaseRepository {
await sql`SELECT pg_advisory_lock(${lock})`.execute(connection); 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> { private async acquireTryLock(lock: DatabaseLock, connection: Kysely<DB>): Promise<boolean> {
const { rows } = await sql<{ const { rows } = await sql<{
pg_try_advisory_lock: boolean; pg_try_advisory_lock: boolean;
@@ -476,6 +494,10 @@ export class DatabaseRepository {
await sql`SELECT pg_advisory_unlock(${lock})`.execute(connection); 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> { async revertLastMigration(): Promise<string | undefined> {
this.logger.debug('Reverting last migration'); this.logger.debug('Reverting last migration');
+4 -1
View File
@@ -42,7 +42,7 @@ type EventMap = {
AlbumInvite: [{ id: string; userId: string; senderName: string }]; AlbumInvite: [{ id: string; userId: string; senderName: string }];
// asset events // asset events
AssetCreate: [{ asset: Asset; file: UploadFile }]; AssetCreate: [{ asset: Asset; file?: UploadFile }];
AssetTag: [{ assetId: string }]; AssetTag: [{ assetId: string }];
AssetUntag: [{ assetId: string }]; AssetUntag: [{ assetId: string }];
AssetHide: [{ assetId: string; userId: string }]; AssetHide: [{ assetId: string; userId: string }];
@@ -81,6 +81,9 @@ type EventMap = {
// stack bulk events // stack bulk events
StackDeleteAll: [{ stackIds: string[]; userId: string }]; StackDeleteAll: [{ stackIds: string[]; userId: string }];
// upload events
UploadAbort: [{ assetId: string; abortTime: Date }];
// user events // user events
UserSignup: [{ notify: boolean; id: string; password?: string }]; UserSignup: [{ notify: boolean; id: string; password?: string }];
UserCreate: [UserEvent]; UserCreate: [UserEvent];
@@ -154,8 +154,7 @@ export class IntegrityRepository {
.select(['allPaths.path as path', 'allPaths.assetId', 'allPaths.fileAssetId', 'integrity_report.id as reportId']) .select(['allPaths.path as path', 'allPaths.assetId', 'allPaths.fileAssetId', 'integrity_report.id as reportId'])
.stream() as AsyncIterableIterator< .stream() as AsyncIterableIterator<
{ path: string; reportId: string | null } & ( { path: string; reportId: string | null } & (
| { assetId: string; fileAssetId: null } { assetId: string; fileAssetId: null } | { assetId: null; fileAssetId: string }
| { assetId: null; fileAssetId: string }
) )
>; >;
} }
+14 -5
View File
@@ -75,8 +75,14 @@ export class StorageRepository {
return fs.writeFile(filepath, buffer, { flag: 'wx' }); return fs.writeFile(filepath, buffer, { flag: 'wx' });
} }
createWriteStream(filepath: string): Writable { createWriteStream(filepath: string, options?: { flush: boolean }): Writable {
return createWriteStream(filepath, { flags: 'w', flush: true }); 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 });
} }
createOrOverwriteFile(filepath: string, buffer: Buffer) { createOrOverwriteFile(filepath: string, buffer: Buffer) {
@@ -199,10 +205,13 @@ export class StorageRepository {
} }
} }
mkdirSync(filepath: string): void { mkdir(filepath: string): Promise<string | undefined> {
if (!existsSync(filepath)) { return fs.mkdir(filepath, { recursive: true });
mkdirSync(filepath, { recursive: true });
} }
mkdirSync(filepath: string): void {
// does not throw an error if the folder already exists
mkdirSync(filepath, { recursive: true });
} }
existsSync(filepath: string) { existsSync(filepath: string) {
@@ -25,7 +25,9 @@ export const serverEvents = [
'HlsSessionRequest', 'HlsSessionRequest',
'HlsSessionResult', 'HlsSessionResult',
'HlsSessionEnd', 'HlsSessionEnd',
'UploadAbort',
] as const; ] as const;
export type ServerEvents = (typeof serverEvents)[number]; export type ServerEvents = (typeof serverEvents)[number];
export interface ClientEventMap { export interface ClientEventMap {
@@ -0,0 +1,9 @@
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
}
@@ -9,7 +9,7 @@ import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos
import { AssetMediaCreateDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto'; import { AssetMediaCreateDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
import { MapAsset } from 'src/dtos/asset-response.dto'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetEditAction } from 'src/dtos/editing.dto';
import { AssetFileType, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum'; import { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard'; import { AuthRequest } from 'src/middleware/auth.guard';
import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetMediaService } from 'src/services/asset-media.service';
import { UploadBody } from 'src/types'; import { UploadBody } from 'src/types';
@@ -188,7 +188,11 @@ describe(AssetMediaService.name, () => {
}); });
it('should find an existing asset', async () => { it('should find an existing asset', async () => {
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue({
id: 'asset-id',
createdAt: new Date(),
status: AssetStatus.Active,
});
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({ await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({
id: 'asset-id', id: 'asset-id',
status: AssetMediaStatus.DUPLICATE, status: AssetMediaStatus.DUPLICATE,
@@ -197,7 +201,11 @@ describe(AssetMediaService.name, () => {
}); });
it('should find an existing asset by base64', async () => { it('should find an existing asset by base64', async () => {
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue({
id: 'asset-id',
createdAt: new Date(),
status: AssetStatus.Active,
});
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({ await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({
id: 'asset-id', id: 'asset-id',
status: AssetMediaStatus.DUPLICATE, status: AssetMediaStatus.DUPLICATE,
@@ -359,7 +367,11 @@ describe(AssetMediaService.name, () => {
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT; (error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
mocks.asset.create.mockRejectedValue(error); mocks.asset.create.mockRejectedValue(error);
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id); mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue({
id: assetEntity.id,
createdAt: new Date(),
status: AssetStatus.Active,
});
await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({ await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({
id: 'id_1', id: 'id_1',
+8 -8
View File
@@ -49,12 +49,12 @@ export class AssetMediaService extends BaseService {
return; return;
} }
const assetId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum)); const asset = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum));
if (!assetId) { if (!asset) {
return; return;
} }
return { id: assetId, status: AssetMediaStatus.DUPLICATE }; return { id: asset.id, status: AssetMediaStatus.DUPLICATE };
} }
canUploadFile({ auth, fieldName, file, body }: UploadRequest): true { canUploadFile({ auth, fieldName, file, body }: UploadRequest): true {
@@ -203,18 +203,18 @@ export class AssetMediaService extends BaseService {
// handle duplicates with a success response // handle duplicates with a success response
if (isAssetChecksumConstraint(error)) { if (isAssetChecksumConstraint(error)) {
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum); const duplicate = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
if (!duplicateId) { if (!duplicate) {
this.logger.error(`Error locating duplicate for checksum constraint`); this.logger.error(`Error locating duplicate for checksum constraint`);
throw new InternalServerErrorException(); throw new InternalServerErrorException();
} }
if (auth.sharedLink) { if (auth.sharedLink) {
await this.addToSharedLink(auth.sharedLink, duplicateId); await this.addToSharedLink(auth.sharedLink, duplicate.id);
} }
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`); this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicate.id}`);
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId }; return { status: AssetMediaStatus.DUPLICATE, id: duplicate.id };
} }
this.logger.error(`Error uploading file ${error}`, error?.stack); this.logger.error(`Error uploading file ${error}`, error?.stack);
@@ -0,0 +1,456 @@
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));
});
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,
deviceAssetId: mockDto.assetData.deviceAssetId,
deviceId: mockDto.assetData.deviceId,
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);
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);
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);
});
});
});
+465
View File
@@ -0,0 +1,465 @@
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}`;
}
}
+2
View File
@@ -3,6 +3,7 @@ import { AlbumService } from 'src/services/album.service';
import { ApiKeyService } from 'src/services/api-key.service'; import { ApiKeyService } from 'src/services/api-key.service';
import { ApiService } from 'src/services/api.service'; import { ApiService } from 'src/services/api.service';
import { AssetMediaService } from 'src/services/asset-media.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 { AssetService } from 'src/services/asset.service';
import { AuthAdminService } from 'src/services/auth-admin.service'; import { AuthAdminService } from 'src/services/auth-admin.service';
import { AuthService } from 'src/services/auth.service'; import { AuthService } from 'src/services/auth.service';
@@ -56,6 +57,7 @@ export const services = [
AlbumService, AlbumService,
ApiService, ApiService,
AssetMediaService, AssetMediaService,
AssetUploadService,
AssetService, AssetService,
AuthService, AuthService,
AuthAdminService, AuthAdminService,
@@ -52,6 +52,9 @@ const updatedConfig = Object.freeze<SystemConfig>({
cronExpression: '0 02 * * *', cronExpression: '0 02 * * *',
keepLastAmount: 14, keepLastAmount: 14,
}, },
upload: {
maxAgeHours: 72,
},
}, },
ffmpeg: { ffmpeg: {
crf: 30, crf: 30,
@@ -149,6 +152,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
missingThumbnails: true, missingThumbnails: true,
generateMemories: true, generateMemories: true,
syncQuotaUsage: true, syncQuotaUsage: true,
removeStaleUploads: true,
}, },
reverseGeocoding: { reverseGeocoding: {
enabled: true, enabled: true,
+2
View File
@@ -239,8 +239,10 @@ export class UserService extends BaseService {
@OnEvent({ name: 'AssetCreate' }) @OnEvent({ name: 'AssetCreate' })
async onAssetCreate({ asset, file }: ArgOf<'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 }) @OnJob({ name: JobName.UserSyncUsage, queue: QueueName.BackgroundTask })
async handleUserSyncUsage(): Promise<JobStatus> { async handleUserSyncUsage(): Promise<JobStatus> {
+4 -4
View File
@@ -336,8 +336,7 @@ export interface IIntegrityUntrackedFilesJob {
export interface IIntegrityMissingFilesJob { export interface IIntegrityMissingFilesJob {
items: ({ path: string; reportId: string | null } & ( items: ({ path: string; reportId: string | null } & (
| { assetId: string; fileAssetId: null } { assetId: string; fileAssetId: null } | { assetId: null; fileAssetId: string }
| { assetId: null; fileAssetId: string }
))[]; ))[];
} }
@@ -433,6 +432,8 @@ export type JobItem =
| { name: JobName.PersonCleanup; data?: IBaseJob } | { name: JobName.PersonCleanup; data?: IBaseJob }
| { name: JobName.AssetDelete; data: IAssetDeleteJob } | { name: JobName.AssetDelete; data: IAssetDeleteJob }
| { name: JobName.AssetDeleteCheck; data?: IBaseJob } | { name: JobName.AssetDeleteCheck; data?: IBaseJob }
| { name: JobName.PartialAssetCleanup; data: IEntityJob }
| { name: JobName.PartialAssetCleanupQueueAll; data?: IBaseJob }
// Library Management // Library Management
| { name: JobName.LibrarySyncFiles; data: ILibraryFileJob } | { name: JobName.LibrarySyncFiles; data: ILibraryFileJob }
@@ -553,8 +554,7 @@ export interface MemoryData {
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string }; export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
export type SystemFlags = { mountChecks: Record<StorageFolder, boolean> }; export type SystemFlags = { mountChecks: Record<StorageFolder, boolean> };
export type MaintenanceModeState = export type MaintenanceModeState =
| { isMaintenanceMode: true; secret: string; action?: SetMaintenanceModeDto } { isMaintenanceMode: true; secret: string; action?: SetMaintenanceModeDto } | { isMaintenanceMode: false };
| { isMaintenanceMode: false };
export type MemoriesState = { export type MemoriesState = {
/** memories have already been created through this date */ /** memories have already been created through this date */
lastOnThisDayDate: string; lastOnThisDayDate: string;
+16
View File
@@ -11,6 +11,7 @@ import _ from 'lodash';
import { cleanupOpenApiDoc } from 'nestjs-zod'; import { cleanupOpenApiDoc } from 'nestjs-zod';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import picomatch from 'picomatch'; import picomatch from 'picomatch';
import parse from 'picomatch/lib/parse'; import parse from 'picomatch/lib/parse';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
@@ -349,3 +350,18 @@ export const globToSqlPattern = (glob: string) => {
export function clamp(value: number, min: number, max: number) { export function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value)); 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;
}
+10
View File
@@ -1,5 +1,7 @@
import { BadRequestException } from '@nestjs/common';
import { IncomingHttpHeaders } from 'node:http'; import { IncomingHttpHeaders } from 'node:http';
import { UAParser } from 'ua-parser-js'; import { UAParser } from 'ua-parser-js';
import z from 'zod';
export const fromChecksum = (checksum: string): Buffer => { export const fromChecksum = (checksum: string): Buffer => {
return Buffer.from(checksum, checksum.length === 28 ? 'base64' : 'hex'); return Buffer.from(checksum, checksum.length === 28 ? 'base64' : 'hex');
@@ -23,3 +25,11 @@ export const getUserAgentDetails = (headers: IncomingHttpHeaders) => {
appVersion, 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 BadRequestException(result.error.issues.map(({ message }) => message));
}
@@ -46,6 +46,10 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
upsertMetadata: vitest.fn(), upsertMetadata: vitest.fn(),
upsertBulkMetadata: vitest.fn(), upsertBulkMetadata: vitest.fn(),
deleteMetadataByKey: vitest.fn(), deleteMetadataByKey: vitest.fn(),
getCompletionMetadata: vitest.fn(),
createWithMetadata: vitest.fn(),
removeAndDecrementQuota: vitest.fn(),
setComplete: vitest.fn(),
deleteBulkMetadata: vitest.fn(), deleteBulkMetadata: vitest.fn(),
getForOriginal: vitest.fn(), getForOriginal: vitest.fn(),
getForOriginals: vitest.fn(), getForOriginals: vitest.fn(),
@@ -57,6 +57,7 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
readdirWithTypes: vitest.fn(), readdirWithTypes: vitest.fn(),
createFile: vitest.fn(), createFile: vitest.fn(),
createWriteStream: vitest.fn(), createWriteStream: vitest.fn(),
createOrAppendWriteStream: vitest.fn(),
createOrOverwriteFile: vitest.fn(), createOrOverwriteFile: vitest.fn(),
existsSync: vitest.fn(), existsSync: vitest.fn(),
overwriteFile: vitest.fn(), overwriteFile: vitest.fn(),
@@ -64,6 +65,7 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
unlinkDir: vitest.fn().mockResolvedValue(true), unlinkDir: vitest.fn().mockResolvedValue(true),
removeEmptyDirs: vitest.fn(), removeEmptyDirs: vitest.fn(),
checkFileExists: vitest.fn(), checkFileExists: vitest.fn(),
mkdir: vitest.fn(),
mkdirSync: vitest.fn(), mkdirSync: vitest.fn(),
checkDiskUsage: vitest.fn(), checkDiskUsage: vitest.fn(),
readdir: vitest.fn(), readdir: vitest.fn(),
+1
View File
@@ -293,6 +293,7 @@ export const getMocks = () => {
const databaseMock = automock(DatabaseRepository, { args: [, loggerMock], strict: false }); const databaseMock = automock(DatabaseRepository, { args: [, loggerMock], strict: false });
databaseMock.withLock.mockImplementation((_type, fn) => fn()); 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.getPostgresVersion = vitest.fn().mockResolvedValue('14.10 (Debian 14.10-1.pgdg120+1)');
databaseMock.getPostgresVersionRange = vitest.fn().mockReturnValue('>=14.0.0'); databaseMock.getPostgresVersionRange = vitest.fn().mockReturnValue('>=14.0.0');
databaseMock.createExtension = vitest.fn().mockResolvedValue(void 0); databaseMock.createExtension = vitest.fn().mockResolvedValue(void 0);
@@ -8,17 +8,7 @@ import { normalizeTransformEdits } from '$lib/utils/editor';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
export type CropAspectRatio = export type CropAspectRatio =
| '1:1' '1:1' | '16:9' | '4:3' | '3:2' | '7:5' | '9:16' | '3:4' | '2:3' | '5:7' | 'free' | 'reset';
| '16:9'
| '4:3'
| '3:2'
| '7:5'
| '9:16'
| '3:4'
| '2:3'
| '5:7'
| 'free'
| 'reset';
type Region = { type Region = {
x: number; x: number;
+7 -7
View File
@@ -1,11 +1,11 @@
export const enum ByteUnit { export const enum ByteUnit {
'B' = 'B', B = 'B',
'KiB' = 'KiB', KiB = 'KiB',
'MiB' = 'MiB', MiB = 'MiB',
'GiB' = 'GiB', GiB = 'GiB',
'TiB' = 'TiB', TiB = 'TiB',
'PiB' = 'PiB', PiB = 'PiB',
'EiB' = 'EiB', EiB = 'EiB',
} }
const byteUnits = [ByteUnit.B, ByteUnit.KiB, ByteUnit.MiB, ByteUnit.GiB, ByteUnit.TiB, ByteUnit.PiB, ByteUnit.EiB]; const byteUnits = [ByteUnit.B, ByteUnit.KiB, ByteUnit.MiB, ByteUnit.GiB, ByteUnit.TiB, ByteUnit.PiB, ByteUnit.EiB];
@@ -6,7 +6,7 @@
import { Field, FormModal, HelperText, Select } from '@immich/ui'; import { Field, FormModal, HelperText, Select } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js'; import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { type PageData } from './$types'; import type { PageData } from './$types';
type Props = { type Props = {
data: PageData; data: PageData;
@@ -63,6 +63,5 @@
{required} {required}
{value} {value}
oninput={handleInput} oninput={handleInput}
{disabled} {disabled}></textarea>
></textarea>
</div> </div>
+1 -1
View File
@@ -13,7 +13,7 @@ const config = {
// TODO pending `@immich/ui` to enable it // TODO pending `@immich/ui` to enable it
// runes: true, // runes: true,
}, },
preprocess: vitePreprocess(), preprocess: vitePreprocess({ script: true }),
kit: { kit: {
version: { version: {
name: process.env.IMMICH_BUILD || process.env.npm_package_version || 'local', name: process.env.IMMICH_BUILD || process.env.npm_package_version || 'local',