put(Isar db) async {
@@ -379,6 +402,8 @@ class Asset {
"checksum": "$checksum",
"ownerId": $ownerId,
"livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
+ "stackCount": "$stackCount",
+ "stackParentId": "${stackParentId ?? "N/A"}",
"fileCreatedAt": "$fileCreatedAt",
"fileModifiedAt": "$fileModifiedAt",
"updatedAt": "$updatedAt",
diff --git a/mobile/lib/shared/models/asset.g.dart b/mobile/lib/shared/models/asset.g.dart
index f06e556ea..4f485dfb0 100644
--- a/mobile/lib/shared/models/asset.g.dart
+++ b/mobile/lib/shared/models/asset.g.dart
@@ -82,19 +82,29 @@ const AssetSchema = CollectionSchema(
name: r'remoteId',
type: IsarType.string,
),
- r'type': PropertySchema(
+ r'stackCount': PropertySchema(
id: 13,
+ name: r'stackCount',
+ type: IsarType.long,
+ ),
+ r'stackParentId': PropertySchema(
+ id: 14,
+ name: r'stackParentId',
+ type: IsarType.string,
+ ),
+ r'type': PropertySchema(
+ id: 15,
name: r'type',
type: IsarType.byte,
enumMap: _AssettypeEnumValueMap,
),
r'updatedAt': PropertySchema(
- id: 14,
+ id: 16,
name: r'updatedAt',
type: IsarType.dateTime,
),
r'width': PropertySchema(
- id: 15,
+ id: 17,
name: r'width',
type: IsarType.int,
)
@@ -184,6 +194,12 @@ int _assetEstimateSize(
bytesCount += 3 + value.length * 3;
}
}
+ {
+ final value = object.stackParentId;
+ if (value != null) {
+ bytesCount += 3 + value.length * 3;
+ }
+ }
return bytesCount;
}
@@ -206,9 +222,11 @@ void _assetSerialize(
writer.writeString(offsets[10], object.localId);
writer.writeLong(offsets[11], object.ownerId);
writer.writeString(offsets[12], object.remoteId);
- writer.writeByte(offsets[13], object.type.index);
- writer.writeDateTime(offsets[14], object.updatedAt);
- writer.writeInt(offsets[15], object.width);
+ writer.writeLong(offsets[13], object.stackCount);
+ writer.writeString(offsets[14], object.stackParentId);
+ writer.writeByte(offsets[15], object.type.index);
+ writer.writeDateTime(offsets[16], object.updatedAt);
+ writer.writeInt(offsets[17], object.width);
}
Asset _assetDeserialize(
@@ -232,10 +250,12 @@ Asset _assetDeserialize(
localId: reader.readStringOrNull(offsets[10]),
ownerId: reader.readLong(offsets[11]),
remoteId: reader.readStringOrNull(offsets[12]),
- type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[13])] ??
+ stackCount: reader.readLong(offsets[13]),
+ stackParentId: reader.readStringOrNull(offsets[14]),
+ type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[15])] ??
AssetType.other,
- updatedAt: reader.readDateTime(offsets[14]),
- width: reader.readIntOrNull(offsets[15]),
+ updatedAt: reader.readDateTime(offsets[16]),
+ width: reader.readIntOrNull(offsets[17]),
);
return object;
}
@@ -274,11 +294,15 @@ P _assetDeserializeProp(
case 12:
return (reader.readStringOrNull(offset)) as P;
case 13:
+ return (reader.readLong(offset)) as P;
+ case 14:
+ return (reader.readStringOrNull(offset)) as P;
+ case 15:
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
AssetType.other) as P;
- case 14:
+ case 16:
return (reader.readDateTime(offset)) as P;
- case 15:
+ case 17:
return (reader.readIntOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
@@ -1801,6 +1825,205 @@ extension AssetQueryFilter on QueryBuilder {
});
}
+ QueryBuilder stackCountEqualTo(
+ int value) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.equalTo(
+ property: r'stackCount',
+ value: value,
+ ));
+ });
+ }
+
+ QueryBuilder stackCountGreaterThan(
+ int value, {
+ bool include = false,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.greaterThan(
+ include: include,
+ property: r'stackCount',
+ value: value,
+ ));
+ });
+ }
+
+ QueryBuilder stackCountLessThan(
+ int value, {
+ bool include = false,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.lessThan(
+ include: include,
+ property: r'stackCount',
+ value: value,
+ ));
+ });
+ }
+
+ QueryBuilder stackCountBetween(
+ int lower,
+ int upper, {
+ bool includeLower = true,
+ bool includeUpper = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.between(
+ property: r'stackCount',
+ lower: lower,
+ includeLower: includeLower,
+ upper: upper,
+ includeUpper: includeUpper,
+ ));
+ });
+ }
+
+ QueryBuilder stackParentIdIsNull() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(const FilterCondition.isNull(
+ property: r'stackParentId',
+ ));
+ });
+ }
+
+ QueryBuilder stackParentIdIsNotNull() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(const FilterCondition.isNotNull(
+ property: r'stackParentId',
+ ));
+ });
+ }
+
+ QueryBuilder stackParentIdEqualTo(
+ String? value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.equalTo(
+ property: r'stackParentId',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder stackParentIdGreaterThan(
+ String? value, {
+ bool include = false,
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.greaterThan(
+ include: include,
+ property: r'stackParentId',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder stackParentIdLessThan(
+ String? value, {
+ bool include = false,
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.lessThan(
+ include: include,
+ property: r'stackParentId',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder stackParentIdBetween(
+ String? lower,
+ String? upper, {
+ bool includeLower = true,
+ bool includeUpper = true,
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.between(
+ property: r'stackParentId',
+ lower: lower,
+ includeLower: includeLower,
+ upper: upper,
+ includeUpper: includeUpper,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder stackParentIdStartsWith(
+ String value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.startsWith(
+ property: r'stackParentId',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder stackParentIdEndsWith(
+ String value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.endsWith(
+ property: r'stackParentId',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder stackParentIdContains(
+ String value,
+ {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.contains(
+ property: r'stackParentId',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder stackParentIdMatches(
+ String pattern,
+ {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.matches(
+ property: r'stackParentId',
+ wildcard: pattern,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder stackParentIdIsEmpty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.equalTo(
+ property: r'stackParentId',
+ value: '',
+ ));
+ });
+ }
+
+ QueryBuilder stackParentIdIsNotEmpty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.greaterThan(
+ property: r'stackParentId',
+ value: '',
+ ));
+ });
+ }
+
QueryBuilder typeEqualTo(
AssetType value) {
return QueryBuilder.apply(this, (query) {
@@ -2137,6 +2360,30 @@ extension AssetQuerySortBy on QueryBuilder {
});
}
+ QueryBuilder sortByStackCount() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'stackCount', Sort.asc);
+ });
+ }
+
+ QueryBuilder sortByStackCountDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'stackCount', Sort.desc);
+ });
+ }
+
+ QueryBuilder sortByStackParentId() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'stackParentId', Sort.asc);
+ });
+ }
+
+ QueryBuilder sortByStackParentIdDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'stackParentId', Sort.desc);
+ });
+ }
+
QueryBuilder sortByType() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'type', Sort.asc);
@@ -2343,6 +2590,30 @@ extension AssetQuerySortThenBy on QueryBuilder {
});
}
+ QueryBuilder thenByStackCount() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'stackCount', Sort.asc);
+ });
+ }
+
+ QueryBuilder thenByStackCountDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'stackCount', Sort.desc);
+ });
+ }
+
+ QueryBuilder thenByStackParentId() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'stackParentId', Sort.asc);
+ });
+ }
+
+ QueryBuilder thenByStackParentIdDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'stackParentId', Sort.desc);
+ });
+ }
+
QueryBuilder thenByType() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'type', Sort.asc);
@@ -2465,6 +2736,20 @@ extension AssetQueryWhereDistinct on QueryBuilder {
});
}
+ QueryBuilder distinctByStackCount() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addDistinctBy(r'stackCount');
+ });
+ }
+
+ QueryBuilder distinctByStackParentId(
+ {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addDistinctBy(r'stackParentId',
+ caseSensitive: caseSensitive);
+ });
+ }
+
QueryBuilder distinctByType() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'type');
@@ -2569,6 +2854,18 @@ extension AssetQueryProperty on QueryBuilder {
});
}
+ QueryBuilder stackCountProperty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addPropertyName(r'stackCount');
+ });
+ }
+
+ QueryBuilder stackParentIdProperty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addPropertyName(r'stackParentId');
+ });
+ }
+
QueryBuilder typeProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'type');
diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart
index 8bffa38a0..78c1c5968 100644
--- a/mobile/lib/shared/providers/asset.provider.dart
+++ b/mobile/lib/shared/providers/asset.provider.dart
@@ -5,6 +5,7 @@ import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
+import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/services/asset.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
@@ -254,6 +255,7 @@ final assetsProvider =
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
+ .stackParentIdIsNull()
.sortByFileCreatedAtDesc();
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =
@@ -264,10 +266,12 @@ final assetsProvider =
}
});
-final remoteAssetsProvider =
- StreamProvider.family((ref, userId) async* {
- if (userId == null) return;
- final query = ref
+QueryBuilder? getRemoteAssetQuery(WidgetRef ref) {
+ final userId = ref.watch(currentUserProvider)?.isarId;
+ if (userId == null) {
+ return null;
+ }
+ return ref
.watch(dbProvider)
.assets
.where()
@@ -275,12 +279,34 @@ final remoteAssetsProvider =
.filter()
.ownerIdEqualTo(userId)
.isTrashedEqualTo(false)
+ .stackParentIdIsNull()
.sortByFileCreatedAtDesc();
- final settings = ref.watch(appSettingsServiceProvider);
- final groupBy =
- GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
- yield await RenderList.fromQuery(query, groupBy);
- await for (final _ in query.watchLazy()) {
- yield await RenderList.fromQuery(query, groupBy);
+}
+
+QueryBuilder? getAssetStackSelectionQuery(
+ WidgetRef ref,
+ Asset parentAsset,
+) {
+ final userId = ref.watch(currentUserProvider)?.isarId;
+ if (userId == null || !parentAsset.isRemote) {
+ return null;
}
-});
+ return ref
+ .watch(dbProvider)
+ .assets
+ .where()
+ .remoteIdIsNotNull()
+ .filter()
+ .isArchivedEqualTo(false)
+ .ownerIdEqualTo(userId)
+ .not()
+ .remoteIdEqualTo(parentAsset.remoteId)
+ // Show existing stack children in selection page
+ .group(
+ (q) => q
+ .stackParentIdIsNull()
+ .or()
+ .stackParentIdEqualTo(parentAsset.remoteId),
+ )
+ .sortByFileCreatedAtDesc();
+}
diff --git a/mobile/lib/shared/providers/websocket.provider.dart b/mobile/lib/shared/providers/websocket.provider.dart
index ed529a6d8..1dda262f5 100644
--- a/mobile/lib/shared/providers/websocket.provider.dart
+++ b/mobile/lib/shared/providers/websocket.provider.dart
@@ -133,6 +133,7 @@ class WebsocketNotifier extends StateNotifier {
socket.on('on_asset_delete', _handleOnAssetDelete);
socket.on('on_asset_trash', _handleServerUpdates);
socket.on('on_asset_restore', _handleServerUpdates);
+ socket.on('on_asset_update', _handleServerUpdates);
} catch (e) {
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
}
diff --git a/mobile/lib/shared/services/api.service.dart b/mobile/lib/shared/services/api.service.dart
index b19860cc8..7c1dfc8fc 100644
--- a/mobile/lib/shared/services/api.service.dart
+++ b/mobile/lib/shared/services/api.service.dart
@@ -21,6 +21,7 @@ class ApiService {
late PartnerApi partnerApi;
late PersonApi personApi;
late AuditApi auditApi;
+ late SharedLinkApi sharedLinkApi;
ApiService() {
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
@@ -45,6 +46,7 @@ class ApiService {
partnerApi = PartnerApi(_apiClient);
personApi = PersonApi(_apiClient);
auditApi = AuditApi(_apiClient);
+ sharedLinkApi = SharedLinkApi(_apiClient);
}
Future resolveAndSetEndpoint(String serverUrl) async {
diff --git a/mobile/lib/shared/services/share.service.dart b/mobile/lib/shared/services/share.service.dart
index df8f138fb..ab3a096ae 100644
--- a/mobile/lib/shared/services/share.service.dart
+++ b/mobile/lib/shared/services/share.service.dart
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
+import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'api.service.dart';
@@ -13,32 +14,60 @@ final shareServiceProvider =
class ShareService {
final ApiService _apiService;
+ final Logger _log = Logger("ShareService");
ShareService(this._apiService);
- Future shareAsset(Asset asset) async {
- await shareAssets([asset]);
+ Future shareAsset(Asset asset) async {
+ return await shareAssets([asset]);
}
- Future shareAssets(List assets) async {
- final downloadedXFiles = assets.map>((asset) async {
- if (asset.isRemote) {
- final tempDir = await getTemporaryDirectory();
- final fileName = asset.fileName;
- final tempFile = await File('${tempDir.path}/$fileName').create();
- final res = await _apiService.assetApi
- .downloadFileWithHttpInfo(asset.remoteId!);
- tempFile.writeAsBytesSync(res.bodyBytes);
- return XFile(tempFile.path);
- } else {
- File? f = await asset.local!.file;
- return XFile(f!.path);
- }
- });
+ Future shareAssets(List assets) async {
+ try {
+ final downloadedXFiles = [];
- Share.shareXFiles(
- await Future.wait(downloadedXFiles),
- sharePositionOrigin: Rect.zero,
- );
+ for (var asset in assets) {
+ if (asset.isRemote) {
+ final tempDir = await getTemporaryDirectory();
+ final fileName = asset.fileName;
+ final tempFile = await File('${tempDir.path}/$fileName').create();
+ final res = await _apiService.assetApi
+ .downloadFileWithHttpInfo(asset.remoteId!);
+
+ if (res.statusCode != 200) {
+ _log.severe(
+ "Asset download failed with status - ${res.statusCode} and response - ${res.body}",
+ );
+ continue;
+ }
+
+ tempFile.writeAsBytesSync(res.bodyBytes);
+ downloadedXFiles.add(XFile(tempFile.path));
+ } else {
+ File? f = await asset.local!.file;
+ downloadedXFiles.add(XFile(f!.path));
+ }
+ }
+
+ if (downloadedXFiles.isEmpty) {
+ _log.warning("No asset can be retrieved for share");
+ return false;
+ }
+
+ if (downloadedXFiles.length != assets.length) {
+ _log.warning(
+ "Partial share - Requested: ${assets.length}, Sharing: ${downloadedXFiles.length}",
+ );
+ }
+
+ Share.shareXFiles(
+ downloadedXFiles,
+ sharePositionOrigin: Rect.zero,
+ );
+ return true;
+ } catch (error) {
+ _log.severe("Share failed with error $error");
+ }
+ return false;
}
}
diff --git a/mobile/lib/utils/immich_app_theme.dart b/mobile/lib/utils/immich_app_theme.dart
index 5eb355173..670a7660d 100644
--- a/mobile/lib/utils/immich_app_theme.dart
+++ b/mobile/lib/utils/immich_app_theme.dart
@@ -41,7 +41,12 @@ ThemeData immichLightTheme = ThemeData(
fontFamily: 'WorkSans',
scaffoldBackgroundColor: immichBackgroundColor,
snackBarTheme: const SnackBarThemeData(
- contentTextStyle: TextStyle(fontFamily: 'WorkSans'),
+ contentTextStyle: TextStyle(
+ fontFamily: 'WorkSans',
+ color: Colors.indigo,
+ fontWeight: FontWeight.bold,
+ ),
+ backgroundColor: Colors.white,
),
appBarTheme: AppBarTheme(
titleTextStyle: const TextStyle(
@@ -156,8 +161,13 @@ ThemeData immichDarkTheme = ThemeData(
scaffoldBackgroundColor: immichDarkBackgroundColor,
hintColor: Colors.grey[600],
fontFamily: 'WorkSans',
- snackBarTheme: const SnackBarThemeData(
- contentTextStyle: TextStyle(fontFamily: 'WorkSans'),
+ snackBarTheme: SnackBarThemeData(
+ contentTextStyle: TextStyle(
+ fontFamily: 'WorkSans',
+ color: immichDarkThemePrimaryColor,
+ fontWeight: FontWeight.bold,
+ ),
+ backgroundColor: Colors.grey[900],
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
diff --git a/mobile/lib/utils/selection_handlers.dart b/mobile/lib/utils/selection_handlers.dart
index 08de7961b..511dcf81e 100644
--- a/mobile/lib/utils/selection_handlers.dart
+++ b/mobile/lib/utils/selection_handlers.dart
@@ -1,3 +1,4 @@
+import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -15,10 +16,19 @@ void handleShareAssets(
showDialog(
context: context,
builder: (BuildContext buildContext) {
- ref
- .watch(shareServiceProvider)
- .shareAssets(selection.toList())
- .then((_) => Navigator.of(buildContext).pop());
+ ref.watch(shareServiceProvider).shareAssets(selection.toList()).then(
+ (bool status) {
+ if (!status) {
+ ImmichToast.show(
+ context: context,
+ msg: 'image_viewer_page_state_provider_share_error'.tr(),
+ toastType: ToastType.error,
+ gravity: ToastGravity.BOTTOM,
+ );
+ }
+ Navigator.of(buildContext).pop();
+ },
+ );
return const ShareDialog();
},
barrierDismissible: false,
diff --git a/mobile/lib/utils/url_helper.dart b/mobile/lib/utils/url_helper.dart
index 66ce723a9..b771a6f70 100644
--- a/mobile/lib/utils/url_helper.dart
+++ b/mobile/lib/utils/url_helper.dart
@@ -1,3 +1,5 @@
+import 'package:immich_mobile/shared/models/store.dart';
+
String sanitizeUrl(String url) {
// Add schema if none is set
final urlWithSchema =
@@ -6,3 +8,15 @@ String sanitizeUrl(String url) {
// Remove trailing slash(es)
return urlWithSchema.replaceFirst(RegExp(r"/+$"), "");
}
+
+String? getServerUrl() {
+ final serverUrl = Store.tryGet(StoreKey.serverEndpoint);
+ final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
+ if (serverUri == null) {
+ return null;
+ }
+
+ return serverUri.hasPort
+ ? "${serverUri.scheme}://${serverUri.host}:${serverUri.port}"
+ : "${serverUri.scheme}://${serverUri.host}";
+}
diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES
index bf699a313..85b96e647 100644
--- a/mobile/openapi/.openapi-generator/FILES
+++ b/mobile/openapi/.openapi-generator/FILES
@@ -149,6 +149,7 @@ doc/TranscodePolicy.md
doc/UpdateAlbumDto.md
doc/UpdateAssetDto.md
doc/UpdateLibraryDto.md
+doc/UpdateStackParentDto.md
doc/UpdateTagDto.md
doc/UpdateUserDto.md
doc/UsageByUserDto.md
@@ -314,6 +315,7 @@ lib/model/transcode_policy.dart
lib/model/update_album_dto.dart
lib/model/update_asset_dto.dart
lib/model/update_library_dto.dart
+lib/model/update_stack_parent_dto.dart
lib/model/update_tag_dto.dart
lib/model/update_user_dto.dart
lib/model/usage_by_user_dto.dart
@@ -468,6 +470,7 @@ test/transcode_policy_test.dart
test/update_album_dto_test.dart
test/update_asset_dto_test.dart
test/update_library_dto_test.dart
+test/update_stack_parent_dto_test.dart
test/update_tag_dto_test.dart
test/update_user_dto_test.dart
test/usage_by_user_dto_test.dart
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 8ccdc36a4..47d04b9bd 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
-- API version: 1.82.0
+- API version: 1.82.1
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@@ -116,6 +116,7 @@ Class | Method | HTTP request | Description
*AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} |
*AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} |
*AssetApi* | [**updateAssets**](doc//AssetApi.md#updateassets) | **PUT** /asset |
+*AssetApi* | [**updateStackParent**](doc//AssetApi.md#updatestackparent) | **PUT** /asset/stack/parent |
*AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload |
*AuditApi* | [**fixAuditFiles**](doc//AuditApi.md#fixauditfiles) | **POST** /audit/file-report/fix |
*AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes |
@@ -330,6 +331,7 @@ Class | Method | HTTP request | Description
- [UpdateAlbumDto](doc//UpdateAlbumDto.md)
- [UpdateAssetDto](doc//UpdateAssetDto.md)
- [UpdateLibraryDto](doc//UpdateLibraryDto.md)
+ - [UpdateStackParentDto](doc//UpdateStackParentDto.md)
- [UpdateTagDto](doc//UpdateTagDto.md)
- [UpdateUserDto](doc//UpdateUserDto.md)
- [UsageByUserDto](doc//UsageByUserDto.md)
diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md
index 30914b10c..075c32bd9 100644
--- a/mobile/openapi/doc/AssetApi.md
+++ b/mobile/openapi/doc/AssetApi.md
@@ -38,6 +38,7 @@ Method | HTTP request | Description
[**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} |
[**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} |
[**updateAssets**](AssetApi.md#updateassets) | **PUT** /asset |
+[**updateStackParent**](AssetApi.md#updatestackparent) | **PUT** /asset/stack/parent |
[**uploadFile**](AssetApi.md#uploadfile) | **POST** /asset/upload |
@@ -1696,6 +1697,60 @@ void (empty response body)
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+# **updateStackParent**
+> updateStackParent(updateStackParentDto)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = AssetApi();
+final updateStackParentDto = UpdateStackParentDto(); // UpdateStackParentDto |
+
+try {
+ api_instance.updateStackParent(updateStackParentDto);
+} catch (e) {
+ print('Exception when calling AssetApi->updateStackParent: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description | Notes
+------------- | ------------- | ------------- | -------------
+ **updateStackParentDto** | [**UpdateStackParentDto**](UpdateStackParentDto.md)| |
+
+### Return type
+
+void (empty response body)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **Accept**: Not defined
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
# **uploadFile**
> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData)
diff --git a/mobile/openapi/doc/AssetBulkUpdateDto.md b/mobile/openapi/doc/AssetBulkUpdateDto.md
index b48268464..74fd5ec45 100644
--- a/mobile/openapi/doc/AssetBulkUpdateDto.md
+++ b/mobile/openapi/doc/AssetBulkUpdateDto.md
@@ -11,6 +11,8 @@ Name | Type | Description | Notes
**ids** | **List** | | [default to const []]
**isArchived** | **bool** | | [optional]
**isFavorite** | **bool** | | [optional]
+**removeParent** | **bool** | | [optional]
+**stackParentId** | **String** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
diff --git a/mobile/openapi/doc/AssetResponseDto.md b/mobile/openapi/doc/AssetResponseDto.md
index a08be71ac..8c4d1db4a 100644
--- a/mobile/openapi/doc/AssetResponseDto.md
+++ b/mobile/openapi/doc/AssetResponseDto.md
@@ -33,6 +33,9 @@ Name | Type | Description | Notes
**people** | [**List**](PersonResponseDto.md) | | [optional] [default to const []]
**resized** | **bool** | |
**smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional]
+**stack** | [**List**](AssetResponseDto.md) | | [optional] [default to const []]
+**stackCount** | **int** | |
+**stackParentId** | **String** | | [optional]
**tags** | [**List**](TagResponseDto.md) | | [optional] [default to const []]
**thumbhash** | **String** | |
**type** | [**AssetTypeEnum**](AssetTypeEnum.md) | |
diff --git a/mobile/openapi/doc/SharedLinkEditDto.md b/mobile/openapi/doc/SharedLinkEditDto.md
index f035e23c6..ccd0d3b54 100644
--- a/mobile/openapi/doc/SharedLinkEditDto.md
+++ b/mobile/openapi/doc/SharedLinkEditDto.md
@@ -10,6 +10,7 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**allowDownload** | **bool** | | [optional]
**allowUpload** | **bool** | | [optional]
+**changeExpiryTime** | **bool** | Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this. | [optional]
**description** | **String** | | [optional]
**expiresAt** | [**DateTime**](DateTime.md) | | [optional]
**showMetadata** | **bool** | | [optional]
diff --git a/mobile/openapi/doc/UpdateStackParentDto.md b/mobile/openapi/doc/UpdateStackParentDto.md
new file mode 100644
index 000000000..750daace0
--- /dev/null
+++ b/mobile/openapi/doc/UpdateStackParentDto.md
@@ -0,0 +1,16 @@
+# openapi.model.UpdateStackParentDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**newParentId** | **String** | |
+**oldParentId** | **String** | |
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 48745a162..e72c1da16 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -176,6 +176,7 @@ part 'model/transcode_policy.dart';
part 'model/update_album_dto.dart';
part 'model/update_asset_dto.dart';
part 'model/update_library_dto.dart';
+part 'model/update_stack_parent_dto.dart';
part 'model/update_tag_dto.dart';
part 'model/update_user_dto.dart';
part 'model/usage_by_user_dto.dart';
diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart
index 91429ce7e..5935f5676 100644
--- a/mobile/openapi/lib/api/asset_api.dart
+++ b/mobile/openapi/lib/api/asset_api.dart
@@ -1654,6 +1654,45 @@ class AssetApi {
}
}
+ /// Performs an HTTP 'PUT /asset/stack/parent' operation and returns the [Response].
+ /// Parameters:
+ ///
+ /// * [UpdateStackParentDto] updateStackParentDto (required):
+ Future updateStackParentWithHttpInfo(UpdateStackParentDto updateStackParentDto,) async {
+ // ignore: prefer_const_declarations
+ final path = r'/asset/stack/parent';
+
+ // ignore: prefer_final_locals
+ Object? postBody = updateStackParentDto;
+
+ final queryParams = [];
+ final headerParams = {};
+ final formParams = {};
+
+ const contentTypes = ['application/json'];
+
+
+ return apiClient.invokeAPI(
+ path,
+ 'PUT',
+ queryParams,
+ postBody,
+ headerParams,
+ formParams,
+ contentTypes.isEmpty ? null : contentTypes.first,
+ );
+ }
+
+ /// Parameters:
+ ///
+ /// * [UpdateStackParentDto] updateStackParentDto (required):
+ Future updateStackParent(UpdateStackParentDto updateStackParentDto,) async {
+ final response = await updateStackParentWithHttpInfo(updateStackParentDto,);
+ if (response.statusCode >= HttpStatus.badRequest) {
+ throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+ }
+ }
+
/// Performs an HTTP 'POST /asset/upload' operation and returns the [Response].
/// Parameters:
///
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 9a98b4997..34b9a431d 100644
--- a/mobile/openapi/lib/api_client.dart
+++ b/mobile/openapi/lib/api_client.dart
@@ -443,6 +443,8 @@ class ApiClient {
return UpdateAssetDto.fromJson(value);
case 'UpdateLibraryDto':
return UpdateLibraryDto.fromJson(value);
+ case 'UpdateStackParentDto':
+ return UpdateStackParentDto.fromJson(value);
case 'UpdateTagDto':
return UpdateTagDto.fromJson(value);
case 'UpdateUserDto':
diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart
index 7eb0e31af..64c8d1e7e 100644
--- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart
+++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart
@@ -16,6 +16,8 @@ class AssetBulkUpdateDto {
this.ids = const [],
this.isArchived,
this.isFavorite,
+ this.removeParent,
+ this.stackParentId,
});
List ids;
@@ -36,21 +38,41 @@ class AssetBulkUpdateDto {
///
bool? isFavorite;
+ ///
+ /// Please note: This property should have been non-nullable! Since the specification file
+ /// does not include a default value (using the "default:" property), however, the generated
+ /// source code must fall back to having a nullable type.
+ /// Consider adding a "default:" property in the specification file to hide this note.
+ ///
+ bool? removeParent;
+
+ ///
+ /// Please note: This property should have been non-nullable! Since the specification file
+ /// does not include a default value (using the "default:" property), however, the generated
+ /// source code must fall back to having a nullable type.
+ /// Consider adding a "default:" property in the specification file to hide this note.
+ ///
+ String? stackParentId;
+
@override
bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
other.ids == ids &&
other.isArchived == isArchived &&
- other.isFavorite == isFavorite;
+ other.isFavorite == isFavorite &&
+ other.removeParent == removeParent &&
+ other.stackParentId == stackParentId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(ids.hashCode) +
(isArchived == null ? 0 : isArchived!.hashCode) +
- (isFavorite == null ? 0 : isFavorite!.hashCode);
+ (isFavorite == null ? 0 : isFavorite!.hashCode) +
+ (removeParent == null ? 0 : removeParent!.hashCode) +
+ (stackParentId == null ? 0 : stackParentId!.hashCode);
@override
- String toString() => 'AssetBulkUpdateDto[ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite]';
+ String toString() => 'AssetBulkUpdateDto[ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, removeParent=$removeParent, stackParentId=$stackParentId]';
Map toJson() {
final json = {};
@@ -65,6 +87,16 @@ class AssetBulkUpdateDto {
} else {
// json[r'isFavorite'] = null;
}
+ if (this.removeParent != null) {
+ json[r'removeParent'] = this.removeParent;
+ } else {
+ // json[r'removeParent'] = null;
+ }
+ if (this.stackParentId != null) {
+ json[r'stackParentId'] = this.stackParentId;
+ } else {
+ // json[r'stackParentId'] = null;
+ }
return json;
}
@@ -81,6 +113,8 @@ class AssetBulkUpdateDto {
: const [],
isArchived: mapValueOfType(json, r'isArchived'),
isFavorite: mapValueOfType(json, r'isFavorite'),
+ removeParent: mapValueOfType(json, r'removeParent'),
+ stackParentId: mapValueOfType(json, r'stackParentId'),
);
}
return null;
diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart
index b2feb0ee8..e580ca5a2 100644
--- a/mobile/openapi/lib/model/asset_response_dto.dart
+++ b/mobile/openapi/lib/model/asset_response_dto.dart
@@ -38,6 +38,9 @@ class AssetResponseDto {
this.people = const [],
required this.resized,
this.smartInfo,
+ this.stack = const [],
+ required this.stackCount,
+ this.stackParentId,
this.tags = const [],
required this.thumbhash,
required this.type,
@@ -113,6 +116,12 @@ class AssetResponseDto {
///
SmartInfoResponseDto? smartInfo;
+ List stack;
+
+ int stackCount;
+
+ String? stackParentId;
+
List tags;
String? thumbhash;
@@ -148,6 +157,9 @@ class AssetResponseDto {
other.people == people &&
other.resized == resized &&
other.smartInfo == smartInfo &&
+ other.stack == stack &&
+ other.stackCount == stackCount &&
+ other.stackParentId == stackParentId &&
other.tags == tags &&
other.thumbhash == thumbhash &&
other.type == type &&
@@ -181,13 +193,16 @@ class AssetResponseDto {
(people.hashCode) +
(resized.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode) +
+ (stack.hashCode) +
+ (stackCount.hashCode) +
+ (stackParentId == null ? 0 : stackParentId!.hashCode) +
(tags.hashCode) +
(thumbhash == null ? 0 : thumbhash!.hashCode) +
(type.hashCode) +
(updatedAt.hashCode);
@override
- String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
+ String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, stackCount=$stackCount, stackParentId=$stackParentId, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
Map toJson() {
final json = {};
@@ -231,6 +246,13 @@ class AssetResponseDto {
json[r'smartInfo'] = this.smartInfo;
} else {
// json[r'smartInfo'] = null;
+ }
+ json[r'stack'] = this.stack;
+ json[r'stackCount'] = this.stackCount;
+ if (this.stackParentId != null) {
+ json[r'stackParentId'] = this.stackParentId;
+ } else {
+ // json[r'stackParentId'] = null;
}
json[r'tags'] = this.tags;
if (this.thumbhash != null) {
@@ -276,6 +298,9 @@ class AssetResponseDto {
people: PersonResponseDto.listFromJson(json[r'people']),
resized: mapValueOfType(json, r'resized')!,
smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
+ stack: AssetResponseDto.listFromJson(json[r'stack']),
+ stackCount: mapValueOfType(json, r'stackCount')!,
+ stackParentId: mapValueOfType(json, r'stackParentId'),
tags: TagResponseDto.listFromJson(json[r'tags']),
thumbhash: mapValueOfType(json, r'thumbhash'),
type: AssetTypeEnum.fromJson(json[r'type'])!,
@@ -347,6 +372,7 @@ class AssetResponseDto {
'originalPath',
'ownerId',
'resized',
+ 'stackCount',
'thumbhash',
'type',
'updatedAt',
diff --git a/mobile/openapi/lib/model/shared_link_edit_dto.dart b/mobile/openapi/lib/model/shared_link_edit_dto.dart
index 6b72e025d..108734999 100644
--- a/mobile/openapi/lib/model/shared_link_edit_dto.dart
+++ b/mobile/openapi/lib/model/shared_link_edit_dto.dart
@@ -15,6 +15,7 @@ class SharedLinkEditDto {
SharedLinkEditDto({
this.allowDownload,
this.allowUpload,
+ this.changeExpiryTime,
this.description,
this.expiresAt,
this.showMetadata,
@@ -36,6 +37,15 @@ class SharedLinkEditDto {
///
bool? allowUpload;
+ /// Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.
+ ///
+ /// Please note: This property should have been non-nullable! Since the specification file
+ /// does not include a default value (using the "default:" property), however, the generated
+ /// source code must fall back to having a nullable type.
+ /// Consider adding a "default:" property in the specification file to hide this note.
+ ///
+ bool? changeExpiryTime;
+
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
@@ -58,6 +68,7 @@ class SharedLinkEditDto {
bool operator ==(Object other) => identical(this, other) || other is SharedLinkEditDto &&
other.allowDownload == allowDownload &&
other.allowUpload == allowUpload &&
+ other.changeExpiryTime == changeExpiryTime &&
other.description == description &&
other.expiresAt == expiresAt &&
other.showMetadata == showMetadata;
@@ -67,12 +78,13 @@ class SharedLinkEditDto {
// ignore: unnecessary_parenthesis
(allowDownload == null ? 0 : allowDownload!.hashCode) +
(allowUpload == null ? 0 : allowUpload!.hashCode) +
+ (changeExpiryTime == null ? 0 : changeExpiryTime!.hashCode) +
(description == null ? 0 : description!.hashCode) +
(expiresAt == null ? 0 : expiresAt!.hashCode) +
(showMetadata == null ? 0 : showMetadata!.hashCode);
@override
- String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, showMetadata=$showMetadata]';
+ String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, changeExpiryTime=$changeExpiryTime, description=$description, expiresAt=$expiresAt, showMetadata=$showMetadata]';
Map toJson() {
final json = {};
@@ -86,6 +98,11 @@ class SharedLinkEditDto {
} else {
// json[r'allowUpload'] = null;
}
+ if (this.changeExpiryTime != null) {
+ json[r'changeExpiryTime'] = this.changeExpiryTime;
+ } else {
+ // json[r'changeExpiryTime'] = null;
+ }
if (this.description != null) {
json[r'description'] = this.description;
} else {
@@ -114,6 +131,7 @@ class SharedLinkEditDto {
return SharedLinkEditDto(
allowDownload: mapValueOfType(json, r'allowDownload'),
allowUpload: mapValueOfType(json, r'allowUpload'),
+ changeExpiryTime: mapValueOfType(json, r'changeExpiryTime'),
description: mapValueOfType(json, r'description'),
expiresAt: mapDateTime(json, r'expiresAt', ''),
showMetadata: mapValueOfType(json, r'showMetadata'),
diff --git a/mobile/openapi/lib/model/update_stack_parent_dto.dart b/mobile/openapi/lib/model/update_stack_parent_dto.dart
new file mode 100644
index 000000000..8940f748d
--- /dev/null
+++ b/mobile/openapi/lib/model/update_stack_parent_dto.dart
@@ -0,0 +1,106 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class UpdateStackParentDto {
+ /// Returns a new [UpdateStackParentDto] instance.
+ UpdateStackParentDto({
+ required this.newParentId,
+ required this.oldParentId,
+ });
+
+ String newParentId;
+
+ String oldParentId;
+
+ @override
+ bool operator ==(Object other) => identical(this, other) || other is UpdateStackParentDto &&
+ other.newParentId == newParentId &&
+ other.oldParentId == oldParentId;
+
+ @override
+ int get hashCode =>
+ // ignore: unnecessary_parenthesis
+ (newParentId.hashCode) +
+ (oldParentId.hashCode);
+
+ @override
+ String toString() => 'UpdateStackParentDto[newParentId=$newParentId, oldParentId=$oldParentId]';
+
+ Map toJson() {
+ final json = {};
+ json[r'newParentId'] = this.newParentId;
+ json[r'oldParentId'] = this.oldParentId;
+ return json;
+ }
+
+ /// Returns a new [UpdateStackParentDto] instance and imports its values from
+ /// [value] if it's a [Map], null otherwise.
+ // ignore: prefer_constructors_over_static_methods
+ static UpdateStackParentDto? fromJson(dynamic value) {
+ if (value is Map) {
+ final json = value.cast();
+
+ return UpdateStackParentDto(
+ newParentId: mapValueOfType(json, r'newParentId')!,
+ oldParentId: mapValueOfType(json, r'oldParentId')!,
+ );
+ }
+ return null;
+ }
+
+ static List listFromJson(dynamic json, {bool growable = false,}) {
+ final result = [];
+ if (json is List && json.isNotEmpty) {
+ for (final row in json) {
+ final value = UpdateStackParentDto.fromJson(row);
+ if (value != null) {
+ result.add(value);
+ }
+ }
+ }
+ return result.toList(growable: growable);
+ }
+
+ static Map mapFromJson(dynamic json) {
+ final map = {};
+ if (json is Map && json.isNotEmpty) {
+ json = json.cast(); // ignore: parameter_assignments
+ for (final entry in json.entries) {
+ final value = UpdateStackParentDto.fromJson(entry.value);
+ if (value != null) {
+ map[entry.key] = value;
+ }
+ }
+ }
+ return map;
+ }
+
+ // maps a json object with a list of UpdateStackParentDto-objects as value to a dart map
+ static Map> mapListFromJson(dynamic json, {bool growable = false,}) {
+ final map = >{};
+ if (json is Map && json.isNotEmpty) {
+ // ignore: parameter_assignments
+ json = json.cast();
+ for (final entry in json.entries) {
+ map[entry.key] = UpdateStackParentDto.listFromJson(entry.value, growable: growable,);
+ }
+ }
+ return map;
+ }
+
+ /// The list of required keys that must be present in a JSON.
+ static const requiredKeys = {
+ 'newParentId',
+ 'oldParentId',
+ };
+}
+
diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart
index 640652167..8e45a1e3c 100644
--- a/mobile/openapi/test/asset_api_test.dart
+++ b/mobile/openapi/test/asset_api_test.dart
@@ -174,6 +174,11 @@ void main() {
// TODO
});
+ //Future updateStackParent(UpdateStackParentDto updateStackParentDto) async
+ test('test updateStackParent', () async {
+ // TODO
+ });
+
//Future uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String key, String duration, bool isArchived, bool isExternal, bool isOffline, bool isReadOnly, bool isVisible, String libraryId, MultipartFile livePhotoData, MultipartFile sidecarData }) async
test('test uploadFile', () async {
// TODO
diff --git a/mobile/openapi/test/asset_bulk_update_dto_test.dart b/mobile/openapi/test/asset_bulk_update_dto_test.dart
index cb23751e0..06f65de66 100644
--- a/mobile/openapi/test/asset_bulk_update_dto_test.dart
+++ b/mobile/openapi/test/asset_bulk_update_dto_test.dart
@@ -31,6 +31,16 @@ void main() {
// TODO
});
+ // bool removeParent
+ test('to test the property `removeParent`', () async {
+ // TODO
+ });
+
+ // String stackParentId
+ test('to test the property `stackParentId`', () async {
+ // TODO
+ });
+
});
diff --git a/mobile/openapi/test/asset_response_dto_test.dart b/mobile/openapi/test/asset_response_dto_test.dart
index f450aae27..63668934a 100644
--- a/mobile/openapi/test/asset_response_dto_test.dart
+++ b/mobile/openapi/test/asset_response_dto_test.dart
@@ -142,6 +142,21 @@ void main() {
// TODO
});
+ // List stack (default value: const [])
+ test('to test the property `stack`', () async {
+ // TODO
+ });
+
+ // int stackCount
+ test('to test the property `stackCount`', () async {
+ // TODO
+ });
+
+ // String stackParentId
+ test('to test the property `stackParentId`', () async {
+ // TODO
+ });
+
// List tags (default value: const [])
test('to test the property `tags`', () async {
// TODO
diff --git a/mobile/openapi/test/shared_link_edit_dto_test.dart b/mobile/openapi/test/shared_link_edit_dto_test.dart
index 26fbb92fd..893d12efe 100644
--- a/mobile/openapi/test/shared_link_edit_dto_test.dart
+++ b/mobile/openapi/test/shared_link_edit_dto_test.dart
@@ -26,6 +26,12 @@ void main() {
// TODO
});
+ // Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.
+ // bool changeExpiryTime
+ test('to test the property `changeExpiryTime`', () async {
+ // TODO
+ });
+
// String description
test('to test the property `description`', () async {
// TODO
diff --git a/mobile/openapi/test/update_stack_parent_dto_test.dart b/mobile/openapi/test/update_stack_parent_dto_test.dart
new file mode 100644
index 000000000..6af71854e
--- /dev/null
+++ b/mobile/openapi/test/update_stack_parent_dto_test.dart
@@ -0,0 +1,32 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for UpdateStackParentDto
+void main() {
+ // final instance = UpdateStackParentDto();
+
+ group('test UpdateStackParentDto', () {
+ // String newParentId
+ test('to test the property `newParentId`', () async {
+ // TODO
+ });
+
+ // String oldParentId
+ test('to test the property `oldParentId`', () async {
+ // TODO
+ });
+
+
+ });
+
+}
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index f902fe7cb..9bae30657 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
-version: 1.82.0+106
+version: 1.82.1+106
isar_version: &isar_version 3.1.0+1
environment:
diff --git a/mobile/test/asset_grid_data_structure_test.dart b/mobile/test/asset_grid_data_structure_test.dart
index a124f5214..6b8f08063 100644
--- a/mobile/test/asset_grid_data_structure_test.dart
+++ b/mobile/test/asset_grid_data_structure_test.dart
@@ -25,6 +25,7 @@ void main() {
isFavorite: false,
isArchived: false,
isTrashed: false,
+ stackCount: 0,
),
);
}
diff --git a/mobile/test/sync_service_test.dart b/mobile/test/sync_service_test.dart
index 9c03ec689..b2543c663 100644
--- a/mobile/test/sync_service_test.dart
+++ b/mobile/test/sync_service_test.dart
@@ -35,6 +35,7 @@ void main() {
isFavorite: false,
isArchived: false,
isTrashed: false,
+ stackCount: 0,
);
}
diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json
index 25631ffda..e6230fcc3 100644
--- a/server/immich-openapi-specs.json
+++ b/server/immich-openapi-specs.json
@@ -1673,6 +1673,41 @@
]
}
},
+ "/asset/stack/parent": {
+ "put": {
+ "operationId": "updateStackParent",
+ "parameters": [],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateStackParentDto"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": ""
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ },
+ {
+ "cookie": []
+ },
+ {
+ "api_key": []
+ }
+ ],
+ "tags": [
+ "Asset"
+ ]
+ }
+ },
"/asset/statistics": {
"get": {
"operationId": "getAssetStats",
@@ -5379,7 +5414,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
- "version": "1.82.0",
+ "version": "1.82.1",
"contact": {}
},
"tags": [],
@@ -5696,6 +5731,13 @@
},
"isFavorite": {
"type": "boolean"
+ },
+ "removeParent": {
+ "type": "boolean"
+ },
+ "stackParentId": {
+ "format": "uuid",
+ "type": "string"
}
},
"required": [
@@ -5941,6 +5983,19 @@
"smartInfo": {
"$ref": "#/components/schemas/SmartInfoResponseDto"
},
+ "stack": {
+ "items": {
+ "$ref": "#/components/schemas/AssetResponseDto"
+ },
+ "type": "array"
+ },
+ "stackCount": {
+ "type": "integer"
+ },
+ "stackParentId": {
+ "nullable": true,
+ "type": "string"
+ },
"tags": {
"items": {
"$ref": "#/components/schemas/TagResponseDto"
@@ -5961,6 +6016,7 @@
},
"required": [
"type",
+ "stackCount",
"deviceAssetId",
"deviceId",
"ownerId",
@@ -7846,6 +7902,10 @@
"allowUpload": {
"type": "boolean"
},
+ "changeExpiryTime": {
+ "description": "Few clients cannot send null to set the expiryTime to never.\nSetting this flag and not sending expiryAt is considered as null instead.\nClients that can send null values can ignore this.",
+ "type": "boolean"
+ },
"description": {
"type": "string"
},
@@ -8521,6 +8581,23 @@
},
"type": "object"
},
+ "UpdateStackParentDto": {
+ "properties": {
+ "newParentId": {
+ "format": "uuid",
+ "type": "string"
+ },
+ "oldParentId": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ "required": [
+ "oldParentId",
+ "newParentId"
+ ],
+ "type": "object"
+ },
"UpdateTagDto": {
"properties": {
"name": {
diff --git a/server/package-lock.json b/server/package-lock.json
index a9480d52f..0b54b2f7f 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "immich",
- "version": "1.82.0",
+ "version": "1.82.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
- "version": "1.82.0",
+ "version": "1.82.1",
"license": "UNLICENSED",
"dependencies": {
"@babel/runtime": "^7.22.11",
diff --git a/server/package.json b/server/package.json
index e4a51cbbe..def7a64ac 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
{
"name": "immich",
- "version": "1.82.0",
+ "version": "1.82.1",
"description": "",
"author": "",
"private": true,
@@ -26,7 +26,7 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
- "test:e2e": "NODE_OPTIONS='--experimental-vm-modules --max_old_space_size=4096' jest --config test/e2e/jest-e2e.json --runInBand --forceExit",
+ "test:e2e": "NODE_OPTIONS='--experimental-vm-modules --max_old_space_size=4096' jest --config test/e2e/jest-e2e.json --runInBand",
"typeorm": "typeorm",
"typeorm:migrations:create": "typeorm migration:create",
"typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js",
diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts
index e326a2711..453539129 100644
--- a/server/src/domain/album/album.service.spec.ts
+++ b/server/src/domain/album/album.service.spec.ts
@@ -1,7 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import {
albumStub,
- assetStub,
authStub,
IAccessRepositoryMock,
newAccessRepositoryMock,
@@ -225,7 +224,7 @@ describe(AlbumService.name, () => {
}),
).rejects.toBeInstanceOf(BadRequestException);
- expect(albumMock.hasAsset).toHaveBeenCalledWith(albumStub.oneAsset.id, 'not-in-album');
+ expect(albumMock.hasAsset).toHaveBeenCalledWith({ albumId: 'album-4', assetId: 'not-in-album' });
expect(albumMock.update).not.toHaveBeenCalled();
});
@@ -461,6 +460,7 @@ describe(AlbumService.name, () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
+ albumMock.hasAsset.mockResolvedValue(false);
await expect(
sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
@@ -473,9 +473,12 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
- assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
albumThumbnailAssetId: 'asset-1',
});
+ expect(albumMock.addAssets).toHaveBeenCalledWith({
+ albumId: 'album-123',
+ assetIds: ['asset-1', 'asset-2', 'asset-3'],
+ });
});
it('should not set the thumbnail if the album has one already', async () => {
@@ -490,9 +493,9 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
- assets: [{ id: 'asset-1' }],
albumThumbnailAssetId: 'asset-id',
});
+ expect(albumMock.addAssets).toHaveBeenCalled();
});
it('should allow a shared user to add assets', async () => {
@@ -512,9 +515,12 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
- assets: [{ id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
albumThumbnailAssetId: 'asset-1',
});
+ expect(albumMock.addAssets).toHaveBeenCalledWith({
+ albumId: 'album-123',
+ assetIds: ['asset-1', 'asset-2', 'asset-3'],
+ });
});
it('should allow a shared link user to add assets', async () => {
@@ -523,6 +529,7 @@ describe(AlbumService.name, () => {
accessMock.album.hasSharedLinkAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
+ albumMock.hasAsset.mockResolvedValue(false);
await expect(
sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
@@ -535,9 +542,12 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
- assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
albumThumbnailAssetId: 'asset-1',
});
+ expect(albumMock.addAssets).toHaveBeenCalledWith({
+ albumId: 'album-123',
+ assetIds: ['asset-1', 'asset-2', 'asset-3'],
+ });
expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLinkId,
@@ -550,6 +560,7 @@ describe(AlbumService.name, () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
+ albumMock.hasAsset.mockResolvedValue(false);
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
{ success: true, id: 'asset-1' },
@@ -558,10 +569,8 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
- assets: [assetStub.image, { id: 'asset-1' }],
albumThumbnailAssetId: 'asset-1',
});
-
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
});
@@ -569,6 +578,7 @@ describe(AlbumService.name, () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
+ albumMock.hasAsset.mockResolvedValue(true);
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE },
@@ -620,17 +630,14 @@ describe(AlbumService.name, () => {
it('should allow the owner to remove assets', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
+ albumMock.hasAsset.mockResolvedValue(true);
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' },
]);
- expect(albumMock.update).toHaveBeenCalledWith({
- id: 'album-123',
- updatedAt: expect.any(Date),
- assets: [],
- albumThumbnailAssetId: null,
- });
+ expect(albumMock.update).toHaveBeenCalledWith({ id: 'album-123', updatedAt: expect.any(Date) });
+ expect(albumMock.removeAssets).toHaveBeenCalledWith({ assetIds: ['asset-id'], albumId: 'album-123' });
});
it('should skip assets not in the album', async () => {
@@ -647,9 +654,14 @@ describe(AlbumService.name, () => {
it('should skip assets without user permission to remove', async () => {
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
+ albumMock.hasAsset.mockResolvedValue(true);
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
- { success: false, id: 'asset-id', error: BulkIdErrorReason.NO_PERMISSION },
+ {
+ success: false,
+ id: 'asset-id',
+ error: BulkIdErrorReason.NO_PERMISSION,
+ },
]);
expect(albumMock.update).not.toHaveBeenCalled();
@@ -658,6 +670,7 @@ describe(AlbumService.name, () => {
it('should reset the thumbnail if it is removed', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets));
+ albumMock.hasAsset.mockResolvedValue(true);
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' },
@@ -666,9 +679,8 @@ describe(AlbumService.name, () => {
expect(albumMock.update).toHaveBeenCalledWith({
id: 'album-123',
updatedAt: expect.any(Date),
- assets: [assetStub.withLocation],
- albumThumbnailAssetId: assetStub.withLocation.id,
});
+ expect(albumMock.updateThumbnails).toHaveBeenCalled();
});
});
diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts
index 5da0b3440..04b885040 100644
--- a/server/src/domain/album/album.service.ts
+++ b/server/src/domain/album/album.service.ts
@@ -120,7 +120,7 @@ export class AlbumService {
const album = await this.findOrFail(id, { withAssets: true });
if (dto.albumThumbnailAssetId) {
- const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId);
+ const valid = await this.albumRepository.hasAsset({ albumId: id, assetId: dto.albumThumbnailAssetId });
if (!valid) {
throw new BadRequestException('Invalid album thumbnail');
}
@@ -148,35 +148,34 @@ export class AlbumService {
}
async addAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise {
- const album = await this.findOrFail(id, { withAssets: true });
+ const album = await this.findOrFail(id, { withAssets: false });
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
const results: BulkIdResponseDto[] = [];
- for (const id of dto.ids) {
- const hasAsset = album.assets.find((asset) => asset.id === id);
+ for (const assetId of dto.ids) {
+ const hasAsset = await this.albumRepository.hasAsset({ albumId: id, assetId });
if (hasAsset) {
- results.push({ id, success: false, error: BulkIdErrorReason.DUPLICATE });
+ results.push({ id: assetId, success: false, error: BulkIdErrorReason.DUPLICATE });
continue;
}
- const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, id);
+ const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, assetId);
if (!hasAccess) {
- results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION });
+ results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue;
}
- results.push({ id, success: true });
- album.assets.push({ id } as AssetEntity);
+ results.push({ id: assetId, success: true });
}
- const newAsset = results.find(({ success }) => success);
- if (newAsset) {
+ const newAssetIds = results.filter(({ success }) => success).map(({ id }) => id);
+ if (newAssetIds.length > 0) {
+ await this.albumRepository.addAssets({ albumId: id, assetIds: newAssetIds });
await this.albumRepository.update({
id,
- assets: album.assets,
updatedAt: new Date(),
- albumThumbnailAssetId: album.albumThumbnailAssetId ?? newAsset.id,
+ albumThumbnailAssetId: album.albumThumbnailAssetId ?? newAssetIds[0],
});
}
@@ -184,42 +183,37 @@ export class AlbumService {
}
async removeAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise {
- const album = await this.findOrFail(id, { withAssets: true });
+ const album = await this.findOrFail(id, { withAssets: false });
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
const results: BulkIdResponseDto[] = [];
- for (const id of dto.ids) {
- const hasAsset = album.assets.find((asset) => asset.id === id);
+ for (const assetId of dto.ids) {
+ const hasAsset = await this.albumRepository.hasAsset({ albumId: id, assetId });
if (!hasAsset) {
- results.push({ id, success: false, error: BulkIdErrorReason.NOT_FOUND });
+ results.push({ id: assetId, success: false, error: BulkIdErrorReason.NOT_FOUND });
continue;
}
const hasAccess = await this.access.hasAny(authUser, [
- { permission: Permission.ALBUM_REMOVE_ASSET, id },
- { permission: Permission.ASSET_SHARE, id },
+ { permission: Permission.ALBUM_REMOVE_ASSET, id: assetId },
+ { permission: Permission.ASSET_SHARE, id: assetId },
]);
if (!hasAccess) {
- results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION });
+ results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue;
}
- results.push({ id, success: true });
- album.assets = album.assets.filter((asset) => asset.id !== id);
- if (album.albumThumbnailAssetId === id) {
- album.albumThumbnailAssetId = null;
- }
+ results.push({ id: assetId, success: true });
}
- const hasSuccess = results.find(({ success }) => success);
- if (hasSuccess) {
- await this.albumRepository.update({
- id,
- assets: album.assets,
- updatedAt: new Date(),
- albumThumbnailAssetId: album.albumThumbnailAssetId || album.assets[0]?.id || null,
- });
+ const removedIds = results.filter(({ success }) => success).map(({ id }) => id);
+ if (removedIds.length > 0) {
+ await this.albumRepository.removeAssets({ albumId: id, assetIds: removedIds });
+ await this.albumRepository.update({ id, updatedAt: new Date() });
+ if (album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) {
+ await this.albumRepository.updateThumbnails();
+ }
}
return results;
diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts
index 20e86f159..763256d0d 100644
--- a/server/src/domain/asset/asset.service.spec.ts
+++ b/server/src/domain/asset/asset.service.spec.ts
@@ -20,6 +20,7 @@ import { Readable } from 'stream';
import { JobName } from '../job';
import {
AssetStats,
+ CommunicationEvent,
IAssetRepository,
ICommunicationRepository,
ICryptoRepository,
@@ -636,10 +637,89 @@ describe(AssetService.name, () => {
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
});
+
+ /// Stack related
+
+ it('should require asset update access for parent', async () => {
+ accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+ when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'parent').mockResolvedValue(false);
+ await expect(
+ sut.updateAll(authStub.user1, {
+ ids: ['asset-1'],
+ stackParentId: 'parent',
+ }),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('should update parent asset when children are added', async () => {
+ accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+ await sut.updateAll(authStub.user1, {
+ ids: [],
+ stackParentId: 'parent',
+ }),
+ expect(assetMock.updateAll).toHaveBeenCalledWith(['parent'], { stackParentId: null });
+ });
+
+ it('should update parent asset when children are removed', async () => {
+ accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+ assetMock.getByIds.mockResolvedValue([{ id: 'child-1', stackParentId: 'parent' } as AssetEntity]);
+
+ await sut.updateAll(authStub.user1, {
+ ids: ['child-1'],
+ removeParent: true,
+ }),
+ expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['parent']), { stackParentId: null });
+ });
+
+ it('update parentId for new children', async () => {
+ accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+ await sut.updateAll(authStub.user1, {
+ stackParentId: 'parent',
+ ids: ['child-1', 'child-2'],
+ });
+
+ expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' });
+ });
+
+ it('nullify parentId for remove children', async () => {
+ accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+ await sut.updateAll(authStub.user1, {
+ removeParent: true,
+ ids: ['child-1', 'child-2'],
+ });
+
+ expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: null });
+ });
+
+ it('merge stacks if new child has children', async () => {
+ accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+ assetMock.getByIds.mockResolvedValue([
+ { id: 'child-1', stack: [{ id: 'child-2' } as AssetEntity] } as AssetEntity,
+ ]);
+
+ await sut.updateAll(authStub.user1, {
+ ids: ['child-1'],
+ stackParentId: 'parent',
+ });
+
+ expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' });
+ });
+
+ it('should send ws asset update event', async () => {
+ accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+ await sut.updateAll(authStub.user1, {
+ ids: ['asset-1'],
+ stackParentId: 'parent',
+ });
+
+ expect(communicationMock.send).toHaveBeenCalledWith(CommunicationEvent.ASSET_UPDATE, authStub.user1.id, [
+ 'asset-1',
+ ]);
+ });
});
describe('deleteAll', () => {
- it('should required asset delete access for all ids', async () => {
+ it('should require asset delete access for all ids', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect(
sut.deleteAll(authStub.user1, {
@@ -677,7 +757,7 @@ describe(AssetService.name, () => {
});
describe('restoreAll', () => {
- it('should required asset restore access for all ids', async () => {
+ it('should require asset restore access for all ids', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect(
sut.deleteAll(authStub.user1, {
@@ -757,6 +837,21 @@ describe(AssetService.name, () => {
expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace);
});
+ it('should update stack parent if asset has stack children', async () => {
+ when(assetMock.getById)
+ .calledWith(assetStub.primaryImage.id)
+ .mockResolvedValue(assetStub.primaryImage as AssetEntity);
+
+ await sut.handleAssetDeletion({ id: assetStub.primaryImage.id });
+
+ expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-2'], {
+ stackParentId: 'stack-child-asset-1',
+ });
+ expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-1'], {
+ stackParentId: null,
+ });
+ });
+
it('should not schedule delete-files job for readonly assets', async () => {
when(assetMock.getById)
.calledWith(assetStub.readOnly.id)
@@ -854,4 +949,70 @@ describe(AssetService.name, () => {
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } });
});
});
+
+ describe('updateStackParent', () => {
+ it('should require asset update access for new parent', async () => {
+ when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(true);
+ when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(false);
+ accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
+ await expect(
+ sut.updateStackParent(authStub.user1, {
+ oldParentId: 'old',
+ newParentId: 'new',
+ }),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('should require asset read access for old parent', async () => {
+ when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(false);
+ when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(true);
+ await expect(
+ sut.updateStackParent(authStub.user1, {
+ oldParentId: 'old',
+ newParentId: 'new',
+ }),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('make old parent the child of new parent', async () => {
+ accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+ when(assetMock.getById)
+ .calledWith(assetStub.image.id)
+ .mockResolvedValue(assetStub.image as AssetEntity);
+
+ await sut.updateStackParent(authStub.user1, {
+ oldParentId: assetStub.image.id,
+ newParentId: 'new',
+ });
+
+ expect(assetMock.updateAll).toBeCalledWith([assetStub.image.id], { stackParentId: 'new' });
+ });
+
+ it('remove stackParentId of new parent', async () => {
+ accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+ await sut.updateStackParent(authStub.user1, {
+ oldParentId: assetStub.primaryImage.id,
+ newParentId: 'new',
+ });
+
+ expect(assetMock.updateAll).toBeCalledWith(['new'], { stackParentId: null });
+ });
+
+ it('update stackParentId of old parents children to new parent', async () => {
+ accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+ when(assetMock.getById)
+ .calledWith(assetStub.primaryImage.id)
+ .mockResolvedValue(assetStub.primaryImage as AssetEntity);
+
+ await sut.updateStackParent(authStub.user1, {
+ oldParentId: assetStub.primaryImage.id,
+ newParentId: 'new',
+ });
+
+ expect(assetMock.updateAll).toBeCalledWith(
+ [assetStub.primaryImage.id, 'stack-child-asset-1', 'stack-child-asset-2'],
+ { stackParentId: 'new' },
+ );
+ });
+ });
});
diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts
index abd0dbe0d..57623fa1b 100644
--- a/server/src/domain/asset/asset.service.ts
+++ b/server/src/domain/asset/asset.service.ts
@@ -40,6 +40,7 @@ import {
TimeBucketDto,
TrashAction,
UpdateAssetDto,
+ UpdateStackParentDto,
mapStats,
} from './dto';
import {
@@ -208,7 +209,7 @@ export class AssetService {
if (authUser.isShowMetadata) {
return assets.map((asset) => mapAsset(asset));
} else {
- return assets.map((asset) => mapAsset(asset, true));
+ return assets.map((asset) => mapAsset(asset, { stripMetadata: true }));
}
}
@@ -338,10 +339,29 @@ export class AssetService {
}
async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise {
- const { ids, ...options } = dto;
+ const { ids, removeParent, ...options } = dto;
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
+
+ if (removeParent) {
+ (options as Partial).stackParentId = null;
+ const assets = await this.assetRepository.getByIds(ids);
+ // This updates the updatedAt column of the parents to indicate that one of its children is removed
+ // All the unique parent's -> parent is set to null
+ ids.push(...new Set(assets.filter((a) => !!a.stackParentId).map((a) => a.stackParentId!)));
+ } else if (options.stackParentId) {
+ await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, options.stackParentId);
+ // Merge stacks
+ const assets = await this.assetRepository.getByIds(ids);
+ const assetsWithChildren = assets.filter((a) => a.stack && a.stack.length > 0);
+ ids.push(...assetsWithChildren.flatMap((child) => child.stack!.map((gChild) => gChild.id)));
+
+ // This updates the updatedAt column of the parent to indicate that a new child has been added
+ await this.assetRepository.updateAll([options.stackParentId], { stackParentId: null });
+ }
+
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
await this.assetRepository.updateAll(ids, options);
+ this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids);
}
async handleAssetDeletionCheck() {
@@ -384,6 +404,14 @@ export class AssetService {
);
}
+ // Replace the parent of the stack children with a new asset
+ if (asset.stack && asset.stack.length != 0) {
+ const stackIds = asset.stack.map((a) => a.id);
+ const newParentId = stackIds[0];
+ await this.assetRepository.updateAll(stackIds.slice(1), { stackParentId: newParentId });
+ await this.assetRepository.updateAll([newParentId], { stackParentId: null });
+ }
+
await this.assetRepository.remove(asset);
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } });
this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, id);
@@ -454,6 +482,25 @@ export class AssetService {
this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids);
}
+ async updateStackParent(authUser: AuthUserDto, dto: UpdateStackParentDto): Promise {
+ const { oldParentId, newParentId } = dto;
+ await this.access.requirePermission(authUser, Permission.ASSET_READ, oldParentId);
+ await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, newParentId);
+
+ const childIds: string[] = [];
+ const oldParent = await this.assetRepository.getById(oldParentId);
+ if (oldParent != null) {
+ childIds.push(oldParent.id);
+ // Get all children of old parent
+ childIds.push(...(oldParent.stack?.map((a) => a.id) ?? []));
+ }
+
+ this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, [...childIds, newParentId]);
+ await this.assetRepository.updateAll(childIds, { stackParentId: newParentId });
+ // Remove ParentId of new parent if this was previously a child of some other asset
+ return this.assetRepository.updateAll([newParentId], { stackParentId: null });
+ }
+
async run(authUser: AuthUserDto, dto: AssetJobsDto) {
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds);
diff --git a/server/src/domain/asset/dto/asset-stack.dto.ts b/server/src/domain/asset/dto/asset-stack.dto.ts
new file mode 100644
index 000000000..80dabdb34
--- /dev/null
+++ b/server/src/domain/asset/dto/asset-stack.dto.ts
@@ -0,0 +1,9 @@
+import { ValidateUUID } from '../../domain.util';
+
+export class UpdateStackParentDto {
+ @ValidateUUID()
+ oldParentId!: string;
+
+ @ValidateUUID()
+ newParentId!: string;
+}
diff --git a/server/src/domain/asset/dto/asset.dto.ts b/server/src/domain/asset/dto/asset.dto.ts
index f5ada315c..0b3ce68d5 100644
--- a/server/src/domain/asset/dto/asset.dto.ts
+++ b/server/src/domain/asset/dto/asset.dto.ts
@@ -1,6 +1,6 @@
import { Type } from 'class-transformer';
import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator';
-import { Optional } from '../../domain.util';
+import { Optional, ValidateUUID } from '../../domain.util';
import { BulkIdsDto } from '../response-dto';
export class AssetBulkUpdateDto extends BulkIdsDto {
@@ -11,6 +11,14 @@ export class AssetBulkUpdateDto extends BulkIdsDto {
@Optional()
@IsBoolean()
isArchived?: boolean;
+
+ @Optional()
+ @ValidateUUID()
+ stackParentId?: string;
+
+ @Optional()
+ @IsBoolean()
+ removeParent?: boolean;
}
export class UpdateAssetDto {
diff --git a/server/src/domain/asset/dto/index.ts b/server/src/domain/asset/dto/index.ts
index 8e780869a..281d924f3 100644
--- a/server/src/domain/asset/dto/index.ts
+++ b/server/src/domain/asset/dto/index.ts
@@ -1,4 +1,5 @@
export * from './asset-ids.dto';
+export * from './asset-stack.dto';
export * from './asset-statistics.dto';
export * from './asset.dto';
export * from './download.dto';
diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts
index e7d5061be..0e5784055 100644
--- a/server/src/domain/asset/response-dto/asset-response.dto.ts
+++ b/server/src/domain/asset/response-dto/asset-response.dto.ts
@@ -42,9 +42,20 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
people?: PersonResponseDto[];
/**base64 encoded sha1 hash */
checksum!: string;
+ stackParentId?: string | null;
+ stack?: AssetResponseDto[];
+ @ApiProperty({ type: 'integer' })
+ stackCount!: number;
}
-export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetResponseDto {
+export type AssetMapOptions = {
+ stripMetadata?: boolean;
+ withStack?: boolean;
+};
+
+export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
+ const { stripMetadata = false, withStack = false } = options;
+
const sanitizedAssetResponse: SanitizedAssetResponseDto = {
id: entity.id,
type: entity.type,
@@ -87,6 +98,9 @@ export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetRespo
tags: entity.tags?.map(mapTag),
people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
checksum: entity.checksum.toString('base64'),
+ stackParentId: entity.stackParentId,
+ stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined,
+ stackCount: entity.stack?.length ?? 0,
isExternal: entity.isExternal,
isOffline: entity.isOffline,
isReadOnly: entity.isReadOnly,
diff --git a/server/src/domain/metadata/metadata.service.spec.ts b/server/src/domain/metadata/metadata.service.spec.ts
index 1096db613..86befcee8 100644
--- a/server/src/domain/metadata/metadata.service.spec.ts
+++ b/server/src/domain/metadata/metadata.service.spec.ts
@@ -409,6 +409,54 @@ describe(MetadataService.name, () => {
localDateTime: new Date('1970-01-01'),
});
});
+
+ it('should handle duration', async () => {
+ assetMock.getByIds.mockResolvedValue([assetStub.image]);
+ metadataMock.getExifTags.mockResolvedValue({ Duration: 6.21 });
+
+ await sut.handleMetadataExtraction({ id: assetStub.image.id });
+
+ expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
+ expect(assetMock.upsertExif).toHaveBeenCalled();
+ expect(assetMock.save).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: assetStub.image.id,
+ duration: '00:00:06.210',
+ }),
+ );
+ });
+
+ it('should handle duration as an object without Scale', async () => {
+ assetMock.getByIds.mockResolvedValue([assetStub.image]);
+ metadataMock.getExifTags.mockResolvedValue({ Duration: { Value: 6.2 } });
+
+ await sut.handleMetadataExtraction({ id: assetStub.image.id });
+
+ expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
+ expect(assetMock.upsertExif).toHaveBeenCalled();
+ expect(assetMock.save).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: assetStub.image.id,
+ duration: '00:00:06.200',
+ }),
+ );
+ });
+
+ it('should handle duration with scale', async () => {
+ assetMock.getByIds.mockResolvedValue([assetStub.image]);
+ metadataMock.getExifTags.mockResolvedValue({ Duration: { Scale: 1.11111111111111e-5, Value: 558720 } });
+
+ await sut.handleMetadataExtraction({ id: assetStub.image.id });
+
+ expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
+ expect(assetMock.upsertExif).toHaveBeenCalled();
+ expect(assetMock.save).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: assetStub.image.id,
+ duration: '00:00:06.207',
+ }),
+ );
+ });
});
describe('handleQueueSidecar', () => {
diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts
index 2779df54c..0b6855ddf 100644
--- a/server/src/domain/metadata/metadata.service.ts
+++ b/server/src/domain/metadata/metadata.service.ts
@@ -7,6 +7,7 @@ import { Duration } from 'luxon';
import { usePagination } from '../domain.util';
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
import {
+ ExifDuration,
IAlbumRepository,
IAssetRepository,
ICryptoRepository,
@@ -109,6 +110,10 @@ export class MetadataService {
}
}
+ async teardown() {
+ await this.repository.teardown();
+ }
+
async handleLivePhotoLinking(job: IEntityJob) {
const { id } = job;
const [asset] = await this.assetRepository.getByIds([id]);
@@ -394,7 +399,11 @@ export class MetadataService {
return bitsPerSample;
}
- private getDuration(seconds?: number): string {
- return Duration.fromObject({ seconds }).toFormat('hh:mm:ss.SSS');
+ private getDuration(seconds?: number | ExifDuration): string {
+ let _seconds = seconds as number;
+ if (typeof seconds === 'object') {
+ _seconds = seconds.Value * (seconds?.Scale || 1);
+ }
+ return Duration.fromObject({ seconds: _seconds }).toFormat('hh:mm:ss.SSS');
}
}
diff --git a/server/src/domain/repositories/album.repository.ts b/server/src/domain/repositories/album.repository.ts
index 5a54dbc80..276ab796b 100644
--- a/server/src/domain/repositories/album.repository.ts
+++ b/server/src/domain/repositories/album.repository.ts
@@ -11,13 +11,24 @@ export interface AlbumInfoOptions {
withAssets: boolean;
}
+export interface AlbumAsset {
+ albumId: string;
+ assetId: string;
+}
+
+export interface AlbumAssets {
+ albumId: string;
+ assetIds: string[];
+}
+
export interface IAlbumRepository {
getById(id: string, options: AlbumInfoOptions): Promise;
getByIds(ids: string[]): Promise;
getByAssetId(ownerId: string, assetId: string): Promise;
- hasAsset(id: string, assetId: string): Promise;
- /** Remove an asset from _all_ albums */
- removeAsset(id: string): Promise;
+ addAssets(assets: AlbumAssets): Promise;
+ hasAsset(asset: AlbumAsset): Promise;
+ removeAsset(assetId: string): Promise;
+ removeAssets(assets: AlbumAssets): Promise;
getAssetCountForIds(ids: string[]): Promise;
getInvalidThumbnail(): Promise;
getOwned(ownerId: string): Promise;
diff --git a/server/src/domain/repositories/communication.repository.ts b/server/src/domain/repositories/communication.repository.ts
index f49beeb50..f4c06a1e9 100644
--- a/server/src/domain/repositories/communication.repository.ts
+++ b/server/src/domain/repositories/communication.repository.ts
@@ -4,6 +4,7 @@ export enum CommunicationEvent {
UPLOAD_SUCCESS = 'on_upload_success',
ASSET_DELETE = 'on_asset_delete',
ASSET_TRASH = 'on_asset_trash',
+ ASSET_UPDATE = 'on_asset_update',
ASSET_RESTORE = 'on_asset_restore',
PERSON_THUMBNAIL = 'on_person_thumbnail',
SERVER_VERSION = 'on_server_version',
diff --git a/server/src/domain/repositories/metadata.repository.ts b/server/src/domain/repositories/metadata.repository.ts
index a037964f4..0c3b78462 100644
--- a/server/src/domain/repositories/metadata.repository.ts
+++ b/server/src/domain/repositories/metadata.repository.ts
@@ -14,7 +14,12 @@ export interface ReverseGeocodeResult {
city: string | null;
}
-export interface ImmichTags extends Omit {
+export interface ExifDuration {
+ Value: number;
+ Scale?: number;
+}
+
+export interface ImmichTags extends Omit {
ContentIdentifier?: string;
MotionPhoto?: number;
MotionPhotoVersion?: number;
@@ -22,10 +27,12 @@ export interface ImmichTags extends Omit {
MediaGroupUUID?: string;
ImagePixelDepth?: string;
FocalLength?: number;
+ Duration?: number | ExifDuration;
}
export interface IMetadataRepository {
init(options: Partial): Promise;
+ teardown(): Promise;
reverseGeocode(point: GeoPoint): Promise;
deleteCache(): Promise;
getExifTags(path: string): Promise;
diff --git a/server/src/domain/shared-link/shared-link-response.dto.ts b/server/src/domain/shared-link/shared-link-response.dto.ts
index 52592d36f..4e35f6546 100644
--- a/server/src/domain/shared-link/shared-link-response.dto.ts
+++ b/server/src/domain/shared-link/shared-link-response.dto.ts
@@ -58,7 +58,7 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): Shar
type: sharedLink.type,
createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt,
- assets: assets.map((asset) => mapAsset(asset, true)) as AssetResponseDto[],
+ assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })) as AssetResponseDto[],
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload,
diff --git a/server/src/domain/shared-link/shared-link.dto.ts b/server/src/domain/shared-link/shared-link.dto.ts
index 4c86afb62..ed38cf984 100644
--- a/server/src/domain/shared-link/shared-link.dto.ts
+++ b/server/src/domain/shared-link/shared-link.dto.ts
@@ -52,4 +52,13 @@ export class SharedLinkEditDto {
@Optional()
showMetadata?: boolean;
+
+ /**
+ * Few clients cannot send null to set the expiryTime to never.
+ * Setting this flag and not sending expiryAt is considered as null instead.
+ * Clients that can send null values can ignore this.
+ */
+ @Optional()
+ @IsBoolean()
+ changeExpiryTime?: boolean;
}
diff --git a/server/src/domain/shared-link/shared-link.service.ts b/server/src/domain/shared-link/shared-link.service.ts
index 06b5b7897..9e2a0fc8a 100644
--- a/server/src/domain/shared-link/shared-link.service.ts
+++ b/server/src/domain/shared-link/shared-link.service.ts
@@ -81,7 +81,7 @@ export class SharedLinkService {
id,
userId: authUser.id,
description: dto.description,
- expiresAt: dto.expiresAt,
+ expiresAt: dto.changeExpiryTime && !dto.expiresAt ? null : dto.expiresAt,
allowUpload: dto.allowUpload,
allowDownload: dto.allowDownload,
showExif: dto.showMetadata,
diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts
index b04ffc89a..9aa3a0e5e 100644
--- a/server/src/domain/storage-template/storage-template.service.ts
+++ b/server/src/domain/storage-template/storage-template.service.ts
@@ -191,6 +191,7 @@ export class StorageTemplateService {
fileCreatedAt: new Date(),
originalPath: '/upload/test/IMG_123.jpg',
type: AssetType.IMAGE,
+ id: 'd587e44b-f8c0-4832-9ba3-43268bbf5d4e',
} as AssetEntity;
try {
this.render(this.compile(config.storageTemplate.template), testAsset, 'IMG_123', 'jpg');
@@ -218,6 +219,7 @@ export class StorageTemplateService {
ext,
filetype: asset.type == AssetType.IMAGE ? 'IMG' : 'VID',
filetypefull: asset.type == AssetType.IMAGE ? 'IMAGE' : 'VIDEO',
+ assetId: asset.id,
};
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
diff --git a/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts b/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts
index 330560ea8..38d977f88 100644
--- a/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts
+++ b/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts
@@ -6,7 +6,7 @@ export class SystemConfigMachineLearningDto {
@IsBoolean()
enabled!: boolean;
- @IsUrl({ require_tld: false })
+ @IsUrl({ require_tld: false, allow_underscores: true })
@ValidateIf((dto) => dto.enabled)
url!: string;
diff --git a/server/src/domain/system-config/system-config.constants.ts b/server/src/domain/system-config/system-config.constants.ts
index ca0497a9d..a1bb03a4c 100644
--- a/server/src/domain/system-config/system-config.constants.ts
+++ b/server/src/domain/system-config/system-config.constants.ts
@@ -20,6 +20,9 @@ export const supportedPresetTokens = [
'{{y}}-{{MMMM}}-{{dd}}/{{filename}}',
'{{y}}/{{y}}-{{MM}}/{{filename}}',
'{{y}}/{{y}}-{{WW}}/{{filename}}',
+ '{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}',
+ '{{y}}/{{y}}-{{MM}}/{{assetId}}',
+ '{{y}}/{{y}}-{{WW}}/{{assetId}}',
];
export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG';
diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts
index 9a9ea969e..66c72fc92 100644
--- a/server/src/domain/system-config/system-config.core.ts
+++ b/server/src/domain/system-config/system-config.core.ts
@@ -13,11 +13,10 @@ import {
VideoCodec,
} from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Injectable, Logger } from '@nestjs/common';
-import { plainToClass } from 'class-transformer';
+import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import * as _ from 'lodash';
import { Subject } from 'rxjs';
-import { DeepPartial } from 'typeorm';
import { QueueName } from '../job/job.constants';
import { ISystemConfigRepository } from '../repositories';
import { SystemConfigDto } from './dto';
@@ -140,7 +139,7 @@ let instance: SystemConfigCore | null;
export class SystemConfigCore {
private logger = new Logger(SystemConfigCore.name);
private validators: SystemConfigValidator[] = [];
- private configCache: SystemConfig | null = null;
+ private configCache: SystemConfigEntity[] | null = null;
public config$ = new Subject();
@@ -218,9 +217,28 @@ export class SystemConfigCore {
this.validators.push(validator);
}
- public getConfig(force = false): Promise {
+ public async getConfig(force = false): Promise {
const configFilePath = process.env.IMMICH_CONFIG_FILE;
- return configFilePath ? this.loadFromFile(configFilePath, force) : this.loadFromDatabase();
+ const config = _.cloneDeep(defaults);
+ const overrides = configFilePath ? await this.loadFromFile(configFilePath, force) : await this.repository.load();
+
+ for (const { key, value } of overrides) {
+ // set via dot notation
+ _.set(config, key, value);
+ }
+
+ const errors = await validate(plainToInstance(SystemConfigDto, config), {
+ forbidNonWhitelisted: true,
+ forbidUnknownValues: true,
+ });
+ if (errors.length > 0) {
+ this.logger.error('Validation error', errors);
+ if (configFilePath) {
+ throw new Error(`Invalid value(s) in file: ${errors}`);
+ }
+ }
+
+ return config;
}
public async updateConfig(config: SystemConfig): Promise {
@@ -246,7 +264,13 @@ export class SystemConfigCore {
const defaultValue = _.get(defaults, key);
const isMissing = !_.has(config, key);
- if (isMissing || item.value === null || item.value === '' || item.value === defaultValue) {
+ if (
+ isMissing ||
+ item.value === null ||
+ item.value === '' ||
+ item.value === defaultValue ||
+ _.isEqual(item.value, defaultValue)
+ ) {
deletes.push(item);
continue;
}
@@ -275,34 +299,25 @@ export class SystemConfigCore {
this.config$.next(newConfig);
}
- private async loadFromDatabase() {
- const config: DeepPartial = {};
- const overrides = await this.repository.load();
- for (const { key, value } of overrides) {
- // set via dot notation
- _.set(config, key, value);
- }
-
- return plainToClass(SystemConfigDto, _.defaultsDeep(config, defaults));
- }
-
private async loadFromFile(filepath: string, force = false) {
if (force || !this.configCache) {
try {
- const overrides = JSON.parse((await this.repository.readFile(filepath)).toString());
- const config = plainToClass(SystemConfigDto, _.defaultsDeep(overrides, defaults));
+ const file = JSON.parse((await this.repository.readFile(filepath)).toString());
+ const overrides: SystemConfigEntity[] = [];
- const errors = await validate(config, {
- whitelist: true,
- forbidNonWhitelisted: true,
- forbidUnknownValues: true,
- });
- if (errors.length > 0) {
- this.logger.error('Validation error', errors);
- throw new Error(`Invalid value(s) in file: ${errors}`);
+ for (const key of Object.values(SystemConfigKey)) {
+ const value = _.get(file, key);
+ this.unsetDeep(file, key);
+ if (value !== undefined) {
+ overrides.push({ key, value });
+ }
}
- this.configCache = config;
+ if (!_.isEmpty(file)) {
+ throw new Error(`Unknown keys found: ${file}`);
+ }
+
+ this.configCache = overrides;
} catch (error: Error | any) {
this.logger.error(`Unable to load configuration file: ${filepath} due to ${error}`, error?.stack);
throw new Error('Invalid configuration file');
@@ -311,4 +326,15 @@ export class SystemConfigCore {
return this.configCache;
}
+
+ private unsetDeep(object: object, key: string) {
+ _.unset(object, key);
+ const path = key.split('.');
+ while (path.pop()) {
+ if (!_.isEmpty(_.get(object, path))) {
+ return;
+ }
+ _.unset(object, path);
+ }
+ }
}
diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts
index ecdec41fd..b094b328e 100644
--- a/server/src/domain/system-config/system-config.service.spec.ts
+++ b/server/src/domain/system-config/system-config.service.spec.ts
@@ -189,6 +189,15 @@ describe(SystemConfigService.name, () => {
expect(configMock.readFile).toHaveBeenCalledWith('immich-config.json');
});
+ it('should allow underscores in the machine learning url', async () => {
+ process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
+ const partialConfig = { machineLearning: { url: 'immich_machine_learning' } };
+ configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify(partialConfig)));
+
+ const config = await sut.getConfig();
+ expect(config.machineLearning.url).toEqual('immich_machine_learning');
+ });
+
const tests = [
{ should: 'validate numbers', config: { ffmpeg: { crf: 'not-a-number' } } },
{ should: 'validate booleans', config: { oauth: { enabled: 'invalid' } } },
@@ -230,6 +239,9 @@ describe(SystemConfigService.name, () => {
'{{y}}-{{MMMM}}-{{dd}}/{{filename}}',
'{{y}}/{{y}}-{{MM}}/{{filename}}',
'{{y}}/{{y}}-{{WW}}/{{filename}}',
+ '{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}',
+ '{{y}}/{{y}}-{{MM}}/{{assetId}}',
+ '{{y}}/{{y}}-{{WW}}/{{assetId}}',
],
secondOptions: ['s', 'ss'],
weekOptions: ['W', 'WW'],
diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/immich/api-v1/asset/asset-repository.ts
index a41b18341..e0e239f6d 100644
--- a/server/src/immich/api-v1/asset/asset-repository.ts
+++ b/server/src/immich/api-v1/asset/asset-repository.ts
@@ -109,6 +109,7 @@ export class AssetRepository implements IAssetRepository {
faces: {
person: true,
},
+ stack: true,
},
// We are specifically asking for this asset. Return it even if it is soft deleted
withDeleted: true,
@@ -131,6 +132,7 @@ export class AssetRepository implements IAssetRepository {
relations: {
exifInfo: true,
tags: true,
+ stack: true,
},
skip: dto.skip || 0,
order: {
diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts
index d3c1fe876..415fb380d 100644
--- a/server/src/immich/api-v1/asset/asset.service.ts
+++ b/server/src/immich/api-v1/asset/asset.service.ts
@@ -196,7 +196,7 @@ export class AssetService {
const includeMetadata = this.getExifPermission(authUser);
const asset = await this._assetRepository.getById(assetId);
if (includeMetadata) {
- const data = mapAsset(asset);
+ const data = mapAsset(asset, { withStack: true });
if (data.ownerId !== authUser.id) {
data.people = [];
@@ -208,7 +208,7 @@ export class AssetService {
return data;
} else {
- return mapAsset(asset, true);
+ return mapAsset(asset, { stripMetadata: true, withStack: true });
}
}
diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts
index f4f376e98..6a91bad30 100644
--- a/server/src/immich/controllers/asset.controller.ts
+++ b/server/src/immich/controllers/asset.controller.ts
@@ -21,6 +21,7 @@ import {
TimeBucketResponseDto,
TrashAction,
UpdateAssetDto as UpdateDto,
+ UpdateStackParentDto,
} from '@app/domain';
import {
Body,
@@ -137,6 +138,12 @@ export class AssetController {
return this.service.handleTrashAction(authUser, TrashAction.RESTORE_ALL);
}
+ @Put('stack/parent')
+ @HttpCode(HttpStatus.OK)
+ updateStackParent(@AuthUser() authUser: AuthUserDto, @Body() dto: UpdateStackParentDto): Promise {
+ return this.service.updateStackParent(authUser, dto);
+ }
+
@Put(':id')
updateAsset(
@AuthUser() authUser: AuthUserDto,
diff --git a/server/src/infra/entities/asset.entity.ts b/server/src/infra/entities/asset.entity.ts
index 31935ae5f..937107f9d 100644
--- a/server/src/infra/entities/asset.entity.ts
+++ b/server/src/infra/entities/asset.entity.ts
@@ -148,6 +148,16 @@ export class AssetEntity {
@OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset)
faces!: AssetFaceEntity[];
+
+ @Column({ nullable: true })
+ stackParentId?: string | null;
+
+ @ManyToOne(() => AssetEntity, (asset) => asset.stack, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
+ @JoinColumn({ name: 'stackParentId' })
+ stackParent?: AssetEntity | null;
+
+ @OneToMany(() => AssetEntity, (asset) => asset.stackParent)
+ stack?: AssetEntity[];
}
export enum AssetType {
diff --git a/server/src/infra/migrations/1695354433573-AddStackParentIdToAssets.ts b/server/src/infra/migrations/1695354433573-AddStackParentIdToAssets.ts
new file mode 100644
index 000000000..d5150d3a8
--- /dev/null
+++ b/server/src/infra/migrations/1695354433573-AddStackParentIdToAssets.ts
@@ -0,0 +1,16 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class AddStackParentIdToAssets1695354433573 implements MigrationInterface {
+ name = 'AddStackParentIdToAssets1695354433573'
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "assets" ADD "stackParentId" uuid`);
+ await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "FK_b463c8edb01364bf2beba08ef19" FOREIGN KEY ("stackParentId") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE CASCADE`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_b463c8edb01364bf2beba08ef19"`);
+ await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "stackParentId"`);
+ }
+
+}
diff --git a/server/src/infra/repositories/album.repository.ts b/server/src/infra/repositories/album.repository.ts
index b53c93471..a8cd50414 100644
--- a/server/src/infra/repositories/album.repository.ts
+++ b/server/src/infra/repositories/album.repository.ts
@@ -1,4 +1,4 @@
-import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from '@app/domain';
+import { AlbumAsset, AlbumAssetCount, AlbumAssets, AlbumInfoOptions, IAlbumRepository } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm';
@@ -168,16 +168,27 @@ export class AlbumRepository implements IAlbumRepository {
.createQueryBuilder()
.delete()
.from('albums_assets_assets')
- .where('"albums_assets_assets"."assetsId" = :assetId', { assetId })
+ .where('"albums_assets_assets"."assetsId" = :assetId', { assetId });
+ }
+
+ async removeAssets(asset: AlbumAssets): Promise {
+ await this.dataSource
+ .createQueryBuilder()
+ .delete()
+ .from('albums_assets_assets')
+ .where({
+ albumsId: asset.albumId,
+ assetsId: In(asset.assetIds),
+ })
.execute();
}
- hasAsset(id: string, assetId: string): Promise {
+ hasAsset(asset: AlbumAsset): Promise {
return this.repository.exist({
where: {
- id,
+ id: asset.albumId,
assets: {
- id: assetId,
+ id: asset.assetId,
},
},
relations: {
@@ -186,6 +197,15 @@ export class AlbumRepository implements IAlbumRepository {
});
}
+ async addAssets({ albumId, assetIds }: AlbumAssets): Promise {
+ await this.dataSource
+ .createQueryBuilder()
+ .insert()
+ .into('albums_assets_assets', ['albumsId', 'assetsId'])
+ .values(assetIds.map((assetId) => ({ albumsId: albumId, assetsId: assetId })))
+ .execute();
+ }
+
async create(album: Partial): Promise {
return this.save(album);
}
diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts
index f6f58b926..a740cf583 100644
--- a/server/src/infra/repositories/asset.repository.ts
+++ b/server/src/infra/repositories/asset.repository.ts
@@ -97,6 +97,7 @@ export class AssetRepository implements IAssetRepository {
month,
},
)
+ .leftJoinAndSelect('entity.exifInfo', 'exifInfo')
.orderBy('entity.localDateTime', 'DESC')
.getMany();
}
@@ -111,6 +112,7 @@ export class AssetRepository implements IAssetRepository {
faces: {
person: true,
},
+ stack: true,
},
withDeleted: true,
});
@@ -191,6 +193,7 @@ export class AssetRepository implements IAssetRepository {
person: true,
},
library: true,
+ stack: true,
},
// We are specifically asking for this asset. Return it even if it is soft deleted
withDeleted: true,
@@ -480,7 +483,6 @@ export class AssetRepository implements IAssetRepository {
getTimeBuckets(options: TimeBucketOptions): Promise {
const truncated = dateTrunc(options);
-
return this.getBuilder(options)
.select(`COUNT(asset.id)::int`, 'count')
.addSelect(truncated, 'timeBucket')
@@ -508,6 +510,7 @@ export class AssetRepository implements IAssetRepository {
let builder = this.repository
.createQueryBuilder('asset')
.where('asset.isVisible = true')
+ .andWhere('asset.fileCreatedAt < NOW()')
.leftJoinAndSelect('asset.exifInfo', 'exifInfo');
if (albumId) {
@@ -537,6 +540,12 @@ export class AssetRepository implements IAssetRepository {
.andWhere('person.id = :personId', { personId });
}
+ // Hide stack children only in main timeline
+ // Uncomment after adding support for stacked assets in web client
+ // if (!isArchived && !isFavorite && !personId && !albumId && !isTrashed) {
+ // builder = builder.andWhere('asset.stackParent IS NULL');
+ // }
+
return builder;
}
}
diff --git a/server/src/infra/repositories/metadata.repository.ts b/server/src/infra/repositories/metadata.repository.ts
index 3cb53e823..63bc29dcb 100644
--- a/server/src/infra/repositories/metadata.repository.ts
+++ b/server/src/infra/repositories/metadata.repository.ts
@@ -45,6 +45,10 @@ export class MetadataRepository implements IMetadataRepository {
});
}
+ async teardown() {
+ await exiftool.end();
+ }
+
async deleteCache() {
const dumpDirectory = REVERSE_GEOCODING_DUMP_DIRECTORY;
if (dumpDirectory) {
diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts
index 1513c6297..365b07329 100644
--- a/server/src/microservices/app.service.ts
+++ b/server/src/microservices/app.service.ts
@@ -103,4 +103,8 @@ export class AppService {
await this.metadataService.init();
await this.searchService.init();
}
+
+ async teardown() {
+ await this.metadataService.teardown();
+ }
}
diff --git a/server/test/e2e/album.e2e-spec.ts b/server/test/e2e/album.e2e-spec.ts
index 633a825a7..e10f5414f 100644
--- a/server/test/e2e/album.e2e-spec.ts
+++ b/server/test/e2e/album.e2e-spec.ts
@@ -2,11 +2,10 @@ import { AlbumResponseDto, LoginResponseDto } from '@app/domain';
import { AlbumController } from '@app/immich';
import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
import { SharedLinkType } from '@app/infra/entities';
-import { INestApplication } from '@nestjs/common';
import { api } from '@test/api';
import { db } from '@test/db';
import { errorStub, uuidStub } from '@test/fixtures';
-import { createTestApp } from '@test/test-utils';
+import { testApp } from '@test/test-utils';
import request from 'supertest';
const user1SharedUser = 'user1SharedUser';
@@ -17,7 +16,6 @@ const user2SharedLink = 'user2SharedLink';
const user2NotShared = 'user2NotShared';
describe(`${AlbumController.name} (e2e)`, () => {
- let app: INestApplication;
let server: any;
let admin: LoginResponseDto;
let user1: LoginResponseDto;
@@ -27,9 +25,11 @@ describe(`${AlbumController.name} (e2e)`, () => {
let user2Albums: AlbumResponseDto[];
beforeAll(async () => {
- app = await createTestApp();
+ [server] = await testApp.create();
+ });
- server = app.getHttpServer();
+ afterAll(async () => {
+ await testApp.teardown();
});
beforeEach(async () => {
@@ -37,24 +37,30 @@ describe(`${AlbumController.name} (e2e)`, () => {
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
- await api.userApi.create(server, admin.accessToken, {
- email: 'user1@immich.app',
- password: 'Password123',
- firstName: 'User 1',
- lastName: 'Test',
- });
- user1 = await api.authApi.login(server, { email: 'user1@immich.app', password: 'Password123' });
+ await Promise.all([
+ api.userApi.create(server, admin.accessToken, {
+ email: 'user1@immich.app',
+ password: 'Password123',
+ firstName: 'User 1',
+ lastName: 'Test',
+ }),
+ api.userApi.create(server, admin.accessToken, {
+ email: 'user2@immich.app',
+ password: 'Password123',
+ firstName: 'User 2',
+ lastName: 'Test',
+ }),
+ ]);
- await api.userApi.create(server, admin.accessToken, {
- email: 'user2@immich.app',
- password: 'Password123',
- firstName: 'User 2',
- lastName: 'Test',
- });
- user2 = await api.authApi.login(server, { email: 'user2@immich.app', password: 'Password123' });
+ [user1, user2] = await Promise.all([
+ api.authApi.login(server, { email: 'user1@immich.app', password: 'Password123' }),
+ api.authApi.login(server, { email: 'user2@immich.app', password: 'Password123' }),
+ ]);
user1Asset = await api.assetApi.upload(server, user1.accessToken, 'example');
- user1Albums = await Promise.all([
+
+ const albums = await Promise.all([
+ // user 1
api.albumApi.create(server, user1.accessToken, {
albumName: user1SharedUser,
sharedWithUserIds: [user2.userId],
@@ -62,15 +68,8 @@ describe(`${AlbumController.name} (e2e)`, () => {
}),
api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink, assetIds: [user1Asset.id] }),
api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared, assetIds: [user1Asset.id] }),
- ]);
- // add shared link to user1SharedLink album
- await api.sharedLinkApi.create(server, user1.accessToken, {
- type: SharedLinkType.ALBUM,
- albumId: user1Albums[1].id,
- });
-
- user2Albums = await Promise.all([
+ // user 2
api.albumApi.create(server, user2.accessToken, {
albumName: user2SharedUser,
sharedWithUserIds: [user1.userId],
@@ -80,16 +79,22 @@ describe(`${AlbumController.name} (e2e)`, () => {
api.albumApi.create(server, user2.accessToken, { albumName: user2NotShared }),
]);
- // add shared link to user2SharedLink album
- await api.sharedLinkApi.create(server, user2.accessToken, {
- type: SharedLinkType.ALBUM,
- albumId: user2Albums[1].id,
- });
- });
+ user1Albums = albums.slice(0, 3);
+ user2Albums = albums.slice(3);
- afterAll(async () => {
- await db.disconnect();
- await app.close();
+ await Promise.all([
+ // add shared link to user1SharedLink album
+ api.sharedLinkApi.create(server, user1.accessToken, {
+ type: SharedLinkType.ALBUM,
+ albumId: user1Albums[1].id,
+ }),
+
+ // add shared link to user2SharedLink album
+ api.sharedLinkApi.create(server, user2.accessToken, {
+ type: SharedLinkType.ALBUM,
+ albumId: user2Albums[1].id,
+ }),
+ ]);
});
describe('GET /album', () => {
diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts
index 4f4021d59..b9b10e104 100644
--- a/server/test/e2e/asset.e2e-spec.ts
+++ b/server/test/e2e/asset.e2e-spec.ts
@@ -12,7 +12,7 @@ import { AssetEntity, AssetType, SharedLinkType } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { api } from '@test/api';
import { errorStub, uuidStub } from '@test/fixtures';
-import { createTestApp, db } from '@test/test-utils';
+import { db, testApp } from '@test/test-utils';
import { randomBytes } from 'crypto';
import request from 'supertest';
@@ -86,12 +86,14 @@ describe(`${AssetController.name} (e2e)`, () => {
let asset4: AssetEntity;
beforeAll(async () => {
- app = await createTestApp();
-
- server = app.getHttpServer();
+ [server, app] = await testApp.create();
assetRepository = app.get(IAssetRepository);
});
+ afterAll(async () => {
+ await testApp.teardown();
+ });
+
beforeEach(async () => {
await db.reset();
await api.authApi.adminSignUp(server);
@@ -123,11 +125,6 @@ describe(`${AssetController.name} (e2e)`, () => {
});
});
- afterAll(async () => {
- await db.disconnect();
- await app.close();
- });
-
describe('POST /asset/upload', () => {
it('should require authentication', async () => {
const { status, body } = await request(server)
@@ -589,9 +586,11 @@ describe(`${AssetController.name} (e2e)`, () => {
describe('GET /asset/map-marker', () => {
beforeEach(async () => {
- await assetRepository.save({ id: asset1.id, isArchived: true });
- await assetRepository.upsertExif({ assetId: asset1.id, latitude: 0, longitude: 0 });
- await assetRepository.upsertExif({ assetId: asset2.id, latitude: 0, longitude: 0 });
+ await Promise.all([
+ assetRepository.save({ id: asset1.id, isArchived: true }),
+ assetRepository.upsertExif({ assetId: asset1.id, latitude: 0, longitude: 0 }),
+ assetRepository.upsertExif({ assetId: asset2.id, latitude: 0, longitude: 0 }),
+ ]);
});
it('should require authentication', async () => {
@@ -627,4 +626,167 @@ describe(`${AssetController.name} (e2e)`, () => {
expect(body).toEqual([expect.objectContaining({ id: asset2.id })]);
});
});
+
+ describe('PUT /asset', () => {
+ beforeEach(async () => {
+ const { status } = await request(server)
+ .put('/asset')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] });
+
+ expect(status).toBe(204);
+ });
+
+ it('should require authentication', async () => {
+ const { status, body } = await request(server).put('/asset');
+
+ expect(status).toBe(401);
+ expect(body).toEqual(errorStub.unauthorized);
+ });
+
+ it('should require a valid parent id', async () => {
+ const { status, body } = await request(server)
+ .put('/asset')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ stackParentId: uuidStub.invalid, ids: [asset1.id] });
+
+ expect(status).toBe(400);
+ expect(body).toEqual(errorStub.badRequest(['stackParentId must be a UUID']));
+ });
+
+ it('should require access to the parent', async () => {
+ const { status, body } = await request(server)
+ .put('/asset')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ stackParentId: asset4.id, ids: [asset1.id] });
+
+ expect(status).toBe(400);
+ expect(body).toEqual(errorStub.noPermission);
+ });
+
+ it('should add stack children', async () => {
+ const parent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
+ const child = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
+
+ const { status } = await request(server)
+ .put('/asset')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ stackParentId: parent.id, ids: [child.id] });
+
+ expect(status).toBe(204);
+
+ const asset = await api.assetApi.get(server, user1.accessToken, parent.id);
+ expect(asset.stack).not.toBeUndefined();
+ expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: child.id })]));
+ });
+
+ it('should remove stack children', async () => {
+ const { status } = await request(server)
+ .put('/asset')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ removeParent: true, ids: [asset2.id] });
+
+ expect(status).toBe(204);
+
+ const asset = await api.assetApi.get(server, user1.accessToken, asset1.id);
+ expect(asset.stack).not.toBeUndefined();
+ expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })]));
+ });
+
+ it('should remove all stack children', async () => {
+ const { status } = await request(server)
+ .put('/asset')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ removeParent: true, ids: [asset2.id, asset3.id] });
+
+ expect(status).toBe(204);
+
+ const asset = await api.assetApi.get(server, user1.accessToken, asset1.id);
+ expect(asset.stack).toHaveLength(0);
+ });
+
+ it('should merge stack children', async () => {
+ const newParent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
+ const { status } = await request(server)
+ .put('/asset')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ stackParentId: newParent.id, ids: [asset1.id] });
+
+ expect(status).toBe(204);
+
+ const asset = await api.assetApi.get(server, user1.accessToken, newParent.id);
+ expect(asset.stack).not.toBeUndefined();
+ expect(asset.stack).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: asset1.id }),
+ expect.objectContaining({ id: asset2.id }),
+ expect.objectContaining({ id: asset3.id }),
+ ]),
+ );
+ });
+ });
+
+ describe('PUT /asset/stack/parent', () => {
+ beforeEach(async () => {
+ const { status } = await request(server)
+ .put('/asset')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] });
+
+ expect(status).toBe(204);
+ });
+
+ it('should require authentication', async () => {
+ const { status, body } = await request(server).put('/asset/stack/parent');
+
+ expect(status).toBe(401);
+ expect(body).toEqual(errorStub.unauthorized);
+ });
+
+ it('should require a valid id', async () => {
+ const { status, body } = await request(server)
+ .put('/asset/stack/parent')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ oldParentId: uuidStub.invalid, newParentId: uuidStub.invalid });
+
+ expect(status).toBe(400);
+ expect(body).toEqual(errorStub.badRequest());
+ });
+
+ it('should require access', async () => {
+ const { status, body } = await request(server)
+ .put('/asset/stack/parent')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ oldParentId: asset4.id, newParentId: asset1.id });
+
+ expect(status).toBe(400);
+ expect(body).toEqual(errorStub.noPermission);
+ });
+
+ it('should make old parent child of new parent', async () => {
+ const { status } = await request(server)
+ .put('/asset/stack/parent')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ oldParentId: asset1.id, newParentId: asset2.id });
+
+ expect(status).toBe(200);
+
+ const asset = await api.assetApi.get(server, user1.accessToken, asset2.id);
+ expect(asset.stack).not.toBeUndefined();
+ expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset1.id })]));
+ });
+
+ it('should make all childrens of old parent, a child of new parent', async () => {
+ const { status } = await request(server)
+ .put('/asset/stack/parent')
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ oldParentId: asset1.id, newParentId: asset2.id });
+
+ expect(status).toBe(200);
+
+ const asset = await api.assetApi.get(server, user1.accessToken, asset2.id);
+ expect(asset.stack).not.toBeUndefined();
+ expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })]));
+ });
+ });
});
diff --git a/server/test/e2e/auth.e2e-spec.ts b/server/test/e2e/auth.e2e-spec.ts
index 4068634e7..a42e1e161 100644
--- a/server/test/e2e/auth.e2e-spec.ts
+++ b/server/test/e2e/auth.e2e-spec.ts
@@ -1,5 +1,4 @@
import { AuthController } from '@app/immich';
-import { INestApplication } from '@nestjs/common';
import { api } from '@test/api';
import { db } from '@test/db';
import {
@@ -12,7 +11,7 @@ import {
signupResponseStub,
uuidStub,
} from '@test/fixtures';
-import { createTestApp } from '@test/test-utils';
+import { testApp } from '@test/test-utils';
import request from 'supertest';
const firstName = 'Immich';
@@ -21,13 +20,16 @@ const password = 'Password123';
const email = 'admin@immich.app';
describe(`${AuthController.name} (e2e)`, () => {
- let app: INestApplication;
let server: any;
let accessToken: string;
beforeAll(async () => {
- app = await createTestApp();
- server = app.getHttpServer();
+ await testApp.reset();
+ [server] = await testApp.create();
+ });
+
+ afterAll(async () => {
+ await testApp.teardown();
});
beforeEach(async () => {
@@ -37,11 +39,6 @@ describe(`${AuthController.name} (e2e)`, () => {
accessToken = response.accessToken;
});
- afterAll(async () => {
- await db.disconnect();
- await app.close();
- });
-
describe('POST /auth/admin-sign-up', () => {
beforeEach(async () => {
await db.reset();
diff --git a/server/test/e2e/formats.e2e-spec.ts b/server/test/e2e/formats.e2e-spec.ts
index 98e24ec9a..f2fce83ac 100644
--- a/server/test/e2e/formats.e2e-spec.ts
+++ b/server/test/e2e/formats.e2e-spec.ts
@@ -1,11 +1,9 @@
import { LoginResponseDto } from '@app/domain';
import { AssetType, LibraryType } from '@app/infra/entities';
-import { INestApplication } from '@nestjs/common';
import { api } from '@test/api';
-import { IMMICH_TEST_ASSET_PATH, createTestApp, db, runAllTests } from '@test/test-utils';
+import { IMMICH_TEST_ASSET_PATH, db, runAllTests, testApp } from '@test/test-utils';
describe(`Supported file formats (e2e)`, () => {
- let app: INestApplication;
let server: any;
let admin: LoginResponseDto;
@@ -170,8 +168,11 @@ describe(`Supported file formats (e2e)`, () => {
const testsToRun = formatTests.filter((formatTest) => formatTest.runTest);
beforeAll(async () => {
- app = await createTestApp(true);
- server = app.getHttpServer();
+ [server] = await testApp.create({ jobs: true });
+ });
+
+ afterAll(async () => {
+ await testApp.teardown();
});
beforeEach(async () => {
@@ -181,11 +182,6 @@ describe(`Supported file formats (e2e)`, () => {
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
});
- afterAll(async () => {
- await db.disconnect();
- await app.close();
- });
-
it.each(testsToRun)('should import file of format $format', async (testedFormat) => {
const library = await api.libraryApi.create(server, admin.accessToken, {
type: LibraryType.EXTERNAL,
diff --git a/server/test/e2e/library.e2e-spec.ts b/server/test/e2e/library.e2e-spec.ts
index 742e6b7fe..9cfbe8961 100644
--- a/server/test/e2e/library.e2e-spec.ts
+++ b/server/test/e2e/library.e2e-spec.ts
@@ -1,22 +1,14 @@
import { LibraryResponseDto, LoginResponseDto } from '@app/domain';
import { LibraryController } from '@app/immich';
import { AssetType, LibraryType } from '@app/infra/entities';
-import { INestApplication } from '@nestjs/common';
import { api } from '@test/api';
-import {
- IMMICH_TEST_ASSET_PATH,
- IMMICH_TEST_ASSET_TEMP_PATH,
- createTestApp,
- db,
- restoreTempFolder,
-} from '@test/test-utils';
+import { IMMICH_TEST_ASSET_PATH, IMMICH_TEST_ASSET_TEMP_PATH, db, restoreTempFolder, testApp } from '@test/test-utils';
import * as fs from 'fs';
import request from 'supertest';
import { utimes } from 'utimes';
import { errorStub, uuidStub } from '../fixtures';
describe(`${LibraryController.name} (e2e)`, () => {
- let app: INestApplication;
let server: any;
let admin: LoginResponseDto;
@@ -35,8 +27,12 @@ describe(`${LibraryController.name} (e2e)`, () => {
};
beforeAll(async () => {
- app = await createTestApp(true);
- server = app.getHttpServer();
+ [server] = await testApp.create({ jobs: true });
+ });
+
+ afterAll(async () => {
+ await testApp.teardown();
+ await restoreTempFolder();
});
beforeEach(async () => {
@@ -46,12 +42,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
admin = await api.authApi.adminLogin(server);
});
- afterAll(async () => {
- await db.disconnect();
- await app.close();
- await restoreTempFolder();
- });
-
describe('GET /library', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/library');
diff --git a/server/test/e2e/oauth.e2e-spec.ts b/server/test/e2e/oauth.e2e-spec.ts
index d0d2137c6..879d53815 100644
--- a/server/test/e2e/oauth.e2e-spec.ts
+++ b/server/test/e2e/oauth.e2e-spec.ts
@@ -1,18 +1,19 @@
import { OAuthController } from '@app/immich';
-import { INestApplication } from '@nestjs/common';
import { api } from '@test/api';
import { db } from '@test/db';
import { errorStub } from '@test/fixtures';
-import { createTestApp } from '@test/test-utils';
+import { testApp } from '@test/test-utils';
import request from 'supertest';
describe(`${OAuthController.name} (e2e)`, () => {
- let app: INestApplication;
let server: any;
beforeAll(async () => {
- app = await createTestApp();
- server = app.getHttpServer();
+ [server] = await testApp.create();
+ });
+
+ afterAll(async () => {
+ await testApp.teardown();
});
beforeEach(async () => {
@@ -20,11 +21,6 @@ describe(`${OAuthController.name} (e2e)`, () => {
await api.authApi.adminSignUp(server);
});
- afterAll(async () => {
- await db.disconnect();
- await app.close();
- });
-
describe('POST /oauth/authorize', () => {
beforeEach(async () => {
await db.reset();
diff --git a/server/test/e2e/partner.e2e-spec.ts b/server/test/e2e/partner.e2e-spec.ts
index b0eb1d4ce..82a09dcc8 100644
--- a/server/test/e2e/partner.e2e-spec.ts
+++ b/server/test/e2e/partner.e2e-spec.ts
@@ -4,7 +4,7 @@ import { INestApplication } from '@nestjs/common';
import { api } from '@test/api';
import { db } from '@test/db';
import { errorStub } from '@test/fixtures';
-import { createTestApp } from '@test/test-utils';
+import { testApp } from '@test/test-utils';
import request from 'supertest';
const user1Dto = {
@@ -31,27 +31,29 @@ describe(`${PartnerController.name} (e2e)`, () => {
let user2: LoginResponseDto;
beforeAll(async () => {
- app = await createTestApp();
- server = app.getHttpServer();
+ [server, app] = await testApp.create();
repository = app.get(IPartnerRepository);
});
+ afterAll(async () => {
+ await testApp.teardown();
+ });
+
beforeEach(async () => {
await db.reset();
await api.authApi.adminSignUp(server);
loginResponse = await api.authApi.adminLogin(server);
accessToken = loginResponse.accessToken;
- await api.userApi.create(server, accessToken, user1Dto);
- user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password });
+ await Promise.all([
+ api.userApi.create(server, accessToken, user1Dto),
+ api.userApi.create(server, accessToken, user2Dto),
+ ]);
- await api.userApi.create(server, accessToken, user2Dto);
- user2 = await api.authApi.login(server, { email: user2Dto.email, password: user2Dto.password });
- });
-
- afterAll(async () => {
- await db.disconnect();
- await app.close();
+ [user1, user2] = await Promise.all([
+ api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password }),
+ api.authApi.login(server, { email: user2Dto.email, password: user2Dto.password }),
+ ]);
});
describe('GET /partner', () => {
diff --git a/server/test/e2e/person.e2e-spec.ts b/server/test/e2e/person.e2e-spec.ts
index f9da56fa8..bb0af4c96 100644
--- a/server/test/e2e/person.e2e-spec.ts
+++ b/server/test/e2e/person.e2e-spec.ts
@@ -5,7 +5,7 @@ import { INestApplication } from '@nestjs/common';
import { api } from '@test/api';
import { db } from '@test/db';
import { errorStub, uuidStub } from '@test/fixtures';
-import { createTestApp } from '@test/test-utils';
+import { testApp } from '@test/test-utils';
import request from 'supertest';
describe(`${PersonController.name}`, () => {
@@ -18,11 +18,14 @@ describe(`${PersonController.name}`, () => {
let hiddenPerson: PersonEntity;
beforeAll(async () => {
- app = await createTestApp();
- server = app.getHttpServer();
+ [server, app] = await testApp.create();
personRepository = app.get(IPersonRepository);
});
+ afterAll(async () => {
+ await testApp.teardown();
+ });
+
beforeEach(async () => {
await db.reset();
await api.authApi.adminSignUp(server);
@@ -46,11 +49,6 @@ describe(`${PersonController.name}`, () => {
await personRepository.createFace({ assetId: faceAsset.id, personId: hiddenPerson.id });
});
- afterAll(async () => {
- await db.disconnect();
- await app.close();
- });
-
describe('GET /person', () => {
beforeEach(async () => {});
diff --git a/server/test/e2e/server-info.e2e-spec.ts b/server/test/e2e/server-info.e2e-spec.ts
index 43cf471f4..cd6afbc07 100644
--- a/server/test/e2e/server-info.e2e-spec.ts
+++ b/server/test/e2e/server-info.e2e-spec.ts
@@ -1,21 +1,22 @@
import { LoginResponseDto } from '@app/domain';
import { ServerInfoController } from '@app/immich';
-import { INestApplication } from '@nestjs/common';
import { api } from '@test/api';
import { db } from '@test/db';
import { errorStub } from '@test/fixtures';
-import { createTestApp } from '@test/test-utils';
+import { testApp } from '@test/test-utils';
import request from 'supertest';
describe(`${ServerInfoController.name} (e2e)`, () => {
- let app: INestApplication;
let server: any;
let accessToken: string;
let loginResponse: LoginResponseDto;
beforeAll(async () => {
- app = await createTestApp();
- server = app.getHttpServer();
+ [server] = await testApp.create();
+ });
+
+ afterAll(async () => {
+ await testApp.teardown();
});
beforeEach(async () => {
@@ -25,11 +26,6 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
accessToken = loginResponse.accessToken;
});
- afterAll(async () => {
- await db.disconnect();
- await app.close();
- });
-
describe('GET /server-info', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/server-info');
diff --git a/server/test/e2e/setup.ts b/server/test/e2e/setup.ts
index 26849f468..234deb754 100644
--- a/server/test/e2e/setup.ts
+++ b/server/test/e2e/setup.ts
@@ -1,5 +1,5 @@
import { PostgreSqlContainer } from '@testcontainers/postgresql';
-import * as fs from 'fs';
+import { access } from 'fs/promises';
import path from 'path';
export default async () => {
@@ -23,8 +23,7 @@ export default async () => {
}
const directoryExists = async (dirPath: string) =>
- await fs.promises
- .access(dirPath)
+ await access(dirPath)
.then(() => true)
.catch(() => false);
diff --git a/server/test/e2e/shared-link.e2e-spec.ts b/server/test/e2e/shared-link.e2e-spec.ts
index 3a52c15a0..80d43c7c7 100644
--- a/server/test/e2e/shared-link.e2e-spec.ts
+++ b/server/test/e2e/shared-link.e2e-spec.ts
@@ -1,16 +1,10 @@
import { AlbumResponseDto, LoginResponseDto, SharedLinkResponseDto } from '@app/domain';
import { PartnerController } from '@app/immich';
import { LibraryType, SharedLinkType } from '@app/infra/entities';
-import { INestApplication } from '@nestjs/common';
import { api } from '@test/api';
import { db } from '@test/db';
import { errorStub, uuidStub } from '@test/fixtures';
-import {
- IMMICH_TEST_ASSET_PATH,
- IMMICH_TEST_ASSET_TEMP_PATH,
- createTestApp,
- restoreTempFolder,
-} from '@test/test-utils';
+import { IMMICH_TEST_ASSET_PATH, IMMICH_TEST_ASSET_TEMP_PATH, restoreTempFolder, testApp } from '@test/test-utils';
import { cp } from 'fs/promises';
import request from 'supertest';
@@ -22,7 +16,6 @@ const user1Dto = {
};
describe(`${PartnerController.name} (e2e)`, () => {
- let app: INestApplication;
let server: any;
let admin: LoginResponseDto;
let user1: LoginResponseDto;
@@ -30,8 +23,12 @@ describe(`${PartnerController.name} (e2e)`, () => {
let sharedLink: SharedLinkResponseDto;
beforeAll(async () => {
- app = await createTestApp(true);
- server = app.getHttpServer();
+ [server] = await testApp.create({ jobs: true });
+ });
+
+ afterAll(async () => {
+ await testApp.teardown();
+ await restoreTempFolder();
});
beforeEach(async () => {
@@ -49,12 +46,6 @@ describe(`${PartnerController.name} (e2e)`, () => {
});
});
- afterAll(async () => {
- await db.disconnect();
- await app.close();
- await restoreTempFolder();
- });
-
describe('GET /shared-link', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/shared-link');
diff --git a/server/test/e2e/user.e2e-spec.ts b/server/test/e2e/user.e2e-spec.ts
index af0cbde74..d20ac729f 100644
--- a/server/test/e2e/user.e2e-spec.ts
+++ b/server/test/e2e/user.e2e-spec.ts
@@ -2,10 +2,11 @@ import { LoginResponseDto, UserResponseDto, UserService } from '@app/domain';
import { AppModule, UserController } from '@app/immich';
import { UserEntity } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
+import { getRepositoryToken } from '@nestjs/typeorm';
import { api } from '@test/api';
import { db } from '@test/db';
import { errorStub, userSignupStub, userStub } from '@test/fixtures';
-import { createTestApp } from '@test/test-utils';
+import { testApp } from '@test/test-utils';
import request from 'supertest';
import { Repository } from 'typeorm';
@@ -18,10 +19,12 @@ describe(`${UserController.name}`, () => {
let userRepository: Repository;
beforeAll(async () => {
- app = await createTestApp();
- userRepository = app.select(AppModule).get('UserEntityRepository');
+ [server, app] = await testApp.create();
+ userRepository = app.select(AppModule).get(getRepositoryToken(UserEntity));
+ });
- server = app.getHttpServer();
+ afterAll(async () => {
+ await testApp.teardown();
});
beforeEach(async () => {
diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts
index 5fef9f6d1..345481843 100644
--- a/server/test/fixtures/asset.stub.ts
+++ b/server/test/fixtures/asset.stub.ts
@@ -41,6 +41,7 @@ export const assetStub = {
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
}),
+
noWebpPath: Object.freeze({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@@ -80,6 +81,7 @@ export const assetStub = {
} as ExifEntity,
deletedAt: null,
}),
+
noThumbhash: Object.freeze({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@@ -116,6 +118,7 @@ export const assetStub = {
sidecarPath: null,
deletedAt: null,
}),
+
primaryImage: Object.freeze({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@@ -154,7 +157,9 @@ export const assetStub = {
exifInfo: {
fileSizeInByte: 5_000,
} as ExifEntity,
+ stack: [{ id: 'stack-child-asset-1' } as AssetEntity, { id: 'stack-child-asset-2' } as AssetEntity],
}),
+
image: Object.freeze({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@@ -194,6 +199,7 @@ export const assetStub = {
fileSizeInByte: 5_000,
} as ExifEntity,
}),
+
external: Object.freeze({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@@ -233,6 +239,7 @@ export const assetStub = {
fileSizeInByte: 5_000,
} as ExifEntity,
}),
+
offline: Object.freeze({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@@ -272,6 +279,7 @@ export const assetStub = {
} as ExifEntity,
deletedAt: null,
}),
+
image1: Object.freeze({
id: 'asset-id-1',
deviceAssetId: 'device-asset-id',
@@ -311,6 +319,7 @@ export const assetStub = {
fileSizeInByte: 5_000,
} as ExifEntity,
}),
+
imageFrom2015: Object.freeze({
id: 'asset-id-1',
deviceAssetId: 'device-asset-id',
@@ -350,6 +359,7 @@ export const assetStub = {
} as ExifEntity,
deletedAt: null,
}),
+
video: Object.freeze({
id: 'asset-id',
originalFileName: 'asset-id.ext',
@@ -389,6 +399,7 @@ export const assetStub = {
} as ExifEntity,
deletedAt: null,
}),
+
livePhotoMotionAsset: Object.freeze({
id: 'live-photo-motion-asset',
originalPath: fileStub.livePhotoMotion.originalPath,
@@ -497,10 +508,41 @@ export const assetStub = {
sidecarPath: '/original/path.ext.xmp',
deletedAt: null,
}),
- readOnly: Object.freeze({
+
+ readOnly: Object.freeze({
id: 'read-only-asset',
+ deviceAssetId: 'device-asset-id',
+ fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
+ fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
+ owner: userStub.user1,
+ ownerId: 'user-id',
+ deviceId: 'device-id',
+ originalPath: '/original/path.ext',
+ resizePath: '/uploads/user-id/thumbs/path.ext',
+ thumbhash: null,
+ checksum: Buffer.from('file hash', 'utf8'),
+ type: AssetType.IMAGE,
+ webpPath: null,
+ encodedVideoPath: null,
+ createdAt: new Date('2023-02-23T05:06:29.716Z'),
+ updatedAt: new Date('2023-02-23T05:06:29.716Z'),
+ localDateTime: new Date('2023-02-23T05:06:29.716Z'),
+ isFavorite: true,
+ isArchived: false,
isReadOnly: true,
+ isExternal: false,
+ isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
+ duration: null,
+ isVisible: true,
+ livePhotoVideo: null,
+ livePhotoVideoId: null,
+ tags: [],
+ sharedLinks: [],
+ originalFileName: 'asset-id.ext',
+ faces: [],
+ sidecarPath: '/original/path.ext.xmp',
+ deletedAt: null,
}),
};
diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts
index acb14c6b2..dd5771cf9 100644
--- a/server/test/fixtures/shared-link.stub.ts
+++ b/server/test/fixtures/shared-link.stub.ts
@@ -72,6 +72,7 @@ const assetResponse: AssetResponseDto = {
isTrashed: false,
libraryId: 'library-id',
hasMetadata: true,
+ stackCount: 0,
};
const assetResponseWithoutMetadata = {
diff --git a/server/test/repositories/album.repository.mock.ts b/server/test/repositories/album.repository.mock.ts
index 25206f028..20c355269 100644
--- a/server/test/repositories/album.repository.mock.ts
+++ b/server/test/repositories/album.repository.mock.ts
@@ -14,7 +14,9 @@ export const newAlbumRepositoryMock = (): jest.Mocked => {
softDeleteAll: jest.fn(),
deleteAll: jest.fn(),
getAll: jest.fn(),
+ addAssets: jest.fn(),
removeAsset: jest.fn(),
+ removeAssets: jest.fn(),
hasAsset: jest.fn(),
create: jest.fn(),
update: jest.fn(),
diff --git a/server/test/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts
index 13589f15b..76c6f777a 100644
--- a/server/test/repositories/metadata.repository.mock.ts
+++ b/server/test/repositories/metadata.repository.mock.ts
@@ -5,6 +5,7 @@ export const newMetadataRepositoryMock = (): jest.Mocked =>
deleteCache: jest.fn(),
getExifTags: jest.fn(),
init: jest.fn(),
+ teardown: jest.fn(),
reverseGeocode: jest.fn(),
};
};
diff --git a/server/test/test-utils.ts b/server/test/test-utils.ts
index 075e0b69f..6b45c6ee6 100644
--- a/server/test/test-utils.ts
+++ b/server/test/test-utils.ts
@@ -1,9 +1,8 @@
-import { dataSource } from '@app/infra';
-
import { IJobRepository, JobItem, JobItemHandler, QueueName } from '@app/domain';
import { AppModule } from '@app/immich';
-import { INestApplication, Logger } from '@nestjs/common';
-import { Test, TestingModule } from '@nestjs/testing';
+import { dataSource } from '@app/infra';
+import { INestApplication } from '@nestjs/common';
+import { Test } from '@nestjs/testing';
import * as fs from 'fs';
import path from 'path';
import { AppService } from '../src/microservices/app.service';
@@ -36,38 +35,48 @@ export const db = {
let _handler: JobItemHandler = () => Promise.resolve();
-export async function createTestApp(runJobs = false, log = false): Promise {
- const moduleBuilder = Test.createTestingModule({
- imports: [AppModule],
- providers: [AppService],
- })
- .overrideProvider(IJobRepository)
- .useValue({
- addHandler: (_queueName: QueueName, _concurrency: number, handler: JobItemHandler) => (_handler = handler),
- queue: (item: JobItem) => runJobs && _handler(item),
- resume: jest.fn(),
- empty: jest.fn(),
- setConcurrency: jest.fn(),
- getQueueStatus: jest.fn(),
- getJobCounts: jest.fn(),
- pause: jest.fn(),
- } as IJobRepository);
-
- const moduleFixture: TestingModule = await moduleBuilder.compile();
-
- const app = moduleFixture.createNestApplication();
- if (log) {
- app.useLogger(new Logger());
- } else {
- app.useLogger(false);
- }
- await app.init();
- const appService = app.get(AppService);
- await appService.init();
-
- return app;
+interface TestAppOptions {
+ jobs: boolean;
}
+let app: INestApplication;
+
+export const testApp = {
+ create: async (options?: TestAppOptions): Promise<[any, INestApplication]> => {
+ const { jobs } = options || { jobs: false };
+
+ const moduleFixture = await Test.createTestingModule({ imports: [AppModule], providers: [AppService] })
+ .overrideProvider(IJobRepository)
+ .useValue({
+ addHandler: (_queueName: QueueName, _concurrency: number, handler: JobItemHandler) => (_handler = handler),
+ queue: (item: JobItem) => jobs && _handler(item),
+ resume: jest.fn(),
+ empty: jest.fn(),
+ setConcurrency: jest.fn(),
+ getQueueStatus: jest.fn(),
+ getJobCounts: jest.fn(),
+ pause: jest.fn(),
+ } as IJobRepository)
+ .compile();
+
+ app = await moduleFixture.createNestApplication().init();
+
+ if (jobs) {
+ await app.get(AppService).init();
+ }
+
+ return [app.getHttpServer(), app];
+ },
+ reset: async () => {
+ await db.reset();
+ },
+ teardown: async () => {
+ await app.get(AppService).teardown();
+ await db.disconnect();
+ await app.close();
+ },
+};
+
export const runAllTests: boolean = process.env.IMMICH_RUN_ALL_TESTS === 'true';
const directoryExists = async (dirPath: string) =>
diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts
index 03fb10f50..8d278b44c 100644
--- a/web/src/api/open-api/api.ts
+++ b/web/src/api/open-api/api.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.82.0
+ * The version of the OpenAPI document: 1.82.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -399,6 +399,18 @@ export interface AssetBulkUpdateDto {
* @memberof AssetBulkUpdateDto
*/
'isFavorite'?: boolean;
+ /**
+ *
+ * @type {boolean}
+ * @memberof AssetBulkUpdateDto
+ */
+ 'removeParent'?: boolean;
+ /**
+ *
+ * @type {string}
+ * @memberof AssetBulkUpdateDto
+ */
+ 'stackParentId'?: string;
}
/**
*
@@ -748,6 +760,24 @@ export interface AssetResponseDto {
* @memberof AssetResponseDto
*/
'smartInfo'?: SmartInfoResponseDto;
+ /**
+ *
+ * @type {Array}
+ * @memberof AssetResponseDto
+ */
+ 'stack'?: Array;
+ /**
+ *
+ * @type {number}
+ * @memberof AssetResponseDto
+ */
+ 'stackCount': number;
+ /**
+ *
+ * @type {string}
+ * @memberof AssetResponseDto
+ */
+ 'stackParentId'?: string | null;
/**
*
* @type {Array}
@@ -3053,6 +3083,12 @@ export interface SharedLinkEditDto {
* @memberof SharedLinkEditDto
*/
'allowUpload'?: boolean;
+ /**
+ * Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.
+ * @type {boolean}
+ * @memberof SharedLinkEditDto
+ */
+ 'changeExpiryTime'?: boolean;
/**
*
* @type {string}
@@ -3981,6 +4017,25 @@ export interface UpdateLibraryDto {
*/
'name'?: string;
}
+/**
+ *
+ * @export
+ * @interface UpdateStackParentDto
+ */
+export interface UpdateStackParentDto {
+ /**
+ *
+ * @type {string}
+ * @memberof UpdateStackParentDto
+ */
+ 'newParentId': string;
+ /**
+ *
+ * @type {string}
+ * @memberof UpdateStackParentDto
+ */
+ 'oldParentId': string;
+}
/**
*
* @export
@@ -7135,6 +7190,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions,
};
},
+ /**
+ *
+ * @param {UpdateStackParentDto} updateStackParentDto
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ updateStackParent: async (updateStackParentDto: UpdateStackParentDto, options: AxiosRequestConfig = {}): Promise => {
+ // verify required parameter 'updateStackParentDto' is not null or undefined
+ assertParamExists('updateStackParent', 'updateStackParentDto', updateStackParentDto)
+ const localVarPath = `/asset/stack/parent`;
+ // use dummy base URL string because the URL constructor only accepts absolute URLs.
+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+ let baseOptions;
+ if (configuration) {
+ baseOptions = configuration.baseOptions;
+ }
+
+ const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
+ const localVarHeaderParameter = {} as any;
+ const localVarQueryParameter = {} as any;
+
+ // authentication cookie required
+
+ // authentication api_key required
+ await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+ // authentication bearer required
+ // http bearer authentication required
+ await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+
+ localVarHeaderParameter['Content-Type'] = 'application/json';
+
+ setSearchParams(localVarUrlObj, localVarQueryParameter);
+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+ localVarRequestOptions.data = serializeDataIfNeeded(updateStackParentDto, localVarRequestOptions, configuration)
+
+ return {
+ url: toPathString(localVarUrlObj),
+ options: localVarRequestOptions,
+ };
+ },
/**
*
* @param {File} assetData
@@ -7601,6 +7700,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
+ /**
+ *
+ * @param {UpdateStackParentDto} updateStackParentDto
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ async updateStackParent(updateStackParentDto: UpdateStackParentDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {
+ const localVarAxiosArgs = await localVarAxiosParamCreator.updateStackParent(updateStackParentDto, options);
+ return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+ },
/**
*
* @param {File} assetData
@@ -7892,6 +8001,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise {
return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
},
+ /**
+ *
+ * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig): AxiosPromise {
+ return localVarFp.updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(axios, basePath));
+ },
/**
*
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.
@@ -8499,6 +8617,20 @@ export interface AssetApiUpdateAssetsRequest {
readonly assetBulkUpdateDto: AssetBulkUpdateDto
}
+/**
+ * Request parameters for updateStackParent operation in AssetApi.
+ * @export
+ * @interface AssetApiUpdateStackParentRequest
+ */
+export interface AssetApiUpdateStackParentRequest {
+ /**
+ *
+ * @type {UpdateStackParentDto}
+ * @memberof AssetApiUpdateStackParent
+ */
+ readonly updateStackParentDto: UpdateStackParentDto
+}
+
/**
* Request parameters for uploadFile operation in AssetApi.
* @export
@@ -8939,6 +9071,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
}
+ /**
+ *
+ * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ * @memberof AssetApi
+ */
+ public updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig) {
+ return AssetApiFp(this.configuration).updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(this.axios, this.basePath));
+ }
+
/**
*
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.
diff --git a/web/src/api/open-api/base.ts b/web/src/api/open-api/base.ts
index 2abcb2b1d..84c4cbb35 100644
--- a/web/src/api/open-api/base.ts
+++ b/web/src/api/open-api/base.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.82.0
+ * The version of the OpenAPI document: 1.82.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
diff --git a/web/src/api/open-api/common.ts b/web/src/api/open-api/common.ts
index 41329bb53..39307ae08 100644
--- a/web/src/api/open-api/common.ts
+++ b/web/src/api/open-api/common.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.82.0
+ * The version of the OpenAPI document: 1.82.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
diff --git a/web/src/api/open-api/configuration.ts b/web/src/api/open-api/configuration.ts
index d49752982..c64b2eb3f 100644
--- a/web/src/api/open-api/configuration.ts
+++ b/web/src/api/open-api/configuration.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.82.0
+ * The version of the OpenAPI document: 1.82.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
diff --git a/web/src/api/open-api/index.ts b/web/src/api/open-api/index.ts
index 8dab99636..2afccb668 100644
--- a/web/src/api/open-api/index.ts
+++ b/web/src/api/open-api/index.ts
@@ -4,7 +4,7 @@
* Immich
* Immich API
*
- * The version of the OpenAPI document: 1.82.0
+ * The version of the OpenAPI document: 1.82.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
index c0018edcb..e744b182d 100644
--- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
@@ -56,6 +56,7 @@
ext: 'jpg',
filetype: 'IMG',
filetypefull: 'IMAGE',
+ assetId: 'a8312960-e277-447d-b4ea-56717ccba856',
};
const dt = luxon.DateTime.fromISO(new Date('2022-02-03T04:56:05.250').toISOString());
@@ -151,35 +152,36 @@