From cd6d8fcdfe97dc502f277f0ce28addcc315ee03a Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Mon, 18 Aug 2025 15:36:53 +0200 Subject: [PATCH 001/137] chore: elaborate dupe bot comment (#21025) Hopefully this stops people opening new threads --- .github/workflows/close-duplicates.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/close-duplicates.yml b/.github/workflows/close-duplicates.yml index 5ef56a6daf..06ff4ba0d5 100644 --- a/.github/workflows/close-duplicates.yml +++ b/.github/workflows/close-duplicates.yml @@ -51,7 +51,7 @@ jobs: run: | gh api graphql \ -f issueId="$NODE_ID" \ - -f body="This issue has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one." \ + -f body="This issue has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one. If you're sure this is not a duplicate, please leave a comment and we will reopen the thread if necessary." \ -f query=' mutation CommentAndCloseIssue($issueId: ID!, $body: String!) { addComment(input: { @@ -77,7 +77,7 @@ jobs: run: | gh api graphql \ -f discussionId="$NODE_ID" \ - -f body="This discussion has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one." \ + -f body="This discussion has automatically been closed as it is likely a duplicate. We get a lot of duplicate threads each day, which is why we ask you in the template to confirm that you searched for duplicates before opening one. If you're sure this is not a duplicate, please leave a comment and we will reopen the thread if necessary." \ -f query=' mutation CommentAndCloseDiscussion($discussionId: ID!, $body: String!) { addDiscussionComment(input: { From ec01db5c8b6304c5772d168bb7e753874e967c71 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 18 Aug 2025 10:20:08 -0500 Subject: [PATCH 002/137] refactor: bottom sheet action button (#20964) * fix: incorrect archive action shown in asset viewer' * Refactor * use enums syntax and add tests --- .../like_activity_action_button.widget.dart | 1 + .../asset_viewer/bottom_bar.widget.dart | 15 +- .../asset_viewer/bottom_sheet.widget.dart | 49 +- .../base_bottom_sheet.widget.dart | 6 +- mobile/lib/utils/action_button.utils.dart | 160 ++++ .../test/utils/action_button_utils_test.dart | 717 ++++++++++++++++++ 6 files changed, 906 insertions(+), 42 deletions(-) create mode 100644 mobile/lib/utils/action_button.utils.dart create mode 100644 mobile/test/utils/action_button_utils_test.dart diff --git a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart index 4fec4e24db..d143f600ce 100644 --- a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart @@ -44,6 +44,7 @@ class LikeActivityActionButton extends ConsumerWidget { ); return BaseActionButton( + maxWidth: 60, iconData: liked != null ? Icons.favorite : Icons.favorite_border, label: "like".t(context: context), onPressed: () => onTap(liked), diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index bb7b06113c..732afee7f9 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; @@ -31,6 +32,7 @@ class ViewerBottomBar extends ConsumerWidget { int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final isInLockedView = ref.watch(inLockedViewProvider); + final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; if (!showControls) { opacity = 0; @@ -40,10 +42,15 @@ class ViewerBottomBar extends ConsumerWidget { const ShareActionButton(source: ActionSource.viewer), if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), if (asset.type == AssetType.image) const EditImageActionButton(), - if (asset.hasRemote && isOwner) const ArchiveActionButton(source: ActionSource.viewer), - asset.isLocalOnly - ? const DeleteLocalActionButton(source: ActionSource.viewer) - : const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true), + if (isOwner) ...[ + if (asset.hasRemote && isOwner && isArchived) + const UnArchiveActionButton(source: ActionSource.viewer) + else + const ArchiveActionButton(source: ActionSource.viewer), + asset.isLocalOnly + ? const DeleteLocalActionButton(source: ActionSource.viewer) + : const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true), + ], ]; return IgnorePointer( diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index f531a29b2d..ccf6e0285e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -6,18 +6,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; @@ -26,6 +14,8 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asse import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -45,34 +35,25 @@ class AssetDetailBottomSheet extends ConsumerWidget { } final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); + final isOwner = asset is RemoteAsset && asset.ownerId == ref.watch(currentUserProvider)?.id; final isInLockedView = ref.watch(inLockedViewProvider); final currentAlbum = ref.watch(currentRemoteAlbumProvider); + final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive; - final actions = [ - const ShareActionButton(source: ActionSource.viewer), - if (currentAlbum != null && currentAlbum.isActivityEnabled && currentAlbum.isShared) - const LikeActivityActionButton(), - if (asset.hasRemote) ...[ - const ShareLinkActionButton(source: ActionSource.viewer), - const ArchiveActionButton(source: ActionSource.viewer), - if (!asset.hasLocal) const DownloadActionButton(source: ActionSource.viewer), - isTrashEnable - ? const TrashActionButton(source: ActionSource.viewer) - : const DeletePermanentActionButton(source: ActionSource.viewer), - const DeleteActionButton(source: ActionSource.viewer), - const MoveToLockFolderActionButton(source: ActionSource.viewer), - ], - if (asset.storage == AssetState.local) ...[ - const DeleteLocalActionButton(source: ActionSource.viewer), - const UploadActionButton(source: ActionSource.timeline), - ], - if (currentAlbum != null) RemoveFromAlbumActionButton(albumId: currentAlbum.id, source: ActionSource.viewer), - ]; + final buttonContext = ActionButtonContext( + asset: asset, + isOwner: isOwner, + isArchived: isArchived, + isTrashEnabled: isTrashEnable, + isInLockedView: isInLockedView, + currentAlbum: currentAlbum, + source: ActionSource.viewer, + ); - final lockedViewActions = []; + final actions = ActionButtonBuilder.build(buttonContext); return BaseBottomSheet( - actions: isInLockedView ? lockedViewActions : actions, + actions: actions, slivers: const [_AssetDetailBottomSheet()], controller: controller, initialChildSize: initialChildSize, diff --git a/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart index acbf2ad74f..0549bceb9c 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart @@ -69,10 +69,8 @@ class _BaseDraggableScrollableSheetState extends ConsumerState shouldCloseOnMinExtent: widget.shouldCloseOnMinExtent, builder: (BuildContext context, ScrollController scrollController) { return Card( - color: widget.backgroundColor ?? context.colorScheme.surface, - borderOnForeground: false, - clipBehavior: Clip.antiAlias, - elevation: 6.0, + color: widget.backgroundColor ?? context.colorScheme.surfaceContainer, + elevation: 3.0, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(18))), margin: const EdgeInsets.symmetric(horizontal: 0), child: CustomScrollView( diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart new file mode 100644 index 0000000000..10facea9a2 --- /dev/null +++ b/mobile/lib/utils/action_button.utils.dart @@ -0,0 +1,160 @@ +import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +class ActionButtonContext { + final BaseAsset asset; + final bool isOwner; + final bool isArchived; + final bool isTrashEnabled; + final bool isInLockedView; + final RemoteAlbum? currentAlbum; + final ActionSource source; + + const ActionButtonContext({ + required this.asset, + required this.isOwner, + required this.isArchived, + required this.isTrashEnabled, + required this.isInLockedView, + required this.currentAlbum, + required this.source, + }); +} + +enum ActionButtonType { + share, + shareLink, + archive, + unarchive, + download, + trash, + deletePermanent, + delete, + moveToLockFolder, + removeFromLockFolder, + deleteLocal, + upload, + removeFromAlbum, + likeActivity; + + bool shouldShow(ActionButtonContext context) { + return switch (this) { + ActionButtonType.share => true, + ActionButtonType.shareLink => + !context.isInLockedView && // + context.asset.hasRemote, + ActionButtonType.archive => + context.isOwner && // + !context.isInLockedView && // + context.asset.hasRemote && // + !context.isArchived, + ActionButtonType.unarchive => + context.isOwner && // + !context.isInLockedView && // + context.asset.hasRemote && // + context.isArchived, + ActionButtonType.download => + !context.isInLockedView && // + context.asset.hasRemote && // + !context.asset.hasLocal, + ActionButtonType.trash => + context.isOwner && // + !context.isInLockedView && // + context.asset.hasRemote && // + context.isTrashEnabled, + ActionButtonType.deletePermanent => + context.isOwner && // + context.asset.hasRemote && // + !context.isTrashEnabled || + context.isInLockedView, + ActionButtonType.delete => + context.isOwner && // + !context.isInLockedView && // + context.asset.hasRemote, + ActionButtonType.moveToLockFolder => + context.isOwner && // + !context.isInLockedView && // + context.asset.hasRemote, + ActionButtonType.removeFromLockFolder => + context.isOwner && // + context.isInLockedView && // + context.asset.hasRemote, + ActionButtonType.deleteLocal => + !context.isInLockedView && // + context.asset.storage == AssetState.local, + ActionButtonType.upload => + !context.isInLockedView && // + context.asset.storage == AssetState.local, + ActionButtonType.removeFromAlbum => + context.isOwner && // + !context.isInLockedView && // + context.currentAlbum != null, + ActionButtonType.likeActivity => + !context.isInLockedView && + context.currentAlbum != null && + context.currentAlbum!.isActivityEnabled && + context.currentAlbum!.isShared, + }; + } + + Widget buildButton(ActionButtonContext context) { + return switch (this) { + ActionButtonType.share => ShareActionButton(source: context.source), + ActionButtonType.shareLink => ShareLinkActionButton(source: context.source), + ActionButtonType.archive => ArchiveActionButton(source: context.source), + ActionButtonType.unarchive => UnArchiveActionButton(source: context.source), + ActionButtonType.download => DownloadActionButton(source: context.source), + ActionButtonType.trash => TrashActionButton(source: context.source), + ActionButtonType.deletePermanent => DeletePermanentActionButton(source: context.source), + ActionButtonType.delete => DeleteActionButton(source: context.source), + ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton(source: context.source), + ActionButtonType.removeFromLockFolder => RemoveFromLockFolderActionButton(source: context.source), + ActionButtonType.deleteLocal => DeleteLocalActionButton(source: context.source), + ActionButtonType.upload => UploadActionButton(source: context.source), + ActionButtonType.removeFromAlbum => RemoveFromAlbumActionButton( + albumId: context.currentAlbum!.id, + source: context.source, + ), + ActionButtonType.likeActivity => const LikeActivityActionButton(), + }; + } +} + +class ActionButtonBuilder { + static const List _actionTypes = [ + ActionButtonType.share, + ActionButtonType.shareLink, + ActionButtonType.likeActivity, + ActionButtonType.archive, + ActionButtonType.unarchive, + ActionButtonType.download, + ActionButtonType.trash, + ActionButtonType.deletePermanent, + ActionButtonType.delete, + ActionButtonType.moveToLockFolder, + ActionButtonType.removeFromLockFolder, + ActionButtonType.deleteLocal, + ActionButtonType.upload, + ActionButtonType.removeFromAlbum, + ]; + + static List build(ActionButtonContext context) { + return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); + } +} diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils/action_button_utils_test.dart new file mode 100644 index 0000000000..3cb77c0b33 --- /dev/null +++ b/mobile/test/utils/action_button_utils_test.dart @@ -0,0 +1,717 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/utils/action_button.utils.dart'; + +LocalAsset createLocalAsset({ + String? remoteId, + String name = 'test.jpg', + String? checksum = 'test-checksum', + AssetType type = AssetType.image, + DateTime? createdAt, + DateTime? updatedAt, + bool isFavorite = false, +}) { + return LocalAsset( + id: 'local-id', + remoteId: remoteId, + name: name, + checksum: checksum, + type: type, + createdAt: createdAt ?? DateTime.now(), + updatedAt: updatedAt ?? DateTime.now(), + isFavorite: isFavorite, + ); +} + +RemoteAsset createRemoteAsset({ + String? localId, + String name = 'test.jpg', + String checksum = 'test-checksum', + AssetType type = AssetType.image, + DateTime? createdAt, + DateTime? updatedAt, + bool isFavorite = false, +}) { + return RemoteAsset( + id: 'remote-id', + localId: localId, + name: name, + checksum: checksum, + type: type, + ownerId: 'owner-id', + createdAt: createdAt ?? DateTime.now(), + updatedAt: updatedAt ?? DateTime.now(), + isFavorite: isFavorite, + ); +} + +RemoteAlbum createRemoteAlbum({ + String id = 'test-album-id', + String name = 'Test Album', + bool isActivityEnabled = false, + bool isShared = false, +}) { + return RemoteAlbum( + id: id, + name: name, + ownerId: 'owner-id', + description: 'Test Description', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + isActivityEnabled: isActivityEnabled, + isShared: isShared, + order: AlbumAssetOrder.asc, + assetCount: 0, + ownerName: 'Test Owner', + ); +} + +void main() { + group('ActionButtonContext', () { + test('should create context with all required parameters', () { + final asset = createLocalAsset(); + + final context = ActionButtonContext( + asset: asset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(context.asset, isA()); + expect(context.isOwner, isTrue); + expect(context.isArchived, isFalse); + expect(context.isTrashEnabled, isTrue); + expect(context.isInLockedView, isFalse); + expect(context.currentAlbum, isNull); + expect(context.source, ActionSource.timeline); + }); + }); + + group('ActionButtonType.shouldShow', () { + late BaseAsset mergedAsset; + + setUp(() { + mergedAsset = createLocalAsset(remoteId: 'remote-id'); + }); + + group('share button', () { + test('should show when not in locked view', () { + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.share.shouldShow(context), isTrue); + }); + + test('should show when in locked view', () { + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.share.shouldShow(context), isTrue); + }); + }); + + group('shareLink button', () { + test('should show when not in locked view and asset has remote', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.shareLink.shouldShow(context), isTrue); + }); + + test('should not show when in locked view', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.shareLink.shouldShow(context), isFalse); + }); + + test('should not show when asset has no remote', () { + final localAsset = createLocalAsset(); + final context = ActionButtonContext( + asset: localAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.shareLink.shouldShow(context), isFalse); + }); + }); + + group('archive button', () { + test('should show when owner, not locked, has remote, and not archived', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.archive.shouldShow(context), isTrue); + }); + + test('should not show when not owner', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: false, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.archive.shouldShow(context), isFalse); + }); + + test('should not show when in locked view', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.archive.shouldShow(context), isFalse); + }); + + test('should not show when asset has no remote', () { + final localAsset = createLocalAsset(); + final context = ActionButtonContext( + asset: localAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.archive.shouldShow(context), isFalse); + }); + + test('should not show when already archived', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: true, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.archive.shouldShow(context), isFalse); + }); + }); + + group('unarchive button', () { + test('should show when owner, not locked, has remote, and is archived', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: true, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.unarchive.shouldShow(context), isTrue); + }); + + test('should not show when not archived', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.unarchive.shouldShow(context), isFalse); + }); + + test('should not show when not owner', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: false, + isArchived: true, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.unarchive.shouldShow(context), isFalse); + }); + }); + + group('download button', () { + test('should show when not locked, has remote, and no local copy', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.download.shouldShow(context), isTrue); + }); + + test('should not show when has local copy', () { + final mergedAsset = createLocalAsset(remoteId: 'remote-id'); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.download.shouldShow(context), isFalse); + }); + + test('should not show when in locked view', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: true, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.download.shouldShow(context), isFalse); + }); + }); + + group('trash button', () { + test('should show when owner, not locked, has remote, and trash enabled', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.trash.shouldShow(context), isTrue); + }); + + test('should not show when trash disabled', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: false, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.trash.shouldShow(context), isFalse); + }); + }); + + group('deletePermanent button', () { + test('should show when owner, not locked, has remote, and trash disabled', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: false, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue); + }); + + test('should not show when trash enabled', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse); + }); + }); + + group('delete button', () { + test('should show when owner, not locked, and has remote', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.delete.shouldShow(context), isTrue); + }); + }); + + group('moveToLockFolder button', () { + test('should show when owner, not locked, and has remote', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.moveToLockFolder.shouldShow(context), isTrue); + }); + }); + + group('deleteLocal button', () { + test('should show when not locked and asset is local only', () { + final localAsset = createLocalAsset(); + final context = ActionButtonContext( + asset: localAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue); + }); + + test('should not show when asset is not local only', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse); + }); + }); + + group('upload button', () { + test('should show when not locked and asset is local only', () { + final localAsset = createLocalAsset(); + final context = ActionButtonContext( + asset: localAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.upload.shouldShow(context), isTrue); + }); + }); + + group('removeFromAlbum button', () { + test('should show when owner, not locked, and has current album', () { + final album = createRemoteAlbum(); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.removeFromAlbum.shouldShow(context), isTrue); + }); + + test('should not show when no current album', () { + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.removeFromAlbum.shouldShow(context), isFalse); + }); + }); + + group('likeActivity button', () { + test('should show when not locked, has album, activity enabled, and shared', () { + final album = createRemoteAlbum(isActivityEnabled: true, isShared: true); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.likeActivity.shouldShow(context), isTrue); + }); + + test('should not show when activity not enabled', () { + final album = createRemoteAlbum(isActivityEnabled: false, isShared: true); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.likeActivity.shouldShow(context), isFalse); + }); + + test('should not show when album not shared', () { + final album = createRemoteAlbum(isActivityEnabled: true, isShared: false); + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.likeActivity.shouldShow(context), isFalse); + }); + + test('should not show when no album', () { + final context = ActionButtonContext( + asset: mergedAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + expect(ActionButtonType.likeActivity.shouldShow(context), isFalse); + }); + }); + }); + + group('ActionButtonType.buildButton', () { + late BaseAsset asset; + late ActionButtonContext context; + + setUp(() { + asset = createLocalAsset(remoteId: 'remote-id'); + context = ActionButtonContext( + asset: asset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + }); + + test('should build correct widget for each button type', () { + for (final buttonType in ActionButtonType.values) { + if (buttonType == ActionButtonType.removeFromAlbum) { + final album = createRemoteAlbum(); + final contextWithAlbum = ActionButtonContext( + asset: asset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + source: ActionSource.timeline, + ); + final widget = buttonType.buildButton(contextWithAlbum); + expect(widget, isA()); + } else { + final widget = buttonType.buildButton(context); + expect(widget, isA()); + } + } + }); + }); + + group('ActionButtonBuilder', () { + test('should return buttons that should show', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + final widgets = ActionButtonBuilder.build(context); + + expect(widgets, isNotEmpty); + expect(widgets.length, greaterThan(0)); + }); + + test('should include album-specific buttons when album is present', () { + final remoteAsset = createRemoteAsset(); + final album = createRemoteAlbum(isActivityEnabled: true, isShared: true); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: album, + source: ActionSource.timeline, + ); + + final widgets = ActionButtonBuilder.build(context); + + expect(widgets, isNotEmpty); + }); + + test('should only include local buttons for local assets', () { + final localAsset = createLocalAsset(); + final context = ActionButtonContext( + asset: localAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + final widgets = ActionButtonBuilder.build(context); + + expect(widgets, isNotEmpty); + }); + + test('should respect archived state', () { + final remoteAsset = createRemoteAsset(); + + final archivedContext = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: true, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + final archivedWidgets = ActionButtonBuilder.build(archivedContext); + + final nonArchivedContext = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + source: ActionSource.timeline, + ); + + final nonArchivedWidgets = ActionButtonBuilder.build(nonArchivedContext); + + expect(archivedWidgets, isNotEmpty); + expect(nonArchivedWidgets, isNotEmpty); + }); + }); +} From 3d515f50729f7877d3c07a7b9849f73861e4a0a3 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 15:23:35 +0000 Subject: [PATCH 003/137] chore: version v1.138.1 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 17 files changed, 30 insertions(+), 26 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index dc9c213393..4ac9df5e10 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.78", + "version": "2.2.79", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.78", + "version": "2.2.79", "license": "GNU Affero General Public License version 3", "dependencies": { "chokidar": "^4.0.3", @@ -54,7 +54,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.138.0", + "version": "1.138.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 13895a336a..ca28453956 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.78", + "version": "2.2.79", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 7d9734980f..d3b0b41455 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.138.1", + "url": "https://v1.138.1.archive.immich.app" + }, { "label": "v1.138.0", "url": "https://v1.138.0.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 7a0532cc60..80f3c2e82f 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.138.0", + "version": "1.138.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.138.0", + "version": "1.138.1", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -46,7 +46,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.78", + "version": "2.2.79", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -95,7 +95,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.138.0", + "version": "1.138.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index eabfcd8f44..a2672bd90a 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.138.0", + "version": "1.138.1", "description": "", "main": "index.js", "type": "module", diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 527532745c..efe0bd6810 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 3003, - "android.injected.version.name" => "1.138.0", + "android.injected.version.code" => 3004, + "android.injected.version.name" => "1.138.1", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index fc965c5855..758930ff28 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -22,7 +22,7 @@ platform :ios do path: "./Runner.xcodeproj", ) increment_version_number( - version_number: "1.138.0" + version_number: "1.138.1" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 0918b85c81..0bee70d193 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.138.0 +- API version: 1.138.1 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 0002725cac..b3c4b3e748 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.138.0+3003 +version: 1.138.1+3004 environment: sdk: '>=3.8.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 85e1426d47..32b26b0cf0 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9499,7 +9499,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.138.0", + "version": "1.138.1", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index a43dc31afa..6a67eed140 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.138.0", + "version": "1.138.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.138.0", + "version": "1.138.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index e3910518f6..1018e513ac 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.138.0", + "version": "1.138.1", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index dbe7ab5f0e..7a0d12f99f 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.138.0 + * 1.138.1 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index f65967c09c..3ff669d1fd 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.138.0", + "version": "1.138.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich", - "version": "1.138.0", + "version": "1.138.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^11.0.1", diff --git a/server/package.json b/server/package.json index fc7d308a57..eeb3e44e4e 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.138.0", + "version": "1.138.1", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 1296a10d03..608589cf46 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.138.0", + "version": "1.138.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.138.0", + "version": "1.138.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", @@ -94,7 +94,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.138.0", + "version": "1.138.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 0d4d46000f..7f5cdbd61e 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.138.0", + "version": "1.138.1", "license": "GNU Affero General Public License version 3", "type": "module", "scripts": { From 257b0c74af98fe2577ed3653dc9bb167a6effbd0 Mon Sep 17 00:00:00 2001 From: Aaron Tulino Date: Mon, 18 Aug 2025 16:02:18 -0700 Subject: [PATCH 004/137] fix(mobile): show most recent image in album as thumbnail (#21037) Show most recent image in album as thumbnail Fixes #21004 --- .../lib/infrastructure/repositories/local_album.repository.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index 869d8f0dc8..db72dddc57 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -319,7 +319,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)), ]) ..where(_db.localAlbumAssetEntity.albumId.equals(albumId)) - ..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)]) + ..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)]) ..limit(1); final results = await query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get(); From a313e4338eceeae7c5a99f5bb3076d897e2cd07f Mon Sep 17 00:00:00 2001 From: xCJPECKOVERx Date: Mon, 18 Aug 2025 19:11:53 -0400 Subject: [PATCH 005/137] feat(web): Skip duplicates (#20880) * - add skip button to duplicates-compare-control * - cleanup * - change to next/previous - move buttons to duplicates page, intead of compareControl - add param based control/position * - remove index param on keep/dedupe all * - cleanup * - cleanup index corrections * - add left/right arrow keyboard shortcuts for previous/next - cleanup * - cleanup --- i18n/en.json | 2 + .../duplicates-compare-control.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 115 +++++++++++++++++- 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 50e3cb67e1..95fddca5d5 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1056,6 +1056,7 @@ "filter_people": "Filter people", "filter_places": "Filter places", "find_them_fast": "Find them fast by name with search", + "first": "First", "fix_incorrect_match": "Fix incorrect match", "folder": "Folder", "folder_not_found": "Folder not found", @@ -1177,6 +1178,7 @@ "language_search_hint": "Search languages...", "language_setting_description": "Select your preferred language", "large_files": "Large Files", + "last": "Last", "last_seen": "Last seen", "latest_version": "Latest Version", "latitude": "Latitude", diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index bbea0a7a27..ccc0249043 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -112,7 +112,7 @@ ]} /> -
+
diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index 67354d894d..cbe8f81bc5 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,10 +1,14 @@ + + {#snippet buttons()} @@ -203,13 +260,61 @@ />
- {#key duplicates[0].duplicateId} + {#key duplicates[duplicatesIndex].duplicateId} - handleResolve(duplicates[0].duplicateId, duplicateAssetIds, trashIds)} - onStack={(assets) => handleStack(duplicates[0].duplicateId, assets)} + handleResolve(duplicates[duplicatesIndex].duplicateId, duplicateAssetIds, trashIds)} + onStack={(assets) => handleStack(duplicates[duplicatesIndex].duplicateId, assets)} /> +
+
+
+ + +
+
+ + +
+
+
{/key} {:else}

From e00556a34afe71bd163529e49aaa821bf43125e4 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 18 Aug 2025 19:15:03 -0400 Subject: [PATCH 006/137] feat: get metadata about the current api key (#21027) --- mobile/openapi/README.md | 1 + mobile/openapi/lib/api/api_keys_api.dart | 41 +++++++++++++++++++ open-api/immich-openapi-specs.json | 32 +++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 8 ++++ .../controllers/api-key.controller.spec.ts | 13 ++++-- server/src/controllers/api-key.controller.ts | 8 +++- server/src/controllers/index.ts | 4 +- server/src/middleware/auth.guard.ts | 4 +- server/src/services/api-key.service.spec.ts | 37 ++++++++++++++++- server/src/services/api-key.service.ts | 15 ++++++- server/src/services/auth.service.spec.ts | 14 +++++++ server/src/services/auth.service.ts | 9 +++- 12 files changed, 174 insertions(+), 12 deletions(-) diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 0bee70d193..f0673e70b9 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -77,6 +77,7 @@ Class | Method | HTTP request | Description *APIKeysApi* | [**deleteApiKey**](doc//APIKeysApi.md#deleteapikey) | **DELETE** /api-keys/{id} | *APIKeysApi* | [**getApiKey**](doc//APIKeysApi.md#getapikey) | **GET** /api-keys/{id} | *APIKeysApi* | [**getApiKeys**](doc//APIKeysApi.md#getapikeys) | **GET** /api-keys | +*APIKeysApi* | [**getMyApiKey**](doc//APIKeysApi.md#getmyapikey) | **GET** /api-keys/me | *APIKeysApi* | [**updateApiKey**](doc//APIKeysApi.md#updateapikey) | **PUT** /api-keys/{id} | *ActivitiesApi* | [**createActivity**](doc//ActivitiesApi.md#createactivity) | **POST** /activities | *ActivitiesApi* | [**deleteActivity**](doc//ActivitiesApi.md#deleteactivity) | **DELETE** /activities/{id} | diff --git a/mobile/openapi/lib/api/api_keys_api.dart b/mobile/openapi/lib/api/api_keys_api.dart index e86c63bc6e..3ac829c30c 100644 --- a/mobile/openapi/lib/api/api_keys_api.dart +++ b/mobile/openapi/lib/api/api_keys_api.dart @@ -213,6 +213,47 @@ class APIKeysApi { return null; } + /// Performs an HTTP 'GET /api-keys/me' operation and returns the [Response]. + Future getMyApiKeyWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/api-keys/me'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getMyApiKey() async { + final response = await getMyApiKeyWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'APIKeyResponseDto',) as APIKeyResponseDto; + + } + return null; + } + /// This endpoint requires the `apiKey.update` permission. /// /// Note: This method returns the HTTP [Response]. diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 32b26b0cf0..197d414921 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1488,6 +1488,38 @@ "description": "This endpoint requires the `apiKey.create` permission." } }, + "/api-keys/me": { + "get": { + "operationId": "getMyApiKey", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIKeyResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "API Keys" + ] + } + }, "/api-keys/{id}": { "delete": { "operationId": "deleteApiKey", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7a0d12f99f..f0cdbef508 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2027,6 +2027,14 @@ export function createApiKey({ apiKeyCreateDto }: { body: apiKeyCreateDto }))); } +export function getMyApiKey(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ApiKeyResponseDto; + }>("/api-keys/me", { + ...opts + })); +} /** * This endpoint requires the `apiKey.delete` permission. */ diff --git a/server/src/controllers/api-key.controller.spec.ts b/server/src/controllers/api-key.controller.spec.ts index 993ad012cc..c6dab09a3c 100644 --- a/server/src/controllers/api-key.controller.spec.ts +++ b/server/src/controllers/api-key.controller.spec.ts @@ -1,16 +1,16 @@ -import { APIKeyController } from 'src/controllers/api-key.controller'; +import { ApiKeyController } from 'src/controllers/api-key.controller'; import { Permission } from 'src/enum'; import { ApiKeyService } from 'src/services/api-key.service'; import request from 'supertest'; import { factory } from 'test/small.factory'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; -describe(APIKeyController.name, () => { +describe(ApiKeyController.name, () => { let ctx: ControllerContext; const service = mockBaseService(ApiKeyService); beforeAll(async () => { - ctx = await controllerSetup(APIKeyController, [{ provide: ApiKeyService, useValue: service }]); + ctx = await controllerSetup(ApiKeyController, [{ provide: ApiKeyService, useValue: service }]); return () => ctx.close(); }); @@ -33,6 +33,13 @@ describe(APIKeyController.name, () => { }); }); + describe('GET /api-keys/me', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/api-keys/me`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + describe('GET /api-keys/:id', () => { it('should be an authenticated route', async () => { await request(ctx.getHttpServer()).get(`/api-keys/${factory.uuid()}`); diff --git a/server/src/controllers/api-key.controller.ts b/server/src/controllers/api-key.controller.ts index dc9e85f33a..59b6908128 100644 --- a/server/src/controllers/api-key.controller.ts +++ b/server/src/controllers/api-key.controller.ts @@ -9,7 +9,7 @@ import { UUIDParamDto } from 'src/validation'; @ApiTags('API Keys') @Controller('api-keys') -export class APIKeyController { +export class ApiKeyController { constructor(private service: ApiKeyService) {} @Post() @@ -24,6 +24,12 @@ export class APIKeyController { return this.service.getAll(auth); } + @Get('me') + @Authenticated({ permission: false }) + async getMyApiKey(@Auth() auth: AuthDto): Promise { + return this.service.getMine(auth); + } + @Get(':id') @Authenticated({ permission: Permission.ApiKeyRead }) getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 137abf103c..e3661ec794 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -1,6 +1,6 @@ import { ActivityController } from 'src/controllers/activity.controller'; import { AlbumController } from 'src/controllers/album.controller'; -import { APIKeyController } from 'src/controllers/api-key.controller'; +import { ApiKeyController } from 'src/controllers/api-key.controller'; import { AppController } from 'src/controllers/app.controller'; import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetController } from 'src/controllers/asset.controller'; @@ -34,7 +34,7 @@ import { UserController } from 'src/controllers/user.controller'; import { ViewController } from 'src/controllers/view.controller'; export const controllers = [ - APIKeyController, + ApiKeyController, ActivityController, AlbumController, AppController, diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index 80d7a37435..8af7bf7fb3 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -17,7 +17,7 @@ import { UAParser } from 'ua-parser-js'; type AdminRoute = { admin?: true }; type SharedLinkRoute = { sharedLink?: true }; -type AuthenticatedOptions = { permission?: Permission } & (AdminRoute | SharedLinkRoute); +type AuthenticatedOptions = { permission?: Permission | false } & (AdminRoute | SharedLinkRoute); export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => { const decorators: MethodDecorator[] = [ @@ -32,7 +32,7 @@ export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorat } if (options?.permission) { - decorators.push(ApiExtension(ApiCustomExtension.Permission, options.permission ?? Permission.All)); + decorators.push(ApiExtension(ApiCustomExtension.Permission, options.permission)); } if ((options as SharedLinkRoute)?.sharedLink) { diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index fffe7bb536..8d48b47f1e 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { Permission } from 'src/enum'; import { ApiKeyService } from 'src/services/api-key.service'; import { factory, newUuid } from 'test/small.factory'; @@ -134,6 +134,41 @@ describe(ApiKeyService.name, () => { }); }); + describe('getMine', () => { + it('should not work with a session token', async () => { + const session = factory.session(); + const auth = factory.auth({ session }); + + mocks.apiKey.getById.mockResolvedValue(void 0); + + await expect(sut.getMine(auth)).rejects.toBeInstanceOf(ForbiddenException); + + expect(mocks.apiKey.getById).not.toHaveBeenCalled(); + }); + + it('should throw an error if the key is not found', async () => { + const apiKey = factory.authApiKey(); + const auth = factory.auth({ apiKey }); + + mocks.apiKey.getById.mockResolvedValue(void 0); + + await expect(sut.getMine(auth)).rejects.toBeInstanceOf(BadRequestException); + + expect(mocks.apiKey.getById).toHaveBeenCalledWith(auth.user.id, apiKey.id); + }); + + it('should get a key by id', async () => { + const auth = factory.auth(); + const apiKey = factory.apiKey({ userId: auth.user.id }); + + mocks.apiKey.getById.mockResolvedValue(apiKey); + + await sut.getById(auth, apiKey.id); + + expect(mocks.apiKey.getById).toHaveBeenCalledWith(auth.user.id, apiKey.id); + }); + }); + describe('getById', () => { it('should throw an error if the key is not found', async () => { const auth = factory.auth(); diff --git a/server/src/services/api-key.service.ts b/server/src/services/api-key.service.ts index 82d4eabdfd..96671daab1 100644 --- a/server/src/services/api-key.service.ts +++ b/server/src/services/api-key.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { ApiKey } from 'src/database'; import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -46,6 +46,19 @@ export class ApiKeyService extends BaseService { await this.apiKeyRepository.delete(auth.user.id, id); } + async getMine(auth: AuthDto): Promise { + if (!auth.apiKey) { + throw new ForbiddenException('Not authenticated with an API Key'); + } + + const key = await this.apiKeyRepository.getById(auth.user.id, auth.apiKey.id); + if (!key) { + throw new BadRequestException('API Key not found'); + } + + return this.map(key); + } + async getById(auth: AuthDto, id: string): Promise { const key = await this.apiKeyRepository.getById(auth.user.id, id); if (!key) { diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index a76fc13009..f4ff0ee8c8 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -518,6 +518,20 @@ describe(AuthService.name, () => { await expect(result).rejects.toThrow('Missing required permission: all'); }); + it('should not require any permission when metadata is set to `false`', async () => { + const authUser = factory.authUser(); + const authApiKey = factory.authApiKey({ permissions: [Permission.ActivityRead] }); + + mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); + + const result = sut.authenticate({ + headers: { 'x-api-key': 'auth_token' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test', permission: false }, + }); + await expect(result).resolves.toEqual({ user: authUser, apiKey: expect.objectContaining(authApiKey) }); + }); + it('should return an auth dto', async () => { const authUser = factory.authUser(); const authApiKey = factory.authApiKey({ permissions: [Permission.All] }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 1e65ba3272..69d872e8c9 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -48,7 +48,8 @@ export type ValidateRequest = { metadata: { sharedLinkRoute: boolean; adminRoute: boolean; - permission?: Permission; + /** `false` explicitly means no permission is required, which otherwise defaults to `all` */ + permission?: Permission | false; uri: string; }; }; @@ -187,7 +188,11 @@ export class AuthService extends BaseService { throw new ForbiddenException('Forbidden'); } - if (authDto.apiKey && !isGranted({ requested: [requestedPermission], current: authDto.apiKey.permissions })) { + if ( + authDto.apiKey && + requestedPermission !== false && + !isGranted({ requested: [requestedPermission], current: authDto.apiKey.permissions }) + ) { throw new ForbiddenException(`Missing required permission: ${requestedPermission}`); } From 9ff664ed36bbf29fa4d09b969b8f6531ece4e6c9 Mon Sep 17 00:00:00 2001 From: xCJPECKOVERx Date: Mon, 18 Aug 2025 20:42:47 -0400 Subject: [PATCH 007/137] feat(web): Add to Multiple Albums (#20072) * Multi add to album picker: - update modal for multi select - Update add-to-album and add-to-album-action to work with new array return from AlbumPickerModal - Add asset-utils.addAssetsToAlbums (incomplete) * initial addToAlbums endpoint * - fix endpoint - add test * - update return type - make open-api * - simplify return dto - handle notification * - fix returns - clean up * - update i18n - format & check * - checks * - correct successId count - fix assets_cannot_be_added language call * tests * foromat * refactor * - update successful add message to included total attempted * - fix web test - format i18n * - fix open-api * - fix imports to resolve checks * - PR suggestions * open-api * refactor addAssetsToAlbums * refactor it again * - fix error returns and tests * - swap icon for IconButton - don't nest the buttons * open-api * - Cleanup multi-select button to match Thumbnail * merge and openapi * - remove onclick from icon element * - fix double onClose call with keyboard shortcuts * - spelling and formatting - apply new api permission * - open-api * chore: styling * translation --------- Co-authored-by: Alex --- i18n/en.json | 6 + mobile/openapi/README.md | 4 + mobile/openapi/lib/api.dart | 3 + mobile/openapi/lib/api/albums_api.dart | 67 ++++ mobile/openapi/lib/api_client.dart | 6 + mobile/openapi/lib/api_helper.dart | 3 + .../lib/model/albums_add_assets_dto.dart | 111 ++++++ .../model/albums_add_assets_response_dto.dart | 132 +++++++ .../lib/model/bulk_id_error_reason.dart | 91 +++++ open-api/immich-openapi-specs.json | 119 +++++++ open-api/typescript-sdk/src/fetch-client.ts | 36 ++ .../src/controllers/album.controller.spec.ts | 7 + server/src/controllers/album.controller.ts | 8 + server/src/dtos/album.dto.ts | 19 + server/src/services/album.service.spec.ts | 332 ++++++++++++++++++ server/src/services/album.service.ts | 41 ++- .../actions/add-to-album-action.svelte | 19 +- .../asset-viewer/album-list-item.svelte | 160 +++++++-- .../photos-page/actions/add-to-album.svelte | 20 +- .../album-selection-utils.spec.ts | 22 +- .../album-selection/album-selection-utils.ts | 4 + .../new-album-list-item.svelte | 6 +- web/src/lib/modals/AlbumPickerModal.svelte | 71 +++- web/src/lib/utils/asset-utils.ts | 48 +++ 24 files changed, 1280 insertions(+), 55 deletions(-) create mode 100644 mobile/openapi/lib/model/albums_add_assets_dto.dart create mode 100644 mobile/openapi/lib/model/albums_add_assets_response_dto.dart create mode 100644 mobile/openapi/lib/model/bulk_id_error_reason.dart diff --git a/i18n/en.json b/i18n/en.json index 95fddca5d5..3988cfdca7 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -28,6 +28,9 @@ "add_to_album": "Add to album", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "add_to_album_toggle": "Toggle selection for {album}", + "add_to_albums": "Add to albums", + "add_to_albums_count": "Add to albums ({count})", "add_to_shared_album": "Add to shared album", "add_url": "Add URL", "added_to_archive": "Added to archive", @@ -497,7 +500,9 @@ "assets": "Assets", "assets_added_count": "Added {count, plural, one {# asset} other {# assets}}", "assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album", + "assets_added_to_albums_count": "Added {assetTotal} assets to {albumTotal} albums", "assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album", + "assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums", "assets_count": "{count, plural, one {# asset} other {# assets}}", "assets_deleted_permanently": "{count} asset(s) deleted permanently", "assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server", @@ -514,6 +519,7 @@ "assets_trashed_count": "Trashed {count, plural, one {# asset} other {# assets}}", "assets_trashed_from_server": "{count} asset(s) trashed from the Immich server", "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} already part of the album", + "assets_were_part_of_albums_count": "{count, plural, one {Asset was} other {Assets were}} already part of the albums", "authorized_devices": "Authorized Devices", "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", "automatic_endpoint_switching_title": "Automatic URL switching", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index f0673e70b9..d07f13f7a3 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -84,6 +84,7 @@ Class | Method | HTTP request | Description *ActivitiesApi* | [**getActivities**](doc//ActivitiesApi.md#getactivities) | **GET** /activities | *ActivitiesApi* | [**getActivityStatistics**](doc//ActivitiesApi.md#getactivitystatistics) | **GET** /activities/statistics | *AlbumsApi* | [**addAssetsToAlbum**](doc//AlbumsApi.md#addassetstoalbum) | **PUT** /albums/{id}/assets | +*AlbumsApi* | [**addAssetsToAlbums**](doc//AlbumsApi.md#addassetstoalbums) | **PUT** /albums/assets | *AlbumsApi* | [**addUsersToAlbum**](doc//AlbumsApi.md#adduserstoalbum) | **PUT** /albums/{id}/users | *AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums | *AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} | @@ -300,6 +301,8 @@ Class | Method | HTTP request | Description - [AlbumUserCreateDto](doc//AlbumUserCreateDto.md) - [AlbumUserResponseDto](doc//AlbumUserResponseDto.md) - [AlbumUserRole](doc//AlbumUserRole.md) + - [AlbumsAddAssetsDto](doc//AlbumsAddAssetsDto.md) + - [AlbumsAddAssetsResponseDto](doc//AlbumsAddAssetsResponseDto.md) - [AlbumsResponse](doc//AlbumsResponse.md) - [AlbumsUpdate](doc//AlbumsUpdate.md) - [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md) @@ -334,6 +337,7 @@ Class | Method | HTTP request | Description - [AudioCodec](doc//AudioCodec.md) - [AuthStatusResponseDto](doc//AuthStatusResponseDto.md) - [AvatarUpdate](doc//AvatarUpdate.md) + - [BulkIdErrorReason](doc//BulkIdErrorReason.md) - [BulkIdResponseDto](doc//BulkIdResponseDto.md) - [BulkIdsDto](doc//BulkIdsDto.md) - [CLIPConfig](doc//CLIPConfig.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8ecb9cd5f5..f5f353c968 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -79,6 +79,8 @@ part 'model/album_user_add_dto.dart'; part 'model/album_user_create_dto.dart'; part 'model/album_user_response_dto.dart'; part 'model/album_user_role.dart'; +part 'model/albums_add_assets_dto.dart'; +part 'model/albums_add_assets_response_dto.dart'; part 'model/albums_response.dart'; part 'model/albums_update.dart'; part 'model/all_job_status_response_dto.dart'; @@ -113,6 +115,7 @@ part 'model/asset_visibility.dart'; part 'model/audio_codec.dart'; part 'model/auth_status_response_dto.dart'; part 'model/avatar_update.dart'; +part 'model/bulk_id_error_reason.dart'; part 'model/bulk_id_response_dto.dart'; part 'model/bulk_ids_dto.dart'; part 'model/clip_config.dart'; diff --git a/mobile/openapi/lib/api/albums_api.dart b/mobile/openapi/lib/api/albums_api.dart index 10674b894f..a45083669c 100644 --- a/mobile/openapi/lib/api/albums_api.dart +++ b/mobile/openapi/lib/api/albums_api.dart @@ -91,6 +91,73 @@ class AlbumsApi { return null; } + /// This endpoint requires the `albumAsset.create` permission. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [AlbumsAddAssetsDto] albumsAddAssetsDto (required): + /// + /// * [String] key: + /// + /// * [String] slug: + Future addAssetsToAlbumsWithHttpInfo(AlbumsAddAssetsDto albumsAddAssetsDto, { String? key, String? slug, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/albums/assets'; + + // ignore: prefer_final_locals + Object? postBody = albumsAddAssetsDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + if (slug != null) { + queryParams.addAll(_queryParams('', 'slug', slug)); + } + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This endpoint requires the `albumAsset.create` permission. + /// + /// Parameters: + /// + /// * [AlbumsAddAssetsDto] albumsAddAssetsDto (required): + /// + /// * [String] key: + /// + /// * [String] slug: + Future addAssetsToAlbums(AlbumsAddAssetsDto albumsAddAssetsDto, { String? key, String? slug, }) async { + final response = await addAssetsToAlbumsWithHttpInfo(albumsAddAssetsDto, key: key, slug: slug, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumsAddAssetsResponseDto',) as AlbumsAddAssetsResponseDto; + + } + return null; + } + /// This endpoint requires the `albumUser.create` permission. /// /// Note: This method returns the HTTP [Response]. diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index bd306cb216..3f31d4ed90 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -212,6 +212,10 @@ class ApiClient { return AlbumUserResponseDto.fromJson(value); case 'AlbumUserRole': return AlbumUserRoleTypeTransformer().decode(value); + case 'AlbumsAddAssetsDto': + return AlbumsAddAssetsDto.fromJson(value); + case 'AlbumsAddAssetsResponseDto': + return AlbumsAddAssetsResponseDto.fromJson(value); case 'AlbumsResponse': return AlbumsResponse.fromJson(value); case 'AlbumsUpdate': @@ -280,6 +284,8 @@ class ApiClient { return AuthStatusResponseDto.fromJson(value); case 'AvatarUpdate': return AvatarUpdate.fromJson(value); + case 'BulkIdErrorReason': + return BulkIdErrorReasonTypeTransformer().decode(value); case 'BulkIdResponseDto': return BulkIdResponseDto.fromJson(value); case 'BulkIdsDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 098d32f4f4..4adb62768b 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -79,6 +79,9 @@ String parameterToString(dynamic value) { if (value is AudioCodec) { return AudioCodecTypeTransformer().encode(value).toString(); } + if (value is BulkIdErrorReason) { + return BulkIdErrorReasonTypeTransformer().encode(value).toString(); + } if (value is CQMode) { return CQModeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/albums_add_assets_dto.dart b/mobile/openapi/lib/model/albums_add_assets_dto.dart new file mode 100644 index 0000000000..bdbf68980c --- /dev/null +++ b/mobile/openapi/lib/model/albums_add_assets_dto.dart @@ -0,0 +1,111 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AlbumsAddAssetsDto { + /// Returns a new [AlbumsAddAssetsDto] instance. + AlbumsAddAssetsDto({ + this.albumIds = const [], + this.assetIds = const [], + }); + + List albumIds; + + List assetIds; + + @override + bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsDto && + _deepEquality.equals(other.albumIds, albumIds) && + _deepEquality.equals(other.assetIds, assetIds); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (albumIds.hashCode) + + (assetIds.hashCode); + + @override + String toString() => 'AlbumsAddAssetsDto[albumIds=$albumIds, assetIds=$assetIds]'; + + Map toJson() { + final json = {}; + json[r'albumIds'] = this.albumIds; + json[r'assetIds'] = this.assetIds; + return json; + } + + /// Returns a new [AlbumsAddAssetsDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AlbumsAddAssetsDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumsAddAssetsDto"); + if (value is Map) { + final json = value.cast(); + + return AlbumsAddAssetsDto( + albumIds: json[r'albumIds'] is Iterable + ? (json[r'albumIds'] as Iterable).cast().toList(growable: false) + : const [], + assetIds: json[r'assetIds'] is Iterable + ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + 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 = AlbumsAddAssetsDto.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 = AlbumsAddAssetsDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AlbumsAddAssetsDto-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] = AlbumsAddAssetsDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'albumIds', + 'assetIds', + }; +} + diff --git a/mobile/openapi/lib/model/albums_add_assets_response_dto.dart b/mobile/openapi/lib/model/albums_add_assets_response_dto.dart new file mode 100644 index 0000000000..168b3f2c45 --- /dev/null +++ b/mobile/openapi/lib/model/albums_add_assets_response_dto.dart @@ -0,0 +1,132 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AlbumsAddAssetsResponseDto { + /// Returns a new [AlbumsAddAssetsResponseDto] instance. + AlbumsAddAssetsResponseDto({ + required this.albumSuccessCount, + required this.assetSuccessCount, + this.error, + required this.success, + }); + + int albumSuccessCount; + + int assetSuccessCount; + + /// + /// 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. + /// + BulkIdErrorReason? error; + + bool success; + + @override + bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsResponseDto && + other.albumSuccessCount == albumSuccessCount && + other.assetSuccessCount == assetSuccessCount && + other.error == error && + other.success == success; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (albumSuccessCount.hashCode) + + (assetSuccessCount.hashCode) + + (error == null ? 0 : error!.hashCode) + + (success.hashCode); + + @override + String toString() => 'AlbumsAddAssetsResponseDto[albumSuccessCount=$albumSuccessCount, assetSuccessCount=$assetSuccessCount, error=$error, success=$success]'; + + Map toJson() { + final json = {}; + json[r'albumSuccessCount'] = this.albumSuccessCount; + json[r'assetSuccessCount'] = this.assetSuccessCount; + if (this.error != null) { + json[r'error'] = this.error; + } else { + // json[r'error'] = null; + } + json[r'success'] = this.success; + return json; + } + + /// Returns a new [AlbumsAddAssetsResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AlbumsAddAssetsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumsAddAssetsResponseDto"); + if (value is Map) { + final json = value.cast(); + + return AlbumsAddAssetsResponseDto( + albumSuccessCount: mapValueOfType(json, r'albumSuccessCount')!, + assetSuccessCount: mapValueOfType(json, r'assetSuccessCount')!, + error: BulkIdErrorReason.fromJson(json[r'error']), + success: mapValueOfType(json, r'success')!, + ); + } + 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 = AlbumsAddAssetsResponseDto.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 = AlbumsAddAssetsResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AlbumsAddAssetsResponseDto-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] = AlbumsAddAssetsResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'albumSuccessCount', + 'assetSuccessCount', + 'success', + }; +} + diff --git a/mobile/openapi/lib/model/bulk_id_error_reason.dart b/mobile/openapi/lib/model/bulk_id_error_reason.dart new file mode 100644 index 0000000000..cdaf70217e --- /dev/null +++ b/mobile/openapi/lib/model/bulk_id_error_reason.dart @@ -0,0 +1,91 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class BulkIdErrorReason { + /// Instantiate a new enum with the provided [value]. + const BulkIdErrorReason._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const duplicate = BulkIdErrorReason._(r'duplicate'); + static const noPermission = BulkIdErrorReason._(r'no_permission'); + static const notFound = BulkIdErrorReason._(r'not_found'); + static const unknown = BulkIdErrorReason._(r'unknown'); + + /// List of all possible values in this [enum][BulkIdErrorReason]. + static const values = [ + duplicate, + noPermission, + notFound, + unknown, + ]; + + static BulkIdErrorReason? fromJson(dynamic value) => BulkIdErrorReasonTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = BulkIdErrorReason.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [BulkIdErrorReason] to String, +/// and [decode] dynamic data back to [BulkIdErrorReason]. +class BulkIdErrorReasonTypeTransformer { + factory BulkIdErrorReasonTypeTransformer() => _instance ??= const BulkIdErrorReasonTypeTransformer._(); + + const BulkIdErrorReasonTypeTransformer._(); + + String encode(BulkIdErrorReason data) => data.value; + + /// Decodes a [dynamic value][data] to a BulkIdErrorReason. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + BulkIdErrorReason? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'duplicate': return BulkIdErrorReason.duplicate; + case r'no_permission': return BulkIdErrorReason.noPermission; + case r'not_found': return BulkIdErrorReason.notFound; + case r'unknown': return BulkIdErrorReason.unknown; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [BulkIdErrorReasonTypeTransformer] instance. + static BulkIdErrorReasonTypeTransformer? _instance; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 197d414921..96c42f981e 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -940,6 +940,67 @@ "description": "This endpoint requires the `album.create` permission." } }, + "/albums/assets": { + "put": { + "operationId": "addAssetsToAlbums", + "parameters": [ + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "slug", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlbumsAddAssetsDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlbumsAddAssetsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Albums" + ], + "x-immich-permission": "albumAsset.create", + "description": "This endpoint requires the `albumAsset.create` permission." + } + }, "/albums/statistics": { "get": { "operationId": "getAlbumStatistics", @@ -9921,6 +9982,55 @@ ], "type": "string" }, + "AlbumsAddAssetsDto": { + "properties": { + "albumIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "assetIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "albumIds", + "assetIds" + ], + "type": "object" + }, + "AlbumsAddAssetsResponseDto": { + "properties": { + "albumSuccessCount": { + "type": "integer" + }, + "assetSuccessCount": { + "type": "integer" + }, + "error": { + "allOf": [ + { + "$ref": "#/components/schemas/BulkIdErrorReason" + } + ] + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "albumSuccessCount", + "assetSuccessCount", + "success" + ], + "type": "object" + }, "AlbumsResponse": { "properties": { "defaultAssetOrder": { @@ -10877,6 +10987,15 @@ }, "type": "object" }, + "BulkIdErrorReason": { + "enum": [ + "duplicate", + "no_permission", + "not_found", + "unknown" + ], + "type": "string" + }, "BulkIdResponseDto": { "properties": { "error": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index f0cdbef508..6dd0a5c622 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -384,6 +384,16 @@ export type CreateAlbumDto = { assetIds?: string[]; description?: string; }; +export type AlbumsAddAssetsDto = { + albumIds: string[]; + assetIds: string[]; +}; +export type AlbumsAddAssetsResponseDto = { + albumSuccessCount: number; + assetSuccessCount: number; + error?: BulkIdErrorReason; + success: boolean; +}; export type AlbumStatisticsResponseDto = { notShared: number; owned: number; @@ -1864,6 +1874,26 @@ export function createAlbum({ createAlbumDto }: { body: createAlbumDto }))); } +/** + * This endpoint requires the `albumAsset.create` permission. + */ +export function addAssetsToAlbums({ key, slug, albumsAddAssetsDto }: { + key?: string; + slug?: string; + albumsAddAssetsDto: AlbumsAddAssetsDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AlbumsAddAssetsResponseDto; + }>(`/albums/assets${QS.query(QS.explode({ + key, + slug + }))}`, oazapfts.json({ + ...opts, + method: "PUT", + body: albumsAddAssetsDto + }))); +} /** * This endpoint requires the `album.statistics` permission. */ @@ -4553,6 +4583,12 @@ export enum AssetTypeEnum { Audio = "AUDIO", Other = "OTHER" } +export enum BulkIdErrorReason { + Duplicate = "duplicate", + NoPermission = "no_permission", + NotFound = "not_found", + Unknown = "unknown" +} export enum Error { Duplicate = "duplicate", NoPermission = "no_permission", diff --git a/server/src/controllers/album.controller.spec.ts b/server/src/controllers/album.controller.spec.ts index 9b8a19c129..d13227555b 100644 --- a/server/src/controllers/album.controller.spec.ts +++ b/server/src/controllers/album.controller.spec.ts @@ -65,6 +65,13 @@ describe(AlbumController.name, () => { }); }); + describe('PUT /albums/assets', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/albums/assets`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + describe('PATCH /albums/:id', () => { it('should be an authenticated route', async () => { await request(ctx.getHttpServer()).patch(`/albums/${factory.uuid()}`).send({ albumName: 'New album name' }); diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index a331fc04f1..47f8b5603a 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -4,6 +4,8 @@ import { AddUsersDto, AlbumInfoDto, AlbumResponseDto, + AlbumsAddAssetsDto, + AlbumsAddAssetsResponseDto, AlbumStatisticsResponseDto, CreateAlbumDto, GetAlbumsDto, @@ -77,6 +79,12 @@ export class AlbumController { return this.service.addAssets(auth, id, dto); } + @Put('assets') + @Authenticated({ permission: Permission.AlbumAssetCreate, sharedLink: true }) + addAssetsToAlbums(@Auth() auth: AuthDto, @Body() dto: AlbumsAddAssetsDto): Promise { + return this.service.addAssetsToAlbums(auth, dto); + } + @Delete(':id/assets') @Authenticated({ permission: Permission.AlbumAssetDelete }) removeAssetFromAlbum( diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 3a88ba5be3..73630b63cb 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -3,6 +3,7 @@ import { Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator'; import _ from 'lodash'; import { AlbumUser, AuthSharedLink, User } from 'src/database'; +import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; @@ -54,6 +55,24 @@ export class CreateAlbumDto { assetIds?: string[]; } +export class AlbumsAddAssetsDto { + @ValidateUUID({ each: true }) + albumIds!: string[]; + + @ValidateUUID({ each: true }) + assetIds!: string[]; +} + +export class AlbumsAddAssetsResponseDto { + success!: boolean; + @ApiProperty({ type: 'integer' }) + albumSuccessCount!: number; + @ApiProperty({ type: 'integer' }) + assetSuccessCount!: number; + @ValidateEnum({ enum: BulkIdErrorReason, name: 'BulkIdErrorReason', optional: true }) + error?: BulkIdErrorReason; +} + export class UpdateAlbumDto { @Optional() @IsString() diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 6f07a31dd9..f3ba57d744 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -776,6 +776,338 @@ describe(AlbumService.name, () => { }); }); + describe('addAssetsToAlbums', () => { + it('should allow the owner to add assets', async () => { + mocks.access.album.checkOwnerAccess + .mockResolvedValueOnce(new Set(['album-123'])) + .mockResolvedValueOnce(new Set(['album-321'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); + + await expect( + sut.addAssetsToAlbums(authStub.admin, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 }); + + expect(mocks.album.update).toHaveBeenCalledTimes(2); + expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { + id: 'album-123', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-1', + }); + expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', { + id: 'album-321', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-1', + }); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); + }); + + it('should not set the thumbnail if the album has one already', async () => { + mocks.access.album.checkOwnerAccess + .mockResolvedValueOnce(new Set(['album-123'])) + .mockResolvedValueOnce(new Set(['album-321'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })) + .mockResolvedValueOnce(_.cloneDeep({ ...albumStub.oneAsset, albumThumbnailAssetId: 'asset-id' })); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); + + await expect( + sut.addAssetsToAlbums(authStub.admin, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 }); + + expect(mocks.album.update).toHaveBeenCalledTimes(2); + expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { + id: 'album-123', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-id', + }); + expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', { + id: 'album-321', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-id', + }); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); + }); + + it('should allow a shared user to add assets', async () => { + mocks.access.album.checkSharedAlbumAccess + .mockResolvedValueOnce(new Set(['album-123'])) + .mockResolvedValueOnce(new Set(['album-321'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); + + await expect( + sut.addAssetsToAlbums(authStub.user1, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 }); + + expect(mocks.album.update).toHaveBeenCalledTimes(2); + expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { + id: 'album-123', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-1', + }); + expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', { + id: 'album-321', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-1', + }); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { + id: 'album-123', + recipientId: 'admin_id', + }); + expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { + id: 'album-321', + recipientId: 'admin_id', + }); + }); + + it('should not allow a shared user with viewer access to add assets', async () => { + mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithAdmin)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); + + await expect( + sut.addAssetsToAlbums(authStub.user2, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ + success: false, + albumSuccessCount: 0, + assetSuccessCount: 0, + error: BulkIdErrorReason.UNKNOWN, + }); + + expect(mocks.album.update).not.toHaveBeenCalled(); + }); + + it('should not allow a shared link user to add assets to multiple albums', async () => { + mocks.access.album.checkSharedLinkAccess + .mockResolvedValueOnce(new Set(['album-123'])) + .mockResolvedValueOnce(new Set()); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); + + await expect( + sut.addAssetsToAlbums(authStub.adminSharedLink, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 }); + + expect(mocks.album.update).toHaveBeenCalledTimes(1); + expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { + id: 'album-123', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-1', + }); + expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { + id: 'album-123', + recipientId: 'user-id', + }); + expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith( + authStub.adminSharedLink.sharedLink?.id, + new Set(['album-123']), + ); + }); + + it('should allow adding assets shared via partner sharing', async () => { + mocks.access.album.checkOwnerAccess + .mockResolvedValueOnce(new Set(['album-123'])) + .mockResolvedValueOnce(new Set(['album-321'])); + mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); + + await expect( + sut.addAssetsToAlbums(authStub.admin, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 }); + + expect(mocks.album.update).toHaveBeenCalledTimes(2); + expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { + id: 'album-123', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-1', + }); + expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', { + id: 'album-321', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-1', + }); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith( + authStub.admin.user.id, + new Set(['asset-1', 'asset-2', 'asset-3']), + ); + }); + + it('should skip some duplicate assets', async () => { + mocks.access.album.checkOwnerAccess + .mockResolvedValueOnce(new Set(['album-123'])) + .mockResolvedValueOnce(new Set(['album-321'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds + .mockResolvedValueOnce(new Set(['asset-1', 'asset-2', 'asset-3'])) + .mockResolvedValueOnce(new Set()); + + await expect( + sut.addAssetsToAlbums(authStub.admin, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 }); + + expect(mocks.album.update).toHaveBeenCalledTimes(1); + expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-321', { + id: 'album-321', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-1', + }); + expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); + }); + + it('should skip all duplicate assets', async () => { + mocks.access.album.checkOwnerAccess + .mockResolvedValueOnce(new Set(['album-123'])) + .mockResolvedValueOnce(new Set(['album-321'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + + await expect( + sut.addAssetsToAlbums(authStub.admin, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2'], + }), + ).resolves.toEqual({ + success: false, + albumSuccessCount: 0, + assetSuccessCount: 0, + error: BulkIdErrorReason.DUPLICATE, + }); + + expect(mocks.album.update).not.toHaveBeenCalled(); + expect(mocks.album.addAssetIds).not.toHaveBeenCalled(); + }); + + it('should skip assets not shared with user', async () => { + mocks.access.album.checkSharedAlbumAccess + .mockResolvedValueOnce(new Set(['album-123'])) + .mockResolvedValueOnce(new Set(['album-321'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); + + await expect( + sut.addAssetsToAlbums(authStub.admin, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ + success: false, + albumSuccessCount: 0, + assetSuccessCount: 0, + error: BulkIdErrorReason.UNKNOWN, + }); + + expect(mocks.album.update).not.toHaveBeenCalled(); + expect(mocks.album.addAssetIds).not.toHaveBeenCalled(); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( + authStub.admin.user.id, + new Set(['asset-1', 'asset-2', 'asset-3']), + false, + ); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith( + authStub.admin.user.id, + new Set(['asset-1', 'asset-2', 'asset-3']), + ); + }); + + it('should not allow unauthorized access to the albums', async () => { + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple)); + + await expect( + sut.addAssetsToAlbums(authStub.admin, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ + success: false, + albumSuccessCount: 0, + assetSuccessCount: 0, + error: BulkIdErrorReason.UNKNOWN, + }); + + expect(mocks.album.update).not.toHaveBeenCalled(); + expect(mocks.album.addAssetIds).not.toHaveBeenCalled(); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalled(); + }); + + it('should not allow unauthorized shared link access to the album', async () => { + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset)); + + await expect( + sut.addAssetsToAlbums(authStub.adminSharedLink, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ + success: false, + albumSuccessCount: 0, + assetSuccessCount: 0, + error: BulkIdErrorReason.UNKNOWN, + }); + + expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled(); + }); + }); + describe('removeAssets', () => { it('should allow the owner to remove assets', async () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 90aefa6d72..32832f0dd3 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -3,6 +3,8 @@ import { AddUsersDto, AlbumInfoDto, AlbumResponseDto, + AlbumsAddAssetsDto, + AlbumsAddAssetsResponseDto, AlbumStatisticsResponseDto, CreateAlbumDto, GetAlbumsDto, @@ -13,7 +15,7 @@ import { UpdateAlbumDto, UpdateAlbumUserDto, } from 'src/dtos/album.dto'; -import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; +import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { Permission } from 'src/enum'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; @@ -186,6 +188,43 @@ export class AlbumService extends BaseService { return results; } + async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise { + const results: AlbumsAddAssetsResponseDto = { + success: false, + albumSuccessCount: 0, + assetSuccessCount: 0, + error: BulkIdErrorReason.DUPLICATE, + }; + const successfulAssetIds: Set = new Set(); + for (const albumId of dto.albumIds) { + try { + const albumResults = await this.addAssets(auth, albumId, { ids: dto.assetIds }); + + let success = false; + for (const res of albumResults) { + if (res.success) { + success = true; + results.success = true; + results.error = undefined; + successfulAssetIds.add(res.id); + } else if (results.error && res.error !== BulkIdErrorReason.DUPLICATE) { + results.error = BulkIdErrorReason.UNKNOWN; + } + } + if (success) { + results.albumSuccessCount++; + } + } catch { + if (results.error) { + results.error = BulkIdErrorReason.UNKNOWN; + } + } + } + results.assetSuccessCount = successfulAssetIds.size; + + return results; + } + async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { await this.requireAccess({ auth, permission: Permission.AlbumAssetDelete, ids: [id] }); diff --git a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte index dca0b7918e..2c6ac54ef7 100644 --- a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte @@ -4,7 +4,7 @@ import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import { AssetAction } from '$lib/constants'; import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte'; - import { addAssetsToAlbum } from '$lib/utils/asset-utils'; + import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import type { AssetResponseDto } from '@immich/sdk'; import { modalManager } from '@immich/ui'; @@ -20,14 +20,23 @@ let { asset, onAction, shared = false }: Props = $props(); const onClick = async () => { - const album = await modalManager.show(AlbumPickerModal, { shared }); + const albums = await modalManager.show(AlbumPickerModal, { shared }); - if (!album) { + if (!albums || albums.length === 0) { return; } - await addAssetsToAlbum(album.id, [asset.id]); - onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album }); + if (albums.length === 1) { + const album = albums[0]; + await addAssetsToAlbum(album.id, [asset.id]); + onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album }); + } else { + await addAssetsToAlbums( + albums.map((a) => a.id), + [asset.id], + ); + onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album: albums[0] }); + } }; diff --git a/web/src/lib/components/asset-viewer/album-list-item.svelte b/web/src/lib/components/asset-viewer/album-list-item.svelte index 7751bd09d8..bf2e34b7c9 100644 --- a/web/src/lib/components/asset-viewer/album-list-item.svelte +++ b/web/src/lib/components/asset-viewer/album-list-item.svelte @@ -1,8 +1,11 @@ - + + {albumNameArray[0]}{albumNameArray[1]}{albumNameArray[2]} + + + + + + + {#if mouseOver || multiSelected} + + {/if} +

diff --git a/web/src/lib/components/photos-page/actions/add-to-album.svelte b/web/src/lib/components/photos-page/actions/add-to-album.svelte index 5ec2e879c9..13a26cd137 100644 --- a/web/src/lib/components/photos-page/actions/add-to-album.svelte +++ b/web/src/lib/components/photos-page/actions/add-to-album.svelte @@ -2,7 +2,7 @@ import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte'; import type { OnAddToAlbum } from '$lib/utils/actions'; - import { addAssetsToAlbum } from '$lib/utils/asset-utils'; + import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils'; import { modalManager } from '@immich/ui'; import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -18,15 +18,23 @@ const { getAssets } = getAssetControlContext(); const onClick = async () => { - const album = await modalManager.show(AlbumPickerModal, { shared }); - - if (!album) { + const albums = await modalManager.show(AlbumPickerModal, { shared }); + if (!albums || albums.length === 0) { return; } const assetIds = [...getAssets()].map(({ id }) => id); - await addAssetsToAlbum(album.id, assetIds); - onAddToAlbum(assetIds, album.id); + if (albums.length === 1) { + const album = albums[0]; + await addAssetsToAlbum(album.id, assetIds); + onAddToAlbum(assetIds, album.id); + } else { + await addAssetsToAlbums( + albums.map(({ id }) => id), + assetIds, + ); + onAddToAlbum(assetIds, albums[0].id); + } }; diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts index 242809d58f..a078e55762 100644 --- a/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts @@ -24,19 +24,26 @@ const createAlbumRow = (album: AlbumResponseDto, selected: boolean) => ({ type: AlbumModalRowType.ALBUM_ITEM, album, selected, + multiSelected: false, }); describe('Album Modal', () => { it('non-shared with no albums configured yet shows message and new', () => { const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); - const modalRows = converter.toModalRows('', [], [], -1); + const modalRows = converter.toModalRows('', [], [], -1, []); expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_yet')]); }); it('non-shared with no matching albums shows message and new', () => { const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); - const modalRows = converter.toModalRows('matches_nothing', [], [albumFactory.build({ albumName: 'Holidays' })], -1); + const modalRows = converter.toModalRows( + 'matches_nothing', + [], + [albumFactory.build({ albumName: 'Holidays' })], + -1, + [], + ); expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_with_name_yet')]); }); @@ -44,7 +51,7 @@ describe('Album Modal', () => { it('non-shared displays single albums', () => { const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); - const modalRows = converter.toModalRows('', [], [holidayAlbum], -1); + const modalRows = converter.toModalRows('', [], [holidayAlbum], -1, []); expect(modalRows).toStrictEqual([ createNewAlbumRow(false), @@ -64,6 +71,7 @@ describe('Album Modal', () => { [holidayAlbum, constructionAlbum], [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], -1, + [], ); expect(modalRows).toStrictEqual([ @@ -90,6 +98,7 @@ describe('Album Modal', () => { [holidayAlbum, constructionAlbum], [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], -1, + [], ); expect(modalRows).toStrictEqual([ @@ -112,6 +121,7 @@ describe('Album Modal', () => { [holidayAlbum, constructionAlbum], [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], -1, + [], ); expect(modalRows).toStrictEqual([ @@ -125,7 +135,7 @@ describe('Album Modal', () => { const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); - const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0); + const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0, []); expect(modalRows).toStrictEqual([ createNewAlbumRow(true), @@ -141,7 +151,7 @@ describe('Album Modal', () => { const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); - const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1); + const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1, []); expect(modalRows).toStrictEqual([ createNewAlbumRow(false), @@ -157,7 +167,7 @@ describe('Album Modal', () => { const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); - const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3); + const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3, []); expect(modalRows).toStrictEqual([ createNewAlbumRow(false), diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts index 73f289eb1d..f016916f7f 100644 --- a/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts @@ -16,6 +16,7 @@ export enum AlbumModalRowType { export type AlbumModalRow = { type: AlbumModalRowType; selected?: boolean; + multiSelected?: boolean; text?: string; album?: AlbumResponseDto; }; @@ -41,6 +42,7 @@ export class AlbumModalRowConverter { recentAlbums: AlbumResponseDto[], albums: AlbumResponseDto[], selectedRowIndex: number, + multiSelectedAlbumIds: string[], ): AlbumModalRow[] { // only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal. const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : []; @@ -64,6 +66,7 @@ export class AlbumModalRowConverter { rows.push({ type: AlbumModalRowType.ALBUM_ITEM, selected: selectedRowIndex === i + selectedOffsetDueToNewAlbumRow, + multiSelected: multiSelectedAlbumIds.includes(album.id), album, }); } @@ -81,6 +84,7 @@ export class AlbumModalRowConverter { rows.push({ type: AlbumModalRowType.ALBUM_ITEM, selected: selectedRowIndex === i + selectedOffsetDueToNewAndRecents, + multiSelected: multiSelectedAlbumIds.includes(album.id), album, }); } diff --git a/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte b/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte index d8be0e2a30..a1adc3ef4f 100644 --- a/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte +++ b/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte @@ -1,9 +1,9 @@ @@ -70,6 +74,8 @@ {/if} - + From 88072910da098885aa3fc786244cf388fa88bd9b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 27 Aug 2025 14:31:23 -0400 Subject: [PATCH 095/137] feat: asset metadata (#20446) --- mobile/openapi/README.md | 10 + mobile/openapi/lib/api.dart | 6 + mobile/openapi/lib/api/assets_api.dart | 238 ++++++++++++- mobile/openapi/lib/api_client.dart | 12 + mobile/openapi/lib/api_helper.dart | 3 + .../openapi/lib/model/asset_metadata_key.dart | 82 +++++ .../model/asset_metadata_response_dto.dart | 115 +++++++ .../lib/model/asset_metadata_upsert_dto.dart | 99 ++++++ .../model/asset_metadata_upsert_item_dto.dart | 107 ++++++ .../model/sync_asset_metadata_delete_v1.dart | 107 ++++++ .../lib/model/sync_asset_metadata_v1.dart | 115 +++++++ .../openapi/lib/model/sync_entity_type.dart | 6 + .../openapi/lib/model/sync_request_type.dart | 3 + open-api/immich-openapi-specs.json | 314 +++++++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 74 +++++ .../src/controllers/asset.controller.spec.ts | 121 ++++++- server/src/controllers/asset.controller.ts | 35 ++ server/src/dtos/asset-media.dto.ts | 7 + server/src/dtos/asset.dto.ts | 53 ++- server/src/dtos/sync.dto.ts | 18 + server/src/enum.ts | 7 + server/src/queries/asset.repository.sql | 27 ++ server/src/queries/sync.repository.sql | 31 ++ server/src/repositories/asset.repository.ts | 42 ++- server/src/repositories/sync.repository.ts | 22 ++ server/src/repositories/user.repository.ts | 7 +- server/src/schema/functions.ts | 13 + server/src/schema/index.ts | 8 + .../1756318797207-AssetMetadataTables.ts | 58 ++++ .../tables/asset-metadata-audit.table.ts | 18 + .../src/schema/tables/asset-metadata.table.ts | 46 +++ server/src/services/asset-media.service.ts | 4 + server/src/services/asset.service.ts | 33 +- server/src/services/sync.service.ts | 29 ++ server/src/types.ts | 20 +- .../specs/sync/sync-asset-metadata.spec.ts | 126 +++++++ .../repositories/asset.repository.mock.ts | 4 + 37 files changed, 1999 insertions(+), 21 deletions(-) create mode 100644 mobile/openapi/lib/model/asset_metadata_key.dart create mode 100644 mobile/openapi/lib/model/asset_metadata_response_dto.dart create mode 100644 mobile/openapi/lib/model/asset_metadata_upsert_dto.dart create mode 100644 mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart create mode 100644 mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart create mode 100644 mobile/openapi/lib/model/sync_asset_metadata_v1.dart create mode 100644 server/src/schema/migrations/1756318797207-AssetMetadataTables.ts create mode 100644 server/src/schema/tables/asset-metadata-audit.table.ts create mode 100644 server/src/schema/tables/asset-metadata.table.ts create mode 100644 server/test/medium/specs/sync/sync-asset-metadata.spec.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 04600250b1..27a0c6fcbe 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -97,16 +97,20 @@ Class | Method | HTTP request | Description *AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | *AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | checkBulkUpload *AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist | checkExistingAssets +*AssetsApi* | [**deleteAssetMetadata**](doc//AssetsApi.md#deleteassetmetadata) | **DELETE** /assets/{id}/metadata/{key} | *AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets | *AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | *AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | getAllUserAssetsByDeviceId *AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | +*AssetsApi* | [**getAssetMetadata**](doc//AssetsApi.md#getassetmetadata) | **GET** /assets/{id}/metadata | +*AssetsApi* | [**getAssetMetadataByKey**](doc//AssetsApi.md#getassetmetadatabykey) | **GET** /assets/{id}/metadata/{key} | *AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | *AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | *AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | *AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | replaceAsset *AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | *AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | +*AssetsApi* | [**updateAssetMetadata**](doc//AssetsApi.md#updateassetmetadata) | **PUT** /assets/{id}/metadata | *AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets | *AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | *AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | @@ -328,6 +332,10 @@ Class | Method | HTTP request | Description - [AssetMediaResponseDto](doc//AssetMediaResponseDto.md) - [AssetMediaSize](doc//AssetMediaSize.md) - [AssetMediaStatus](doc//AssetMediaStatus.md) + - [AssetMetadataKey](doc//AssetMetadataKey.md) + - [AssetMetadataResponseDto](doc//AssetMetadataResponseDto.md) + - [AssetMetadataUpsertDto](doc//AssetMetadataUpsertDto.md) + - [AssetMetadataUpsertItemDto](doc//AssetMetadataUpsertItemDto.md) - [AssetOrder](doc//AssetOrder.md) - [AssetResponseDto](doc//AssetResponseDto.md) - [AssetStackResponseDto](doc//AssetStackResponseDto.md) @@ -485,6 +493,8 @@ Class | Method | HTTP request | Description - [SyncAssetExifV1](doc//SyncAssetExifV1.md) - [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md) - [SyncAssetFaceV1](doc//SyncAssetFaceV1.md) + - [SyncAssetMetadataDeleteV1](doc//SyncAssetMetadataDeleteV1.md) + - [SyncAssetMetadataV1](doc//SyncAssetMetadataV1.md) - [SyncAssetV1](doc//SyncAssetV1.md) - [SyncAuthUserV1](doc//SyncAuthUserV1.md) - [SyncEntityType](doc//SyncEntityType.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index f5f353c968..a197f17fa7 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -106,6 +106,10 @@ part 'model/asset_jobs_dto.dart'; part 'model/asset_media_response_dto.dart'; part 'model/asset_media_size.dart'; part 'model/asset_media_status.dart'; +part 'model/asset_metadata_key.dart'; +part 'model/asset_metadata_response_dto.dart'; +part 'model/asset_metadata_upsert_dto.dart'; +part 'model/asset_metadata_upsert_item_dto.dart'; part 'model/asset_order.dart'; part 'model/asset_response_dto.dart'; part 'model/asset_stack_response_dto.dart'; @@ -263,6 +267,8 @@ part 'model/sync_asset_delete_v1.dart'; part 'model/sync_asset_exif_v1.dart'; part 'model/sync_asset_face_delete_v1.dart'; part 'model/sync_asset_face_v1.dart'; +part 'model/sync_asset_metadata_delete_v1.dart'; +part 'model/sync_asset_metadata_v1.dart'; part 'model/sync_asset_v1.dart'; part 'model/sync_auth_user_v1.dart'; part 'model/sync_entity_type.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index c0de1a0801..0b53e09938 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -128,6 +128,56 @@ class AssetsApi { return null; } + /// This endpoint requires the `asset.update` permission. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetMetadataKey] key (required): + Future deleteAssetMetadataWithHttpInfo(String id, AssetMetadataKey key,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/{id}/metadata/{key}' + .replaceAll('{id}', id) + .replaceAll('{key}', key.toString()); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This endpoint requires the `asset.update` permission. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetMetadataKey] key (required): + Future deleteAssetMetadata(String id, AssetMetadataKey key,) async { + final response = await deleteAssetMetadataWithHttpInfo(id, key,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// This endpoint requires the `asset.delete` permission. /// /// Note: This method returns the HTTP [Response]. @@ -368,6 +418,120 @@ class AssetsApi { return null; } + /// This endpoint requires the `asset.read` permission. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getAssetMetadataWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/{id}/metadata' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This endpoint requires the `asset.read` permission. + /// + /// Parameters: + /// + /// * [String] id (required): + Future?> getAssetMetadata(String id,) async { + final response = await getAssetMetadataWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// This endpoint requires the `asset.read` permission. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetMetadataKey] key (required): + Future getAssetMetadataByKeyWithHttpInfo(String id, AssetMetadataKey key,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/{id}/metadata/{key}' + .replaceAll('{id}', id) + .replaceAll('{key}', key.toString()); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This endpoint requires the `asset.read` permission. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetMetadataKey] key (required): + Future getAssetMetadataByKey(String id, AssetMetadataKey key,) async { + final response = await getAssetMetadataByKeyWithHttpInfo(id, key,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetMetadataResponseDto',) as AssetMetadataResponseDto; + + } + return null; + } + /// This endpoint requires the `asset.statistics` permission. /// /// Note: This method returns the HTTP [Response]. @@ -795,6 +959,66 @@ class AssetsApi { return null; } + /// This endpoint requires the `asset.update` permission. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetMetadataUpsertDto] assetMetadataUpsertDto (required): + Future updateAssetMetadataWithHttpInfo(String id, AssetMetadataUpsertDto assetMetadataUpsertDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/{id}/metadata' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = assetMetadataUpsertDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This endpoint requires the `asset.update` permission. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetMetadataUpsertDto] assetMetadataUpsertDto (required): + Future?> updateAssetMetadata(String id, AssetMetadataUpsertDto assetMetadataUpsertDto,) async { + final response = await updateAssetMetadataWithHttpInfo(id, assetMetadataUpsertDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// This endpoint requires the `asset.update` permission. /// /// Note: This method returns the HTTP [Response]. @@ -855,6 +1079,8 @@ class AssetsApi { /// /// * [DateTime] fileModifiedAt (required): /// + /// * [List] metadata (required): + /// /// * [String] key: /// /// * [String] slug: @@ -873,7 +1099,7 @@ class AssetsApi { /// * [MultipartFile] sidecarData: /// /// * [AssetVisibility] visibility: - Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { + Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, List metadata, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { // ignore: prefer_const_declarations final apiPath = r'/assets'; @@ -936,6 +1162,10 @@ class AssetsApi { hasFields = true; mp.fields[r'livePhotoVideoId'] = parameterToString(livePhotoVideoId); } + if (metadata != null) { + hasFields = true; + mp.fields[r'metadata'] = parameterToString(metadata); + } if (sidecarData != null) { hasFields = true; mp.fields[r'sidecarData'] = sidecarData.field; @@ -974,6 +1204,8 @@ class AssetsApi { /// /// * [DateTime] fileModifiedAt (required): /// + /// * [List] metadata (required): + /// /// * [String] key: /// /// * [String] slug: @@ -992,8 +1224,8 @@ class AssetsApi { /// * [MultipartFile] sidecarData: /// /// * [AssetVisibility] visibility: - Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { - final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, visibility: visibility, ); + Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, List metadata, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { + final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, metadata, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, visibility: visibility, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 3f31d4ed90..3ea3b3c3e3 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -266,6 +266,14 @@ class ApiClient { return AssetMediaSizeTypeTransformer().decode(value); case 'AssetMediaStatus': return AssetMediaStatusTypeTransformer().decode(value); + case 'AssetMetadataKey': + return AssetMetadataKeyTypeTransformer().decode(value); + case 'AssetMetadataResponseDto': + return AssetMetadataResponseDto.fromJson(value); + case 'AssetMetadataUpsertDto': + return AssetMetadataUpsertDto.fromJson(value); + case 'AssetMetadataUpsertItemDto': + return AssetMetadataUpsertItemDto.fromJson(value); case 'AssetOrder': return AssetOrderTypeTransformer().decode(value); case 'AssetResponseDto': @@ -580,6 +588,10 @@ class ApiClient { return SyncAssetFaceDeleteV1.fromJson(value); case 'SyncAssetFaceV1': return SyncAssetFaceV1.fromJson(value); + case 'SyncAssetMetadataDeleteV1': + return SyncAssetMetadataDeleteV1.fromJson(value); + case 'SyncAssetMetadataV1': + return SyncAssetMetadataV1.fromJson(value); case 'SyncAssetV1': return SyncAssetV1.fromJson(value); case 'SyncAuthUserV1': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 4adb62768b..b34e9210c8 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -67,6 +67,9 @@ String parameterToString(dynamic value) { if (value is AssetMediaStatus) { return AssetMediaStatusTypeTransformer().encode(value).toString(); } + if (value is AssetMetadataKey) { + return AssetMetadataKeyTypeTransformer().encode(value).toString(); + } if (value is AssetOrder) { return AssetOrderTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/asset_metadata_key.dart b/mobile/openapi/lib/model/asset_metadata_key.dart new file mode 100644 index 0000000000..70186cd41c --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_key.dart @@ -0,0 +1,82 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class AssetMetadataKey { + /// Instantiate a new enum with the provided [value]. + const AssetMetadataKey._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const mobileApp = AssetMetadataKey._(r'mobile-app'); + + /// List of all possible values in this [enum][AssetMetadataKey]. + static const values = [ + mobileApp, + ]; + + static AssetMetadataKey? fromJson(dynamic value) => AssetMetadataKeyTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataKey.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [AssetMetadataKey] to String, +/// and [decode] dynamic data back to [AssetMetadataKey]. +class AssetMetadataKeyTypeTransformer { + factory AssetMetadataKeyTypeTransformer() => _instance ??= const AssetMetadataKeyTypeTransformer._(); + + const AssetMetadataKeyTypeTransformer._(); + + String encode(AssetMetadataKey data) => data.value; + + /// Decodes a [dynamic value][data] to a AssetMetadataKey. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + AssetMetadataKey? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'mobile-app': return AssetMetadataKey.mobileApp; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [AssetMetadataKeyTypeTransformer] instance. + static AssetMetadataKeyTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_response_dto.dart b/mobile/openapi/lib/model/asset_metadata_response_dto.dart new file mode 100644 index 0000000000..af5769b9bb --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_response_dto.dart @@ -0,0 +1,115 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetMetadataResponseDto { + /// Returns a new [AssetMetadataResponseDto] instance. + AssetMetadataResponseDto({ + required this.key, + required this.updatedAt, + required this.value, + }); + + AssetMetadataKey key; + + DateTime updatedAt; + + Object value; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataResponseDto && + other.key == key && + other.updatedAt == updatedAt && + other.value == value; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (key.hashCode) + + (updatedAt.hashCode) + + (value.hashCode); + + @override + String toString() => 'AssetMetadataResponseDto[key=$key, updatedAt=$updatedAt, value=$value]'; + + Map toJson() { + final json = {}; + json[r'key'] = this.key; + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + json[r'value'] = this.value; + return json; + } + + /// Returns a new [AssetMetadataResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataResponseDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataResponseDto( + key: AssetMetadataKey.fromJson(json[r'key'])!, + updatedAt: mapDateTime(json, r'updatedAt', r'')!, + value: mapValueOfType(json, r'value')!, + ); + } + 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 = AssetMetadataResponseDto.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 = AssetMetadataResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataResponseDto-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] = AssetMetadataResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'key', + 'updatedAt', + 'value', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_upsert_dto.dart b/mobile/openapi/lib/model/asset_metadata_upsert_dto.dart new file mode 100644 index 0000000000..45d044feb0 --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_upsert_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetMetadataUpsertDto { + /// Returns a new [AssetMetadataUpsertDto] instance. + AssetMetadataUpsertDto({ + this.items = const [], + }); + + List items; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataUpsertDto && + _deepEquality.equals(other.items, items); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (items.hashCode); + + @override + String toString() => 'AssetMetadataUpsertDto[items=$items]'; + + Map toJson() { + final json = {}; + json[r'items'] = this.items; + return json; + } + + /// Returns a new [AssetMetadataUpsertDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataUpsertDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataUpsertDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataUpsertDto( + items: AssetMetadataUpsertItemDto.listFromJson(json[r'items']), + ); + } + 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 = AssetMetadataUpsertDto.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 = AssetMetadataUpsertDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataUpsertDto-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] = AssetMetadataUpsertDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'items', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart b/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart new file mode 100644 index 0000000000..4b7e6579a1 --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetMetadataUpsertItemDto { + /// Returns a new [AssetMetadataUpsertItemDto] instance. + AssetMetadataUpsertItemDto({ + required this.key, + required this.value, + }); + + AssetMetadataKey key; + + Object value; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataUpsertItemDto && + other.key == key && + other.value == value; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (key.hashCode) + + (value.hashCode); + + @override + String toString() => 'AssetMetadataUpsertItemDto[key=$key, value=$value]'; + + Map toJson() { + final json = {}; + json[r'key'] = this.key; + json[r'value'] = this.value; + return json; + } + + /// Returns a new [AssetMetadataUpsertItemDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataUpsertItemDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataUpsertItemDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataUpsertItemDto( + key: AssetMetadataKey.fromJson(json[r'key'])!, + value: mapValueOfType(json, r'value')!, + ); + } + 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 = AssetMetadataUpsertItemDto.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 = AssetMetadataUpsertItemDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataUpsertItemDto-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] = AssetMetadataUpsertItemDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'key', + 'value', + }; +} + diff --git a/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart b/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart new file mode 100644 index 0000000000..c9a7ef4670 --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncAssetMetadataDeleteV1 { + /// Returns a new [SyncAssetMetadataDeleteV1] instance. + SyncAssetMetadataDeleteV1({ + required this.assetId, + required this.key, + }); + + String assetId; + + AssetMetadataKey key; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetMetadataDeleteV1 && + other.assetId == assetId && + other.key == key; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (key.hashCode); + + @override + String toString() => 'SyncAssetMetadataDeleteV1[assetId=$assetId, key=$key]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'key'] = this.key; + return json; + } + + /// Returns a new [SyncAssetMetadataDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetMetadataDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetMetadataDeleteV1"); + if (value is Map) { + final json = value.cast(); + + return SyncAssetMetadataDeleteV1( + assetId: mapValueOfType(json, r'assetId')!, + key: AssetMetadataKey.fromJson(json[r'key'])!, + ); + } + 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 = SyncAssetMetadataDeleteV1.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 = SyncAssetMetadataDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetMetadataDeleteV1-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] = SyncAssetMetadataDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'key', + }; +} + diff --git a/mobile/openapi/lib/model/sync_asset_metadata_v1.dart b/mobile/openapi/lib/model/sync_asset_metadata_v1.dart new file mode 100644 index 0000000000..720fcef947 --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_metadata_v1.dart @@ -0,0 +1,115 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncAssetMetadataV1 { + /// Returns a new [SyncAssetMetadataV1] instance. + SyncAssetMetadataV1({ + required this.assetId, + required this.key, + required this.value, + }); + + String assetId; + + AssetMetadataKey key; + + Object value; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetMetadataV1 && + other.assetId == assetId && + other.key == key && + other.value == value; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (key.hashCode) + + (value.hashCode); + + @override + String toString() => 'SyncAssetMetadataV1[assetId=$assetId, key=$key, value=$value]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'key'] = this.key; + json[r'value'] = this.value; + return json; + } + + /// Returns a new [SyncAssetMetadataV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetMetadataV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetMetadataV1"); + if (value is Map) { + final json = value.cast(); + + return SyncAssetMetadataV1( + assetId: mapValueOfType(json, r'assetId')!, + key: AssetMetadataKey.fromJson(json[r'key'])!, + value: mapValueOfType(json, r'value')!, + ); + } + 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 = SyncAssetMetadataV1.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 = SyncAssetMetadataV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetMetadataV1-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] = SyncAssetMetadataV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'key', + 'value', + }; +} + diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index f259fdc9d9..1a86b870e1 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -29,6 +29,8 @@ class SyncEntityType { static const assetV1 = SyncEntityType._(r'AssetV1'); static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1'); static const assetExifV1 = SyncEntityType._(r'AssetExifV1'); + static const assetMetadataV1 = SyncEntityType._(r'AssetMetadataV1'); + static const assetMetadataDeleteV1 = SyncEntityType._(r'AssetMetadataDeleteV1'); static const partnerV1 = SyncEntityType._(r'PartnerV1'); static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1'); static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1'); @@ -76,6 +78,8 @@ class SyncEntityType { assetV1, assetDeleteV1, assetExifV1, + assetMetadataV1, + assetMetadataDeleteV1, partnerV1, partnerDeleteV1, partnerAssetV1, @@ -158,6 +162,8 @@ class SyncEntityTypeTypeTransformer { case r'AssetV1': return SyncEntityType.assetV1; case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1; case r'AssetExifV1': return SyncEntityType.assetExifV1; + case r'AssetMetadataV1': return SyncEntityType.assetMetadataV1; + case r'AssetMetadataDeleteV1': return SyncEntityType.assetMetadataDeleteV1; case r'PartnerV1': return SyncEntityType.partnerV1; case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1; case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1; diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index 8a1857366e..c3dc1c4d61 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -30,6 +30,7 @@ class SyncRequestType { static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1'); static const assetsV1 = SyncRequestType._(r'AssetsV1'); static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1'); + static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1'); static const authUsersV1 = SyncRequestType._(r'AuthUsersV1'); static const memoriesV1 = SyncRequestType._(r'MemoriesV1'); static const memoryToAssetsV1 = SyncRequestType._(r'MemoryToAssetsV1'); @@ -52,6 +53,7 @@ class SyncRequestType { albumAssetExifsV1, assetsV1, assetExifsV1, + assetMetadataV1, authUsersV1, memoriesV1, memoryToAssetsV1, @@ -109,6 +111,7 @@ class SyncRequestTypeTypeTransformer { case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1; case r'AssetsV1': return SyncRequestType.assetsV1; case r'AssetExifsV1': return SyncRequestType.assetExifsV1; + case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1; case r'AuthUsersV1': return SyncRequestType.authUsersV1; case r'MemoriesV1': return SyncRequestType.memoriesV1; case r'MemoryToAssetsV1': return SyncRequestType.memoryToAssetsV1; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index eb9b6ac5a9..44b4e0da4f 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2245,6 +2245,203 @@ "description": "This endpoint requires the `asset.update` permission." } }, + "/assets/{id}/metadata": { + "get": { + "operationId": "getAssetMetadata", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetMetadataResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Assets" + ], + "x-immich-permission": "asset.read", + "description": "This endpoint requires the `asset.read` permission." + }, + "put": { + "operationId": "updateAssetMetadata", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMetadataUpsertDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetMetadataResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Assets" + ], + "x-immich-permission": "asset.update", + "description": "This endpoint requires the `asset.update` permission." + } + }, + "/assets/{id}/metadata/{key}": { + "delete": { + "operationId": "deleteAssetMetadata", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "key", + "required": true, + "in": "path", + "schema": { + "$ref": "#/components/schemas/AssetMetadataKey" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Assets" + ], + "x-immich-permission": "asset.update", + "description": "This endpoint requires the `asset.update` permission." + }, + "get": { + "operationId": "getAssetMetadataByKey", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "key", + "required": true, + "in": "path", + "schema": { + "$ref": "#/components/schemas/AssetMetadataKey" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMetadataResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Assets" + ], + "x-immich-permission": "asset.read", + "description": "This endpoint requires the `asset.read` permission." + } + }, "/assets/{id}/original": { "get": { "operationId": "downloadAsset", @@ -10615,6 +10812,12 @@ "format": "uuid", "type": "string" }, + "metadata": { + "items": { + "$ref": "#/components/schemas/AssetMetadataUpsertItemDto" + }, + "type": "array" + }, "sidecarData": { "format": "binary", "type": "string" @@ -10632,7 +10835,8 @@ "deviceAssetId", "deviceId", "fileCreatedAt", - "fileModifiedAt" + "fileModifiedAt", + "metadata" ], "type": "object" }, @@ -10707,6 +10911,69 @@ ], "type": "string" }, + "AssetMetadataKey": { + "enum": [ + "mobile-app" + ], + "type": "string" + }, + "AssetMetadataResponseDto": { + "properties": { + "key": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetMetadataKey" + } + ] + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "value": { + "type": "object" + } + }, + "required": [ + "key", + "updatedAt", + "value" + ], + "type": "object" + }, + "AssetMetadataUpsertDto": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AssetMetadataUpsertItemDto" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "AssetMetadataUpsertItemDto": { + "properties": { + "key": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetMetadataKey" + } + ] + }, + "value": { + "type": "object" + } + }, + "required": [ + "key", + "value" + ], + "type": "object" + }, "AssetOrder": { "enum": [ "asc", @@ -14944,6 +15211,48 @@ ], "type": "object" }, + "SyncAssetMetadataDeleteV1": { + "properties": { + "assetId": { + "type": "string" + }, + "key": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetMetadataKey" + } + ] + } + }, + "required": [ + "assetId", + "key" + ], + "type": "object" + }, + "SyncAssetMetadataV1": { + "properties": { + "assetId": { + "type": "string" + }, + "key": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetMetadataKey" + } + ] + }, + "value": { + "type": "object" + } + }, + "required": [ + "assetId", + "key", + "value" + ], + "type": "object" + }, "SyncAssetV1": { "properties": { "checksum": { @@ -15114,6 +15423,8 @@ "AssetV1", "AssetDeleteV1", "AssetExifV1", + "AssetMetadataV1", + "AssetMetadataDeleteV1", "PartnerV1", "PartnerDeleteV1", "PartnerAssetV1", @@ -15373,6 +15684,7 @@ "AlbumAssetExifsV1", "AssetsV1", "AssetExifsV1", + "AssetMetadataV1", "AuthUsersV1", "MemoriesV1", "MemoryToAssetsV1", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 08fa714823..3213b5e240 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -447,6 +447,10 @@ export type AssetBulkDeleteDto = { force?: boolean; ids: string[]; }; +export type AssetMetadataUpsertItemDto = { + key: AssetMetadataKey; + value: object; +}; export type AssetMediaCreateDto = { assetData: Blob; deviceAssetId: string; @@ -457,6 +461,7 @@ export type AssetMediaCreateDto = { filename?: string; isFavorite?: boolean; livePhotoVideoId?: string; + metadata: AssetMetadataUpsertItemDto[]; sidecarData?: Blob; visibility?: AssetVisibility; }; @@ -516,6 +521,14 @@ export type UpdateAssetDto = { rating?: number; visibility?: AssetVisibility; }; +export type AssetMetadataResponseDto = { + key: AssetMetadataKey; + updatedAt: string; + value: object; +}; +export type AssetMetadataUpsertDto = { + items: AssetMetadataUpsertItemDto[]; +}; export type AssetMediaReplaceDto = { assetData: Blob; deviceAssetId: string; @@ -2273,6 +2286,61 @@ export function updateAsset({ id, updateAssetDto }: { body: updateAssetDto }))); } +/** + * This endpoint requires the `asset.read` permission. + */ +export function getAssetMetadata({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetMetadataResponseDto[]; + }>(`/assets/${encodeURIComponent(id)}/metadata`, { + ...opts + })); +} +/** + * This endpoint requires the `asset.update` permission. + */ +export function updateAssetMetadata({ id, assetMetadataUpsertDto }: { + id: string; + assetMetadataUpsertDto: AssetMetadataUpsertDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetMetadataResponseDto[]; + }>(`/assets/${encodeURIComponent(id)}/metadata`, oazapfts.json({ + ...opts, + method: "PUT", + body: assetMetadataUpsertDto + }))); +} +/** + * This endpoint requires the `asset.update` permission. + */ +export function deleteAssetMetadata({ id, key }: { + id: string; + key: AssetMetadataKey; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/assets/${encodeURIComponent(id)}/metadata/${encodeURIComponent(key)}`, { + ...opts, + method: "DELETE" + })); +} +/** + * This endpoint requires the `asset.read` permission. + */ +export function getAssetMetadataByKey({ id, key }: { + id: string; + key: AssetMetadataKey; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetMetadataResponseDto; + }>(`/assets/${encodeURIComponent(id)}/metadata/${encodeURIComponent(key)}`, { + ...opts + })); +} /** * This endpoint requires the `asset.download` permission. */ @@ -4725,6 +4793,9 @@ export enum Permission { AdminUserDelete = "adminUser.delete", AdminAuthUnlinkAll = "adminAuth.unlinkAll" } +export enum AssetMetadataKey { + MobileApp = "mobile-app" +} export enum AssetMediaStatus { Created = "created", Replaced = "replaced", @@ -4811,6 +4882,8 @@ export enum SyncEntityType { AssetV1 = "AssetV1", AssetDeleteV1 = "AssetDeleteV1", AssetExifV1 = "AssetExifV1", + AssetMetadataV1 = "AssetMetadataV1", + AssetMetadataDeleteV1 = "AssetMetadataDeleteV1", PartnerV1 = "PartnerV1", PartnerDeleteV1 = "PartnerDeleteV1", PartnerAssetV1 = "PartnerAssetV1", @@ -4858,6 +4931,7 @@ export enum SyncRequestType { AlbumAssetExifsV1 = "AlbumAssetExifsV1", AssetsV1 = "AssetsV1", AssetExifsV1 = "AssetExifsV1", + AssetMetadataV1 = "AssetMetadataV1", AuthUsersV1 = "AuthUsersV1", MemoriesV1 = "MemoriesV1", MemoryToAssetsV1 = "MemoryToAssetsV1", diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 66d2d7c206..7a7a37fe2e 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -1,4 +1,5 @@ import { AssetController } from 'src/controllers/asset.controller'; +import { AssetMetadataKey } from 'src/enum'; import { AssetService } from 'src/services/asset.service'; import request from 'supertest'; import { factory } from 'test/small.factory'; @@ -6,14 +7,16 @@ import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils' describe(AssetController.name, () => { let ctx: ControllerContext; + const service = mockBaseService(AssetService); beforeAll(async () => { - ctx = await controllerSetup(AssetController, [{ provide: AssetService, useValue: mockBaseService(AssetService) }]); + ctx = await controllerSetup(AssetController, [{ provide: AssetService, useValue: service }]); return () => ctx.close(); }); beforeEach(() => { ctx.reset(); + service.resetAllMocks(); }); describe('PUT /assets', () => { @@ -115,4 +118,120 @@ describe(AssetController.name, () => { ); }); }); + + describe('GET /assets/:id/metadata', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('PUT /assets/:id/metadata', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({ items: [] }); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] }); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID']))); + }); + + it('should require items to be an array', async () => { + const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({}); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(['items must be an array'])); + }); + + it('should require each item to have a valid key', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/assets/${factory.uuid()}/metadata`) + .send({ items: [{ key: 'someKey' }] }); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest( + expect.arrayContaining([expect.stringContaining('items.0.key must be one of the following values')]), + ), + ); + }); + + it('should require each item to have a value', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/assets/${factory.uuid()}/metadata`) + .send({ items: [{ key: 'mobile-app', value: null }] }); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest(expect.arrayContaining([expect.stringContaining('value must be an object')])), + ); + }); + + describe(AssetMetadataKey.MobileApp, () => { + it('should accept valid data and pass to service correctly', async () => { + const assetId = factory.uuid(); + const { status } = await request(ctx.getHttpServer()) + .put(`/assets/${assetId}/metadata`) + .send({ items: [{ key: 'mobile-app', value: { iCloudId: '123' } }] }); + expect(service.upsertMetadata).toHaveBeenCalledWith(undefined, assetId, { + items: [{ key: 'mobile-app', value: { iCloudId: '123' } }], + }); + expect(status).toBe(200); + }); + + it('should work without iCloudId', async () => { + const assetId = factory.uuid(); + const { status } = await request(ctx.getHttpServer()) + .put(`/assets/${assetId}/metadata`) + .send({ items: [{ key: 'mobile-app', value: {} }] }); + expect(service.upsertMetadata).toHaveBeenCalledWith(undefined, assetId, { + items: [{ key: 'mobile-app', value: {} }], + }); + expect(status).toBe(200); + }); + }); + }); + + describe('GET /assets/:id/metadata/:key', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/mobile-app`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID']))); + }); + + it('should require a valid key', async () => { + const { status, body } = await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/invalid`); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest( + expect.arrayContaining([expect.stringContaining('key must be one of the following value')]), + ), + ); + }); + }); + + describe('DELETE /assets/:id/metadata/:key', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/mobile-app`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(['id must be a UUID'])); + }); + + it('should require a valid key', async () => { + const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/invalid`); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest([expect.stringContaining('key must be one of the following values')]), + ); + }); + }); }); diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index edb5aab602..1f320f6595 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -6,6 +6,9 @@ import { AssetBulkDeleteDto, AssetBulkUpdateDto, AssetJobsDto, + AssetMetadataResponseDto, + AssetMetadataRouteParams, + AssetMetadataUpsertDto, AssetStatsDto, AssetStatsResponseDto, DeviceIdDto, @@ -85,4 +88,36 @@ export class AssetController { ): Promise { return this.service.update(auth, id, dto); } + + @Get(':id/metadata') + @Authenticated({ permission: Permission.AssetRead }) + getAssetMetadata(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.getMetadata(auth, id); + } + + @Put(':id/metadata') + @Authenticated({ permission: Permission.AssetUpdate }) + updateAssetMetadata( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: AssetMetadataUpsertDto, + ): Promise { + return this.service.upsertMetadata(auth, id, dto); + } + + @Get(':id/metadata/:key') + @Authenticated({ permission: Permission.AssetRead }) + getAssetMetadataByKey( + @Auth() auth: AuthDto, + @Param() { id, key }: AssetMetadataRouteParams, + ): Promise { + return this.service.getMetadataByKey(auth, id, key); + } + + @Delete(':id/metadata/:key') + @Authenticated({ permission: Permission.AssetUpdate }) + @HttpCode(HttpStatus.NO_CONTENT) + deleteAssetMetadata(@Auth() auth: AuthDto, @Param() { id, key }: AssetMetadataRouteParams): Promise { + return this.service.deleteMetadataByKey(auth, id, key); + } } diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index ea86e087d8..25395000cd 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator'; +import { AssetMetadataUpsertItemDto } from 'src/dtos/asset.dto'; import { AssetVisibility } from 'src/enum'; import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation'; @@ -64,6 +65,12 @@ export class AssetMediaCreateDto extends AssetMediaBase { @ValidateUUID({ optional: true }) livePhotoVideoId?: string; + @Optional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssetMetadataUpsertItemDto) + metadata!: AssetMetadataUpsertItemDto[]; + @ApiProperty({ type: 'string', format: 'binary', required: false }) [UploadFieldName.SIDECAR_DATA]?: any; } diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 31e5679e76..6a89b7e2cf 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -1,21 +1,25 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { + IsArray, IsDateString, IsInt, IsLatitude, IsLongitude, IsNotEmpty, + IsObject, IsPositive, IsString, IsTimeZone, Max, Min, ValidateIf, + ValidateNested, } from 'class-validator'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetType, AssetVisibility } from 'src/enum'; +import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; +import { AssetMetadata, AssetMetadataItem } from 'src/types'; import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; export class DeviceIdDto { @@ -135,6 +139,53 @@ export class AssetStatsResponseDto { total!: number; } +export class AssetMetadataRouteParams { + @ValidateUUID() + id!: string; + + @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) + key!: AssetMetadataKey; +} + +export class AssetMetadataUpsertDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssetMetadataUpsertItemDto) + items!: AssetMetadataUpsertItemDto[]; +} + +export class AssetMetadataUpsertItemDto implements AssetMetadataItem { + @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) + key!: AssetMetadataKey; + + @IsObject() + @ValidateNested() + @Type((options) => { + switch (options?.object.key) { + case AssetMetadataKey.MobileApp: { + return AssetMetadataMobileAppDto; + } + default: { + return Object; + } + } + }) + value!: AssetMetadata[AssetMetadataKey]; +} + +export class AssetMetadataMobileAppDto { + @IsString() + @Optional() + iCloudId?: string; +} + +export class AssetMetadataResponseDto { + @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) + key!: AssetMetadataKey; + value!: object; + updatedAt!: Date; +} + export const mapStats = (stats: AssetStats): AssetStatsResponseDto => { return { images: stats[AssetType.Image], diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 9ac85755ab..0fae619e0f 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -4,6 +4,7 @@ import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AlbumUserRole, + AssetMetadataKey, AssetOrder, AssetType, AssetVisibility, @@ -162,6 +163,21 @@ export class SyncAssetExifV1 { fps!: number | null; } +@ExtraModel() +export class SyncAssetMetadataV1 { + assetId!: string; + @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) + key!: AssetMetadataKey; + value!: object; +} + +@ExtraModel() +export class SyncAssetMetadataDeleteV1 { + assetId!: string; + @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) + key!: AssetMetadataKey; +} + @ExtraModel() export class SyncAlbumDeleteV1 { albumId!: string; @@ -328,6 +344,8 @@ export type SyncItem = { [SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1; [SyncEntityType.AssetV1]: SyncAssetV1; [SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1; + [SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1; + [SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1; [SyncEntityType.AssetExifV1]: SyncAssetExifV1; [SyncEntityType.PartnerAssetV1]: SyncAssetV1; [SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1; diff --git a/server/src/enum.ts b/server/src/enum.ts index 02ef222883..bf72b24a14 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -276,6 +276,10 @@ export enum UserMetadataKey { Onboarding = 'onboarding', } +export enum AssetMetadataKey { + MobileApp = 'mobile-app', +} + export enum UserAvatarColor { Primary = 'primary', Pink = 'pink', @@ -627,6 +631,7 @@ export enum SyncRequestType { AlbumAssetExifsV1 = 'AlbumAssetExifsV1', AssetsV1 = 'AssetsV1', AssetExifsV1 = 'AssetExifsV1', + AssetMetadataV1 = 'AssetMetadataV1', AuthUsersV1 = 'AuthUsersV1', MemoriesV1 = 'MemoriesV1', MemoryToAssetsV1 = 'MemoryToAssetsV1', @@ -650,6 +655,8 @@ export enum SyncEntityType { AssetV1 = 'AssetV1', AssetDeleteV1 = 'AssetDeleteV1', AssetExifV1 = 'AssetExifV1', + AssetMetadataV1 = 'AssetMetadataV1', + AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1', PartnerV1 = 'PartnerV1', PartnerDeleteV1 = 'PartnerDeleteV1', diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index e2bc80eabe..1283ff0a66 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -19,6 +19,33 @@ returning "dateTimeOriginal", "timeZone" +-- AssetRepository.getMetadata +select + "key", + "value", + "updatedAt" +from + "asset_metadata" +where + "assetId" = $1 + +-- AssetRepository.getMetadataByKey +select + "key", + "value", + "updatedAt" +from + "asset_metadata" +where + "assetId" = $1 + and "key" = $2 + +-- AssetRepository.deleteMetadataByKey +delete from "asset_metadata" +where + "assetId" = $1 + and "key" = $2 + -- AssetRepository.getByDayOfYear with "res" as ( diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 80021368a0..3e70baa5d4 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -539,6 +539,37 @@ where order by "asset_face"."updateId" asc +-- SyncRepository.assetMetadata.getDeletes +select + "asset_metadata_audit"."id", + "assetId", + "key" +from + "asset_metadata_audit" as "asset_metadata_audit" + left join "asset" on "asset"."id" = "asset_metadata_audit"."assetId" +where + "asset_metadata_audit"."id" < $1 + and "asset_metadata_audit"."id" > $2 + and "asset"."ownerId" = $3 +order by + "asset_metadata_audit"."id" asc + +-- SyncRepository.assetMetadata.getUpserts +select + "assetId", + "key", + "value", + "asset_metadata"."updateId" +from + "asset_metadata" as "asset_metadata" + inner join "asset" on "asset"."id" = "asset_metadata"."assetId" +where + "asset_metadata"."updateId" < $1 + and "asset_metadata"."updateId" > $2 + and "asset"."ownerId" = $3 +order by + "asset_metadata"."updateId" asc + -- SyncRepository.authUser.getUpserts select "id", diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 6752d7bf62..ae595e35ae 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,15 +1,16 @@ import { Injectable } from '@nestjs/common'; -import { Insertable, Kysely, NotNull, Selectable, UpdateResult, Updateable, sql } from 'kysely'; +import { Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely'; import { isEmpty, isUndefined, omitBy } from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { Stack } from 'src/database'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; -import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; import { AssetTable } from 'src/schema/tables/asset.table'; +import { AssetMetadataItem } from 'src/types'; import { anyUuid, asUuid, @@ -210,6 +211,43 @@ export class AssetRepository { .execute(); } + @GenerateSql({ params: [DummyValue.UUID] }) + getMetadata(assetId: string) { + return this.db + .selectFrom('asset_metadata') + .select(['key', 'value', 'updatedAt']) + .where('assetId', '=', assetId) + .execute(); + } + + upsertMetadata(id: string, items: AssetMetadataItem[]) { + return this.db + .insertInto('asset_metadata') + .values(items.map((item) => ({ assetId: id, ...item }))) + .onConflict((oc) => + oc + .columns(['assetId', 'key']) + .doUpdateSet((eb) => ({ key: eb.ref('excluded.key'), value: eb.ref('excluded.value') })), + ) + .returning(['key', 'value', 'updatedAt']) + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) + getMetadataByKey(assetId: string, key: AssetMetadataKey) { + return this.db + .selectFrom('asset_metadata') + .select(['key', 'value', 'updatedAt']) + .where('assetId', '=', assetId) + .where('key', '=', key) + .executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) + async deleteMetadataByKey(id: string, key: AssetMetadataKey) { + await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute(); + } + create(asset: Insertable) { return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow(); } diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 13e933fd2f..398d49bd5d 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -54,6 +54,7 @@ export class SyncRepository { asset: AssetSync; assetExif: AssetExifSync; assetFace: AssetFaceSync; + assetMetadata: AssetMetadataSync; authUser: AuthUserSync; memory: MemorySync; memoryToAsset: MemoryToAssetSync; @@ -75,6 +76,7 @@ export class SyncRepository { this.asset = new AssetSync(this.db); this.assetExif = new AssetExifSync(this.db); this.assetFace = new AssetFaceSync(this.db); + this.assetMetadata = new AssetMetadataSync(this.db); this.authUser = new AuthUserSync(this.db); this.memory = new MemorySync(this.db); this.memoryToAsset = new MemoryToAssetSync(this.db); @@ -685,3 +687,23 @@ class UserMetadataSync extends BaseSync { .stream(); } } + +class AssetMetadataSync extends BaseSync { + @GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true }) + getDeletes(options: SyncQueryOptions, userId: string) { + return this.auditQuery('asset_metadata_audit', options) + .select(['asset_metadata_audit.id', 'assetId', 'key']) + .leftJoin('asset', 'asset.id', 'asset_metadata_audit.assetId') + .where('asset.ownerId', '=', userId) + .stream(); + } + + @GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true }) + getUpserts(options: SyncQueryOptions, userId: string) { + return this.upsertQuery('asset_metadata', options) + .select(['assetId', 'key', 'value', 'asset_metadata.updateId']) + .innerJoin('asset', 'asset.id', 'asset_metadata.assetId') + .where('asset.ownerId', '=', userId) + .stream(); + } +} diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index a63a4cc553..44f4a2bb9c 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -7,13 +7,10 @@ import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetType, AssetVisibility, UserStatus } from 'src/enum'; import { DB } from 'src/schema'; -import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; import { UserTable } from 'src/schema/tables/user.table'; import { UserMetadata, UserMetadataItem } from 'src/types'; import { asUuid } from 'src/utils/database'; -type Upsert = Insertable; - export interface UserListFilter { id?: string; withDeleted?: boolean; @@ -211,12 +208,12 @@ export class UserRepository { async upsertMetadata(id: string, { key, value }: { key: T; value: UserMetadata[T] }) { await this.db .insertInto('user_metadata') - .values({ userId: id, key, value } as Upsert) + .values({ userId: id, key, value }) .onConflict((oc) => oc.columns(['userId', 'key']).doUpdateSet({ key, value, - } as Upsert), + }), ) .execute(); } diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 786e7a1ffa..e255742b5d 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -230,6 +230,19 @@ export const user_metadata_audit = registerFunction({ END`, }); +export const asset_metadata_audit = registerFunction({ + name: 'asset_metadata_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO asset_metadata_audit ("assetId", "key") + SELECT "assetId", "key" + FROM OLD; + RETURN NULL; + END`, +}); + export const asset_face_audit = registerFunction({ name: 'asset_face_audit', returnType: 'TRIGGER', diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 8982437b34..48f454d455 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -5,6 +5,7 @@ import { album_user_delete_audit, asset_delete_audit, asset_face_audit, + asset_metadata_audit, f_concat_ws, f_unaccent, immich_uuid_v7, @@ -32,6 +33,8 @@ import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; +import { AssetMetadataAuditTable } from 'src/schema/tables/asset-metadata-audit.table'; +import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { AuditTable } from 'src/schema/tables/audit.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; @@ -81,6 +84,8 @@ export class ImmichDatabase { AssetAuditTable, AssetFaceTable, AssetFaceAuditTable, + AssetMetadataTable, + AssetMetadataAuditTable, AssetJobStatusTable, AssetTable, AssetFileTable, @@ -135,6 +140,7 @@ export class ImmichDatabase { stack_delete_audit, person_delete_audit, user_metadata_audit, + asset_metadata_audit, asset_face_audit, ]; @@ -164,6 +170,8 @@ export interface DB { asset_face: AssetFaceTable; asset_face_audit: AssetFaceAuditTable; asset_file: AssetFileTable; + asset_metadata: AssetMetadataTable; + asset_metadata_audit: AssetMetadataAuditTable; asset_job_status: AssetJobStatusTable; asset_audit: AssetAuditTable; diff --git a/server/src/schema/migrations/1756318797207-AssetMetadataTables.ts b/server/src/schema/migrations/1756318797207-AssetMetadataTables.ts new file mode 100644 index 0000000000..ba0bad9d9a --- /dev/null +++ b/server/src/schema/migrations/1756318797207-AssetMetadataTables.ts @@ -0,0 +1,58 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION asset_metadata_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO asset_metadata_audit ("assetId", "key") + SELECT "assetId", "key" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE TABLE "asset_metadata_audit" ( + "id" uuid NOT NULL DEFAULT immich_uuid_v7(), + "assetId" uuid NOT NULL, + "key" character varying NOT NULL, + "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT "asset_metadata_audit_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "asset_metadata_audit_assetId_idx" ON "asset_metadata_audit" ("assetId");`.execute(db); + await sql`CREATE INDEX "asset_metadata_audit_key_idx" ON "asset_metadata_audit" ("key");`.execute(db); + await sql`CREATE INDEX "asset_metadata_audit_deletedAt_idx" ON "asset_metadata_audit" ("deletedAt");`.execute(db); + await sql`CREATE TABLE "asset_metadata" ( + "assetId" uuid NOT NULL, + "key" character varying NOT NULL, + "value" jsonb NOT NULL, + "updateId" uuid NOT NULL DEFAULT immich_uuid_v7(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "asset_metadata_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "asset_metadata_pkey" PRIMARY KEY ("assetId", "key") +);`.execute(db); + await sql`CREATE INDEX "asset_metadata_updateId_idx" ON "asset_metadata" ("updateId");`.execute(db); + await sql`CREATE INDEX "asset_metadata_updatedAt_idx" ON "asset_metadata" ("updatedAt");`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_metadata_audit" + AFTER DELETE ON "asset_metadata" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION asset_metadata_audit();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_metadata_updated_at" + BEFORE UPDATE ON "asset_metadata" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_metadata_audit', '{"type":"function","name":"asset_metadata_audit","sql":"CREATE OR REPLACE FUNCTION asset_metadata_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO asset_metadata_audit (\\"assetId\\", \\"key\\")\\n SELECT \\"assetId\\", \\"key\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_metadata_audit', '{"type":"trigger","name":"asset_metadata_audit","sql":"CREATE OR REPLACE TRIGGER \\"asset_metadata_audit\\"\\n AFTER DELETE ON \\"asset_metadata\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_metadata_audit();"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_metadata_updated_at', '{"type":"trigger","name":"asset_metadata_updated_at","sql":"CREATE OR REPLACE TRIGGER \\"asset_metadata_updated_at\\"\\n BEFORE UPDATE ON \\"asset_metadata\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TABLE "asset_metadata_audit";`.execute(db); + await sql`DROP TABLE "asset_metadata";`.execute(db); + await sql`DROP FUNCTION asset_metadata_audit;`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_asset_metadata_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_metadata_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_metadata_updated_at';`.execute(db); +} diff --git a/server/src/schema/tables/asset-metadata-audit.table.ts b/server/src/schema/tables/asset-metadata-audit.table.ts new file mode 100644 index 0000000000..3b94ce6d1a --- /dev/null +++ b/server/src/schema/tables/asset-metadata-audit.table.ts @@ -0,0 +1,18 @@ +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { AssetMetadataKey } from 'src/enum'; +import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; + +@Table('asset_metadata_audit') +export class AssetMetadataAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @Column({ type: 'uuid', index: true }) + assetId!: string; + + @Column({ index: true }) + key!: AssetMetadataKey; + + @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) + deletedAt!: Generated; +} diff --git a/server/src/schema/tables/asset-metadata.table.ts b/server/src/schema/tables/asset-metadata.table.ts new file mode 100644 index 0000000000..486101408d --- /dev/null +++ b/server/src/schema/tables/asset-metadata.table.ts @@ -0,0 +1,46 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { AssetMetadataKey } from 'src/enum'; +import { asset_metadata_audit } from 'src/schema/functions'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { + AfterDeleteTrigger, + Column, + ForeignKeyColumn, + Generated, + PrimaryColumn, + Table, + Timestamp, + UpdateDateColumn, +} from 'src/sql-tools'; +import { AssetMetadata, AssetMetadataItem } from 'src/types'; + +@UpdatedAtTrigger('asset_metadata_updated_at') +@Table('asset_metadata') +@AfterDeleteTrigger({ + scope: 'statement', + function: asset_metadata_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) +export class AssetMetadataTable implements AssetMetadataItem { + @ForeignKeyColumn(() => AssetTable, { + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + primary: true, + // [assetId, key] is the PK constraint + index: false, + }) + assetId!: string; + + @PrimaryColumn({ type: 'character varying' }) + key!: T; + + @Column({ type: 'jsonb' }) + value!: AssetMetadata[T]; + + @UpdateIdColumn({ index: true }) + updateId!: Generated; + + @UpdateDateColumn({ index: true }) + updatedAt!: Generated; +} diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 517a1f665f..69e1dfd3a0 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -423,6 +423,10 @@ export class AssetMediaService extends BaseService { sidecarPath: sidecarFile?.originalPath, }); + if (dto.metadata) { + await this.assetRepository.upsertMetadata(asset.id, dto.metadata); + } + if (sidecarFile) { await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt)); } diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 9a2c580707..725e3cff15 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -9,12 +9,14 @@ import { AssetBulkUpdateDto, AssetJobName, AssetJobsDto, + AssetMetadataResponseDto, + AssetMetadataUpsertDto, AssetStatsDto, UpdateAssetDto, mapStats, } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; +import { AssetMetadataKey, AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { ISidecarWriteJob, JobItem, JobOf } from 'src/types'; import { requireElevatedPermission } from 'src/utils/access'; @@ -93,7 +95,7 @@ export class AssetService extends BaseService { } } - await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); + await this.updateExif({ id, description, dateTimeOriginal, latitude, longitude, rating }); const asset = await this.assetRepository.update({ id, ...rest }); @@ -273,6 +275,31 @@ export class AssetService extends BaseService { }); } + async getMetadata(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); + return this.assetRepository.getMetadata(id); + } + + async upsertMetadata(auth: AuthDto, id: string, dto: AssetMetadataUpsertDto): Promise { + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] }); + return this.assetRepository.upsertMetadata(id, dto.items); + } + + async getMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise { + await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); + + const item = await this.assetRepository.getMetadataByKey(id, key); + if (!item) { + throw new BadRequestException(`Metadata with key "${key}" not found for asset with id "${id}"`); + } + return item; + } + + async deleteMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise { + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] }); + return this.assetRepository.deleteMetadataByKey(id, key); + } + async run(auth: AuthDto, dto: AssetJobsDto) { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds }); @@ -313,7 +340,7 @@ export class AssetService extends BaseService { return asset; } - private async updateMetadata(dto: ISidecarWriteJob) { + private async updateExif(dto: ISidecarWriteJob) { const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); if (Object.keys(writes).length > 0) { diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 6b8512eacb..677c799fb8 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -74,6 +74,7 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.PeopleV1, SyncRequestType.AssetFacesV1, SyncRequestType.UserMetadataV1, + SyncRequestType.AssetMetadataV1, ]; const throwSessionRequired = () => { @@ -156,6 +157,7 @@ export class SyncService extends BaseService { [SyncRequestType.AssetsV1]: () => this.syncAssetsV1(options, response, checkpointMap), [SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(options, response, checkpointMap), [SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(options, response, checkpointMap, session.id), + [SyncRequestType.AssetMetadataV1]: () => this.syncAssetMetadataV1(options, response, checkpointMap, auth), [SyncRequestType.PartnerAssetExifsV1]: () => this.syncPartnerAssetExifsV1(options, response, checkpointMap, session.id), [SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(options, response, checkpointMap), @@ -759,6 +761,33 @@ export class SyncService extends BaseService { } } + private async syncAssetMetadataV1( + options: SyncQueryOptions, + response: Writable, + checkpointMap: CheckpointMap, + auth: AuthDto, + ) { + const deleteType = SyncEntityType.AssetMetadataDeleteV1; + const deletes = this.syncRepository.assetMetadata.getDeletes( + { ...options, ack: checkpointMap[deleteType] }, + auth.user.id, + ); + + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.AssetMetadataV1; + const upserts = this.syncRepository.assetMetadata.getUpserts( + { ...options, ack: checkpointMap[upsertType] }, + auth.user.id, + ); + + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) { const { type, sessionId, createId } = item; await this.syncCheckpointRepository.upsertAll([ diff --git a/server/src/types.ts b/server/src/types.ts index 9cd1aa996b..b77dd4df6e 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,6 +1,7 @@ import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; import { + AssetMetadataKey, AssetOrder, AssetType, DatabaseSslMode, @@ -465,11 +466,6 @@ export interface SystemMetadata extends Record = { - key: T; - value: UserMetadata[T]; -}; - export interface UserPreferences { albums: { defaultAssetOrder: AssetOrder; @@ -514,8 +510,22 @@ export interface UserPreferences { }; } +export type UserMetadataItem = { + key: T; + value: UserMetadata[T]; +}; + export interface UserMetadata extends Record> { [UserMetadataKey.Preferences]: DeepPartial; [UserMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: string }; [UserMetadataKey.Onboarding]: { isOnboarded: boolean }; } + +export type AssetMetadataItem = { + key: T; + value: AssetMetadata[T]; +}; + +export interface AssetMetadata extends Record> { + [AssetMetadataKey.MobileApp]: { iCloudId: string }; +} diff --git a/server/test/medium/specs/sync/sync-asset-metadata.spec.ts b/server/test/medium/specs/sync/sync-asset-metadata.spec.ts new file mode 100644 index 0000000000..84353883a2 --- /dev/null +++ b/server/test/medium/specs/sync/sync-asset-metadata.spec.ts @@ -0,0 +1,126 @@ +import { Kysely } from 'kysely'; +import { AssetMetadataKey, SyncEntityType, SyncRequestType } from 'src/enum'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { DB } from 'src/schema'; +import { SyncTestContext } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const ctx = new SyncTestContext(db || defaultDatabase); + const { auth, user, session } = await ctx.newSyncAuthUser(); + return { auth, user, session, ctx }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SyncEntityType.AssetMetadataV1, () => { + it('should detect and sync new asset metadata', async () => { + const { auth, user, ctx } = await setup(); + + const assetRepo = ctx.get(AssetRepository); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await assetRepo.upsertMetadata(asset.id, [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'abc123' } }]); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + key: AssetMetadataKey.MobileApp, + assetId: asset.id, + value: { iCloudId: 'abc123' }, + }, + type: 'AssetMetadataV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1])).resolves.toEqual([]); + }); + + it('should update asset metadata', async () => { + const { auth, user, ctx } = await setup(); + + const assetRepo = ctx.get(AssetRepository); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await assetRepo.upsertMetadata(asset.id, [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'abc123' } }]); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + key: AssetMetadataKey.MobileApp, + assetId: asset.id, + value: { iCloudId: 'abc123' }, + }, + type: 'AssetMetadataV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + + await assetRepo.upsertMetadata(asset.id, [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'abc456' } }]); + + const updatedResponse = await ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1]); + expect(updatedResponse).toEqual([ + { + ack: expect.any(String), + data: { + key: AssetMetadataKey.MobileApp, + assetId: asset.id, + value: { iCloudId: 'abc456' }, + }, + type: 'AssetMetadataV1', + }, + ]); + + await ctx.syncAckAll(auth, updatedResponse); + await expect(ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1])).resolves.toEqual([]); + }); +}); + +describe(SyncEntityType.AssetMetadataDeleteV1, () => { + it('should delete and sync asset metadata', async () => { + const { auth, user, ctx } = await setup(); + + const assetRepo = ctx.get(AssetRepository); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await assetRepo.upsertMetadata(asset.id, [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'abc123' } }]); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + key: AssetMetadataKey.MobileApp, + assetId: asset.id, + value: { iCloudId: 'abc123' }, + }, + type: 'AssetMetadataV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + + await assetRepo.deleteMetadataByKey(asset.id, AssetMetadataKey.MobileApp); + + await expect(ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1])).resolves.toEqual([ + { + ack: expect.any(String), + data: { + assetId: asset.id, + key: AssetMetadataKey.MobileApp, + }, + type: 'AssetMetadataDeleteV1', + }, + ]); + }); +}); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 79e3d506f3..e735b37564 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -41,5 +41,9 @@ export const newAssetRepositoryMock = (): Mocked Date: Wed, 27 Aug 2025 15:10:55 -0400 Subject: [PATCH 096/137] fix: motion video extraction race condition (#21285) fix: motion video extraction race ccondition --- server/src/services/asset-media.service.ts | 4 +- server/src/services/metadata.service.ts | 82 +++++++++++++--------- server/src/utils/database.ts | 6 +- 3 files changed, 56 insertions(+), 36 deletions(-) diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 69e1dfd3a0..54bbedea9c 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -27,7 +27,7 @@ import { BaseService } from 'src/services/base.service'; import { UploadFile } from 'src/types'; import { requireUploadAccess } from 'src/utils/access'; import { asRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util'; -import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database'; +import { isAssetChecksumConstraint } from 'src/utils/database'; import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { fromChecksum } from 'src/utils/request'; @@ -318,7 +318,7 @@ export class AssetMediaService extends BaseService { }); // handle duplicates with a success response - if (error.constraint_name === ASSET_CHECKSUM_CONSTRAINT) { + if (isAssetChecksumConstraint(error)) { const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum); if (!duplicateId) { this.logger.error(`Error locating duplicate for checksum constraint`); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index c675b7200d..ac2b927510 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -29,6 +29,7 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; +import { isAssetChecksumConstraint } from 'src/utils/database'; import { isFaceImportEnabled } from 'src/utils/misc'; import { upsertTags } from 'src/utils/tag'; @@ -545,47 +546,62 @@ export class MetadataService extends BaseService { }); } const checksum = this.cryptoRepository.hashSha1(video); + const checksumQuery = { ownerId: asset.ownerId, libraryId: asset.libraryId ?? undefined, checksum }; - let motionAsset = await this.assetRepository.getByChecksum({ - ownerId: asset.ownerId, - libraryId: asset.libraryId ?? undefined, - checksum, - }); - if (motionAsset) { + let motionAsset = await this.assetRepository.getByChecksum(checksumQuery); + let isNewMotionAsset = false; + + if (!motionAsset) { + try { + const motionAssetId = this.cryptoRepository.randomUUID(); + motionAsset = await this.assetRepository.create({ + id: motionAssetId, + libraryId: asset.libraryId, + type: AssetType.Video, + fileCreatedAt: dates.dateTimeOriginal, + fileModifiedAt: stats.mtime, + localDateTime: dates.localDateTime, + checksum, + ownerId: asset.ownerId, + originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId), + originalFileName: `${path.parse(asset.originalFileName).name}.mp4`, + visibility: AssetVisibility.Hidden, + deviceAssetId: 'NONE', + deviceId: 'NONE', + }); + + isNewMotionAsset = true; + + if (!asset.isExternal) { + await this.userRepository.updateUsage(asset.ownerId, video.byteLength); + } + } catch (error) { + if (!isAssetChecksumConstraint(error)) { + throw error; + } + + motionAsset = await this.assetRepository.getByChecksum(checksumQuery); + if (!motionAsset) { + this.logger.warn(`Unable to find existing motion video asset for ${asset.id}: ${asset.originalPath}`); + return; + } + } + } + + if (!isNewMotionAsset) { this.logger.debugFn(() => { const base64Checksum = checksum.toString('base64'); return `Motion asset with checksum ${base64Checksum} already exists for asset ${asset.id}: ${asset.originalPath}`; }); + } - // Hide the motion photo video asset if it's not already hidden to prepare for linking - if (motionAsset.visibility === AssetVisibility.Timeline) { - await this.assetRepository.update({ - id: motionAsset.id, - visibility: AssetVisibility.Hidden, - }); - this.logger.log(`Hid unlinked motion photo video asset (${motionAsset.id})`); - } - } else { - const motionAssetId = this.cryptoRepository.randomUUID(); - motionAsset = await this.assetRepository.create({ - id: motionAssetId, - libraryId: asset.libraryId, - type: AssetType.Video, - fileCreatedAt: dates.dateTimeOriginal, - fileModifiedAt: stats.mtime, - localDateTime: dates.localDateTime, - checksum, - ownerId: asset.ownerId, - originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId), - originalFileName: `${path.parse(asset.originalFileName).name}.mp4`, + // Hide the motion photo video asset if it's not already hidden to prepare for linking + if (motionAsset.visibility === AssetVisibility.Timeline) { + await this.assetRepository.update({ + id: motionAsset.id, visibility: AssetVisibility.Hidden, - deviceAssetId: 'NONE', - deviceId: 'NONE', }); - - if (!asset.isExternal) { - await this.userRepository.updateUsage(asset.ownerId, video.byteLength); - } + this.logger.log(`Hid unlinked motion photo video asset (${motionAsset.id})`); } if (asset.livePhotoVideoId !== motionAsset.id) { diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 1ef9b8e926..d9fe6b7897 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -14,7 +14,7 @@ import { import { PostgresJSDialect } from 'kysely-postgres-js'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { parse } from 'pg-connection-string'; -import postgres, { Notice } from 'postgres'; +import postgres, { Notice, PostgresError } from 'postgres'; import { columns, Exif, Person } from 'src/database'; import { AssetFileType, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; @@ -153,6 +153,10 @@ export function toJson { + return (error as PostgresError)?.constraint_name === 'UQ_assets_owner_checksum'; +}; + export function withDefaultVisibility(qb: SelectQueryBuilder) { return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]); } From ae104ad7cc1256a51dd039bc006cfe20eebdeea8 Mon Sep 17 00:00:00 2001 From: prajwal <93521144+Prajwalg19@users.noreply.github.com> Date: Thu, 28 Aug 2025 01:21:43 +0530 Subject: [PATCH 097/137] fix(web): add primary text color to file upload toast (#21340) * fix:add primary text color to file upload toast * fix:make progress bar visible in dark mode * fix:make it text-primary --------- Co-authored-by: prajwal --- .../components/shared-components/upload-asset-preview.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/shared-components/upload-asset-preview.svelte b/web/src/lib/components/shared-components/upload-asset-preview.svelte index 428dafebe3..985400a033 100644 --- a/web/src/lib/components/shared-components/upload-asset-preview.svelte +++ b/web/src/lib/components/shared-components/upload-asset-preview.svelte @@ -91,9 +91,9 @@ {#if uploadAsset.state === UploadState.STARTED} -
+
-

+

{#if uploadAsset.message} {uploadAsset.message} {:else} From dc6ac3aaecacf8cbc2fd19bd37ebf65cc5ad76d7 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:40:45 -0400 Subject: [PATCH 098/137] fix(mobile): thumbnail requests not being cancelled (#21331) * fix requests not being cancelled * handle thumbhash --- .../widgets/images/image_provider.dart | 3 +-- .../widgets/images/local_image_provider.dart | 5 ++-- .../widgets/images/remote_image_provider.dart | 26 +++++++------------ .../widgets/images/thumb_hash_provider.dart | 5 ++-- .../widgets/images/thumbnail.widget.dart | 11 ++++++++ 5 files changed, 27 insertions(+), 23 deletions(-) diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index d0428e5013..dd87d2f228 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -50,12 +50,11 @@ mixin CancellableImageProviderMixin on CancellableImageProvide Stream loadRequest(ImageRequest request, ImageDecoderCallback decode) async* { if (isCancelled) { + this.request = null; evict(); return; } - this.request = request; - try { final image = await request.load(decode); if (image == null || isCancelled) { diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index 8bdbe3c16a..223d095432 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -35,7 +35,8 @@ class LocalThumbProvider extends CancellableImageProvider } Stream _codec(LocalThumbProvider key, ImageDecoderCallback decode) { - return loadRequest(LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType), decode); + final request = this.request = LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType); + return loadRequest(request, decode); } @override @@ -87,7 +88,7 @@ class LocalFullImageProvider extends CancellableImageProvider } Stream _codec(RemoteThumbProvider key, ImageDecoderCallback decode) { - final request = RemoteImageRequest( + final request = this.request = RemoteImageRequest( uri: getThumbnailUrlForRemoteId(key.assetId), headers: ApiService.getRequestHeaders(), cacheManager: cacheManager, @@ -92,16 +92,12 @@ class RemoteFullImageProvider extends CancellableImageProvider @override ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) { - return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode))..addOnLastListenerRemovedCallback(cancel); + return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode), onDispose: cancel); } Stream _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) { - return loadRequest(ThumbhashImageRequest(thumbhash: key.thumbHash), decode); + final request = this.request = ThumbhashImageRequest(thumbhash: key.thumbHash); + return loadRequest(request, decode); } @override diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index 9cf77cc29e..3ecd5cd491 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart'; @@ -235,6 +236,16 @@ class _ThumbnailState extends State with SingleTickerProviderStateMix @override void dispose() { + final imageProvider = widget.imageProvider; + if (imageProvider is CancellableImageProvider) { + imageProvider.cancel(); + } + + final thumbhashProvider = widget.thumbhashProvider; + if (thumbhashProvider is CancellableImageProvider) { + thumbhashProvider.cancel(); + } + _fadeController.removeStatusListener(_onAnimationStatusChanged); _fadeController.dispose(); _stopListeningToStream(); From a5841a8bf44bb6764ddd9179273986d55bbdeeea Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 27 Aug 2025 22:16:41 -0400 Subject: [PATCH 099/137] fix(mobile): memory lane rebuild (#21350) * avoid unnecessary timeline rebuild * add key * handle disabled memories * avoid rebuild if no memories --- .../repositories/memory.repository.dart | 5 ++++- .../pages/dev/main_timeline.page.dart | 21 ++++--------------- .../widgets/memory/memory_lane.widget.dart | 15 +++++++++---- .../infrastructure/memory.provider.dart | 11 +++++----- 4 files changed, 24 insertions(+), 28 deletions(-) diff --git a/mobile/lib/infrastructure/repositories/memory.repository.dart b/mobile/lib/infrastructure/repositories/memory.repository.dart index 2a52faf2dd..b5bed18ad5 100644 --- a/mobile/lib/infrastructure/repositories/memory.repository.dart +++ b/mobile/lib/infrastructure/repositories/memory.repository.dart @@ -30,6 +30,9 @@ class DriftMemoryRepository extends DriftDatabaseRepository { ..orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]); final rows = await query.get(); + if (rows.isEmpty) { + return const []; + } final Map memoriesMap = {}; @@ -46,7 +49,7 @@ class DriftMemoryRepository extends DriftDatabaseRepository { } } - return memoriesMap.values.toList(); + return memoriesMap.values.toList(growable: false); } Future get(String memoryId) async { diff --git a/mobile/lib/presentation/pages/dev/main_timeline.page.dart b/mobile/lib/presentation/pages/dev/main_timeline.page.dart index 8ef3ef9757..60a296a22c 100644 --- a/mobile/lib/presentation/pages/dev/main_timeline.page.dart +++ b/mobile/lib/presentation/pages/dev/main_timeline.page.dart @@ -4,7 +4,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; @RoutePage() class MainTimelinePage extends ConsumerWidget { @@ -12,22 +11,10 @@ class MainTimelinePage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final memoryLaneProvider = ref.watch(driftMemoryFutureProvider); - final memoriesEnabled = ref.watch(currentUserProvider.select((user) => user?.memoryEnabled ?? true)); - - return memoryLaneProvider.maybeWhen( - data: (memories) { - return memories.isEmpty || !memoriesEnabled - ? const Timeline() - : Timeline( - topSliverWidget: SliverToBoxAdapter( - key: Key('memory-lane-${memories.first.assets.first.id}'), - child: DriftMemoryLane(memories: memories), - ), - topSliverWidgetHeight: 200, - ); - }, - orElse: () => const Timeline(), + final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false)); + return Timeline( + topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()), + topSliverWidgetHeight: hasMemories ? 200 : 0, ); } } diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart index ec49bbec96..b2c61c7488 100644 --- a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart @@ -7,15 +7,20 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart' import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/routing/router.dart'; class DriftMemoryLane extends ConsumerWidget { - final List memories; - - const DriftMemoryLane({super.key, required this.memories}); + const DriftMemoryLane({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + final memoryLaneProvider = ref.watch(driftMemoryFutureProvider); + final memories = memoryLaneProvider.value ?? const []; + if (memories.isEmpty) { + return const SizedBox.shrink(); + } + return ConstrainedBox( constraints: const BoxConstraints(maxHeight: 200), child: CarouselView( @@ -38,7 +43,9 @@ class DriftMemoryLane extends ConsumerWidget { context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index)); }, - children: memories.map((memory) => DriftMemoryCard(memory: memory)).toList(), + children: memories + .map((memory) => DriftMemoryCard(key: Key(memory.id), memory: memory)) + .toList(growable: false), ), ); } diff --git a/mobile/lib/providers/infrastructure/memory.provider.dart b/mobile/lib/providers/infrastructure/memory.provider.dart index e5809a12b4..0965f4349b 100644 --- a/mobile/lib/providers/infrastructure/memory.provider.dart +++ b/mobile/lib/providers/infrastructure/memory.provider.dart @@ -14,13 +14,12 @@ final driftMemoryServiceProvider = Provider( (ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)), ); -final driftMemoryFutureProvider = FutureProvider.autoDispose>((ref) async { - final user = ref.watch(currentUserProvider); - if (user == null) { - return []; +final driftMemoryFutureProvider = FutureProvider.autoDispose>((ref) { + final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true))); + if (userId == null || !enabled) { + return const []; } final service = ref.watch(driftMemoryServiceProvider); - - return service.getMemoryLane(user.id); + return service.getMemoryLane(userId); }); From f65dabd43acfe893b059987a0882fcc96ad43824 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 27 Aug 2025 21:17:56 -0500 Subject: [PATCH 100/137] chore: post release tasks (#21228) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 18 +++++++++--------- mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 563c4cda33..827c9be881 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -669,7 +669,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 217; + CURRENT_PROJECT_VERSION = 218; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -813,7 +813,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 217; + CURRENT_PROJECT_VERSION = 218; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -843,7 +843,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 217; + CURRENT_PROJECT_VERSION = 218; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -877,7 +877,7 @@ CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 217; + CURRENT_PROJECT_VERSION = 218; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -920,7 +920,7 @@ CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 217; + CURRENT_PROJECT_VERSION = 218; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -960,7 +960,7 @@ CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 217; + CURRENT_PROJECT_VERSION = 218; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -999,7 +999,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 217; + CURRENT_PROJECT_VERSION = 218; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1043,7 +1043,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 217; + CURRENT_PROJECT_VERSION = 218; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1084,7 +1084,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 217; + CURRENT_PROJECT_VERSION = 218; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 8c72f125f4..5db281ea86 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -78,7 +78,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.139.3 + 1.139.4 CFBundleSignature ???? CFBundleURLTypes @@ -105,7 +105,7 @@ CFBundleVersion - 217 + 218 FLTEnableImpeller ITSAppUsesNonExemptEncryption From e2169f531613732c4baca94a6fa7ed885c7d9ddd Mon Sep 17 00:00:00 2001 From: Yaros Date: Thu, 28 Aug 2025 04:42:38 +0200 Subject: [PATCH 101/137] fix(mobile): fast animations when "disable animations" enabled (#21309) * fix(mobile): disable animations speed android * use animationBehavior instead of workaround --- .../widgets/common/mesmerizing_sliver_app_bar.dart | 12 ++++++++++-- mobile/lib/widgets/common/person_sliver_app_bar.dart | 12 ++++++++++-- .../widgets/common/remote_album_sliver_app_bar.dart | 12 ++++++++++-- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart index 359b400456..73dbbfc85b 100644 --- a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart @@ -272,9 +272,17 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic void initState() { super.initState(); - _zoomController = AnimationController(duration: const Duration(seconds: 12), vsync: this); + _zoomController = AnimationController( + duration: const Duration(seconds: 12), + vsync: this, + animationBehavior: AnimationBehavior.preserve, + ); - _crossFadeController = AnimationController(duration: const Duration(milliseconds: 1200), vsync: this); + _crossFadeController = AnimationController( + duration: const Duration(milliseconds: 1200), + vsync: this, + animationBehavior: AnimationBehavior.preserve, + ); _zoomAnimation = Tween( begin: 1.0, diff --git a/mobile/lib/widgets/common/person_sliver_app_bar.dart b/mobile/lib/widgets/common/person_sliver_app_bar.dart index 1cc117139d..0f9555a101 100644 --- a/mobile/lib/widgets/common/person_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/person_sliver_app_bar.dart @@ -378,9 +378,17 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic void initState() { super.initState(); - _zoomController = AnimationController(duration: const Duration(seconds: 12), vsync: this); + _zoomController = AnimationController( + duration: const Duration(seconds: 12), + vsync: this, + animationBehavior: AnimationBehavior.preserve, + ); - _crossFadeController = AnimationController(duration: const Duration(milliseconds: 1200), vsync: this); + _crossFadeController = AnimationController( + duration: const Duration(milliseconds: 1200), + vsync: this, + animationBehavior: AnimationBehavior.preserve, + ); _zoomAnimation = Tween( begin: 1.0, diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index 54497a10de..f9768d575e 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -378,9 +378,17 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic void initState() { super.initState(); - _zoomController = AnimationController(duration: const Duration(seconds: 12), vsync: this); + _zoomController = AnimationController( + duration: const Duration(seconds: 12), + vsync: this, + animationBehavior: AnimationBehavior.preserve, + ); - _crossFadeController = AnimationController(duration: const Duration(milliseconds: 1200), vsync: this); + _crossFadeController = AnimationController( + duration: const Duration(milliseconds: 1200), + vsync: this, + animationBehavior: AnimationBehavior.preserve, + ); _zoomAnimation = Tween( begin: 1.0, From a3808c26ce15ba80023b79e0335a3a38518c235c Mon Sep 17 00:00:00 2001 From: Yaros Date: Thu, 28 Aug 2025 04:43:39 +0200 Subject: [PATCH 102/137] fix(web): middle click not working on videos (#21304) Co-authored-by: Alex --- web/src/lib/components/assets/thumbnail/thumbnail.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index e4b590b8ea..9af9287c76 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -321,7 +321,7 @@ onComplete={(errored) => ((loaded = true), (thumbError = errored))} /> {#if asset.isVideo} -

+
{:else if asset.isImage && asset.livePhotoVideoId} -
+
Date: Wed, 27 Aug 2025 21:44:19 -0500 Subject: [PATCH 103/137] chore(deps): pin busybox docker tag to ab33eac (#21280) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 439140e3f5..2c003270e4 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -185,7 +185,7 @@ services: init: container_name: init - image: busybox + image: busybox@sha256:ab33eacc8251e3807b85bb6dba570e4698c3998eca6f0fc2ccb60575a563ea74 env_file: - .env user: 0:0 From 227789225ab9aa4675272c6eb81c878dc4385058 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 27 Aug 2025 22:52:51 -0400 Subject: [PATCH 104/137] fix(mobile): allow gestures in asset viewer before image is loaded (#21354) * allow gestures while loading * disable zoom --- .../photo_view/src/photo_view_wrappers.dart | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart index 65037dde96..a2ad04e6b5 100644 --- a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart +++ b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart @@ -172,12 +172,36 @@ class _ImageWrapperState extends State { @override Widget build(BuildContext context) { - if (_loading) { - return _buildLoading(context); - } - - if (_lastException != null) { - return _buildError(context); + if (_loading || _lastException != null) { + return CustomChildWrapper( + childSize: null, + backgroundDecoration: widget.backgroundDecoration, + heroAttributes: widget.heroAttributes, + scaleStateChangedCallback: widget.scaleStateChangedCallback, + enableRotation: widget.enableRotation, + controller: widget.controller, + scaleStateController: widget.scaleStateController, + maxScale: widget.maxScale, + minScale: widget.minScale, + initialScale: widget.initialScale, + basePosition: widget.basePosition, + scaleStateCycle: widget.scaleStateCycle, + onTapUp: widget.onTapUp, + onTapDown: widget.onTapDown, + onDragStart: widget.onDragStart, + onDragEnd: widget.onDragEnd, + onDragUpdate: widget.onDragUpdate, + onScaleEnd: widget.onScaleEnd, + onLongPressStart: widget.onLongPressStart, + outerSize: widget.outerSize, + gestureDetectorBehavior: widget.gestureDetectorBehavior, + tightMode: widget.tightMode, + filterQuality: widget.filterQuality, + disableGestures: widget.disableGestures, + disableScaleGestures: true, + enablePanAlways: widget.enablePanAlways, + child: _loading ? _buildLoading(context) : _buildError(context), + ); } final scaleBoundaries = ScaleBoundaries( From e78144ea316e8b09ef998c0463cb2b9faef3b14e Mon Sep 17 00:00:00 2001 From: Snowknight26 Date: Wed, 27 Aug 2025 22:00:50 -0500 Subject: [PATCH 105/137] fix(web): Translate confirmation modal header and action buttons (#21330) fix(web): Translate confirmation modal --- web/src/routes/+layout.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 71958d9d9f..d2311a4204 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -36,6 +36,8 @@ close: $t('close'), show_password: $t('show_password'), hide_password: $t('hide_password'), + confirm: $t('confirm'), + cancel: $t('cancel'), }); }); From 0df88fc22bca588dc743ac57ec69fc784c7f699c Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 28 Aug 2025 19:41:54 +0530 Subject: [PATCH 106/137] feat: beta background sync (#21243) * feat: ios background sync # Conflicts: # mobile/ios/Runner/Info.plist * feat: Android sync * add local sync worker and rename stuff * group upload notifications * uncomment onresume beta handling * rename methods --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex --- mobile/analysis_options.yaml | 1 + .../kotlin/app/alextran/immich/ImmichApp.kt | 24 +- .../app/alextran/immich/MainActivity.kt | 35 ++- .../immich/background/BackgroundWorker.g.kt | 238 ++++++++++++++ .../immich/background/BackgroundWorker.kt | 162 ++++++++++ .../background/BackgroundWorkerApiImpl.kt | 92 ++++++ .../immich/background/MediaObserver.kt | 34 ++ mobile/ios/Runner.xcodeproj/project.pbxproj | 32 +- mobile/ios/Runner/AppDelegate.swift | 17 +- .../Background/BackgroundWorker.g.swift | 245 +++++++++++++++ .../Runner/Background/BackgroundWorker.swift | 202 ++++++++++++ .../Background/BackgroundWorkerApiImpl.swift | 155 +++++++++ mobile/ios/Runner/Info.plist | 15 +- .../services/background_worker.service.dart | 232 ++++++++++++++ mobile/lib/domain/services/hash.service.dart | 20 ++ mobile/lib/domain/services/log.service.dart | 5 + mobile/lib/domain/utils/background_sync.dart | 22 ++ mobile/lib/main.dart | 47 +-- .../lib/pages/backup/drift_backup.page.dart | 3 + .../pages/common/change_experience.page.dart | 4 + .../lib/platform/background_worker_api.g.dart | 296 ++++++++++++++++++ .../lib/providers/backup/backup.provider.dart | 4 + .../lib/repositories/upload.repository.dart | 8 +- mobile/lib/services/upload.service.dart | 32 +- mobile/lib/utils/bootstrap.dart | 33 ++ mobile/lib/utils/isolate.dart | 6 +- mobile/makefile | 2 + mobile/pigeon/background_worker_api.dart | 48 +++ 28 files changed, 1933 insertions(+), 81 deletions(-) create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/background/MediaObserver.kt create mode 100644 mobile/ios/Runner/Background/BackgroundWorker.g.swift create mode 100644 mobile/ios/Runner/Background/BackgroundWorker.swift create mode 100644 mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift create mode 100644 mobile/lib/domain/services/background_worker.service.dart create mode 100644 mobile/lib/platform/background_worker_api.g.dart create mode 100644 mobile/pigeon/background_worker_api.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 1b0b7170d2..bef051bff2 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -81,6 +81,7 @@ custom_lint: # acceptable exceptions for the time being (until Isar is fully replaced) - lib/providers/app_life_cycle.provider.dart - integration_test/test_utils/general_helper.dart + - lib/domain/services/background_worker.service.dart - lib/main.dart - lib/pages/album/album_asset_selection.page.dart - lib/routing/router.dart diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt index ff806870f9..4237643233 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt @@ -5,15 +5,15 @@ import androidx.work.Configuration import androidx.work.WorkManager class ImmichApp : Application() { - override fun onCreate() { - super.onCreate() - val config = Configuration.Builder().build() - WorkManager.initialize(this, config) - // always start BackupWorker after WorkManager init; this fixes the following bug: - // After the process is killed (by user or system), the first trigger (taking a new picture) is lost. - // Thus, the BackupWorker is not started. If the system kills the process after each initialization - // (because of low memory etc.), the backup is never performed. - // As a workaround, we also run a backup check when initializing the application - ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0) - } -} \ No newline at end of file + override fun onCreate() { + super.onCreate() + val config = Configuration.Builder().build() + WorkManager.initialize(this, config) + // always start BackupWorker after WorkManager init; this fixes the following bug: + // After the process is killed (by user or system), the first trigger (taking a new picture) is lost. + // Thus, the BackupWorker is not started. If the system kills the process after each initialization + // (because of low memory etc.), the backup is never performed. + // As a workaround, we also run a backup check when initializing the application + ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0) + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index b1a50695a3..a87feddd1a 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -1,8 +1,10 @@ package app.alextran.immich +import android.content.Context import android.os.Build import android.os.ext.SdkExtensions -import androidx.annotation.NonNull +import app.alextran.immich.background.BackgroundWorkerApiImpl +import app.alextran.immich.background.BackgroundWorkerFgHostApi import app.alextran.immich.images.ThumbnailApi import app.alextran.immich.images.ThumbnailsImpl import app.alextran.immich.sync.NativeSyncApi @@ -12,19 +14,26 @@ import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine class MainActivity : FlutterFragmentActivity() { - override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - flutterEngine.plugins.add(BackgroundServicePlugin()) - flutterEngine.plugins.add(HttpSSLOptionsPlugin()) - // No need to set up method channel here as it's now handled in the plugin + registerPlugins(this, flutterEngine) + } - val nativeSyncApiImpl = - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) { - NativeSyncApiImpl26(this) - } else { - NativeSyncApiImpl30(this) - } - NativeSyncApi.setUp(flutterEngine.dartExecutor.binaryMessenger, nativeSyncApiImpl) - ThumbnailApi.setUp(flutterEngine.dartExecutor.binaryMessenger, ThumbnailsImpl(this)) + companion object { + fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { + flutterEngine.plugins.add(BackgroundServicePlugin()) + flutterEngine.plugins.add(HttpSSLOptionsPlugin()) + + val messenger = flutterEngine.dartExecutor.binaryMessenger + val nativeSyncApiImpl = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) { + NativeSyncApiImpl26(ctx) + } else { + NativeSyncApiImpl30(ctx) + } + NativeSyncApi.setUp(messenger, nativeSyncApiImpl) + ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx)) + BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx)) + } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt new file mode 100644 index 0000000000..39a2345a9b --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt @@ -0,0 +1,238 @@ +// Autogenerated from Pigeon (v26.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package app.alextran.immich.background + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object BackgroundWorkerPigeonUtils { + + fun createConnectionError(channelName: String): FlutterError { + return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") } + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() +private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return super.readValueOfType(type, buffer) + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + super.writeValue(stream, value) + } +} + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface BackgroundWorkerFgHostApi { + fun enableSyncWorker() + fun enableUploadWorker(callbackHandle: Long) + fun disableUploadWorker() + + companion object { + /** The codec used by BackgroundWorkerFgHostApi. */ + val codec: MessageCodec by lazy { + BackgroundWorkerPigeonCodec() + } + /** Sets up an instance of `BackgroundWorkerFgHostApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.enableSyncWorker() + listOf(null) + } catch (exception: Throwable) { + BackgroundWorkerPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val callbackHandleArg = args[0] as Long + val wrapped: List = try { + api.enableUploadWorker(callbackHandleArg) + listOf(null) + } catch (exception: Throwable) { + BackgroundWorkerPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.disableUploadWorker() + listOf(null) + } catch (exception: Throwable) { + BackgroundWorkerPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface BackgroundWorkerBgHostApi { + fun onInitialized() + + companion object { + /** The codec used by BackgroundWorkerBgHostApi. */ + val codec: MessageCodec by lazy { + BackgroundWorkerPigeonCodec() + } + /** Sets up an instance of `BackgroundWorkerBgHostApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerBgHostApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.onInitialized$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.onInitialized() + listOf(null) + } catch (exception: Throwable) { + BackgroundWorkerPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +class BackgroundWorkerFlutterApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by BackgroundWorkerFlutterApi. */ + val codec: MessageCodec by lazy { + BackgroundWorkerPigeonCodec() + } + } + fun onLocalSync(maxSecondsArg: Long?, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(maxSecondsArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(BackgroundWorkerPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onIosUpload(isRefreshArg: Boolean, maxSecondsArg: Long?, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(isRefreshArg, maxSecondsArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(BackgroundWorkerPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onAndroidUpload(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(BackgroundWorkerPigeonUtils.createConnectionError(channelName))) + } + } + } + fun cancel(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.cancel$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(BackgroundWorkerPigeonUtils.createConnectionError(channelName))) + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt new file mode 100644 index 0000000000..0ce601b363 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt @@ -0,0 +1,162 @@ +package app.alextran.immich.background + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import app.alextran.immich.MainActivity +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.SettableFuture +import io.flutter.FlutterInjector +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor.DartCallback +import io.flutter.embedding.engine.loader.FlutterLoader +import io.flutter.view.FlutterCallbackInformation + +private const val TAG = "BackgroundWorker" + +enum class BackgroundTaskType { + LOCAL_SYNC, + UPLOAD, +} + +class BackgroundWorker(context: Context, params: WorkerParameters) : + ListenableWorker(context, params), BackgroundWorkerBgHostApi { + private val ctx: Context = context.applicationContext + + /// The Flutter loader that loads the native Flutter library and resources. + /// This must be initialized before starting the Flutter engine. + private var loader: FlutterLoader = FlutterInjector.instance().flutterLoader() + + /// The Flutter engine created specifically for background execution. + /// This is a separate instance from the main Flutter engine that handles the UI. + /// It operates in its own isolate and doesn't share memory with the main engine. + /// Must be properly started, registered, and torn down during background execution. + private var engine: FlutterEngine? = null + + // Used to call methods on the flutter side + private var flutterApi: BackgroundWorkerFlutterApi? = null + + /// Result returned when the background task completes. This is used to signal + /// to the WorkManager that the task has finished, either successfully or with failure. + private val completionHandler: SettableFuture = SettableFuture.create() + + /// Flag to track whether the background task has completed to prevent duplicate completions + private var isComplete = false + + init { + if (!loader.initialized()) { + loader.startInitialization(ctx) + } + } + + override fun startWork(): ListenableFuture { + Log.i(TAG, "Starting background upload worker") + + loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { + engine = FlutterEngine(ctx) + + // Retrieve the callback handle stored by the main Flutter app + // This handle points to the Flutter function that should be executed in the background + val callbackHandle = + ctx.getSharedPreferences(BackgroundWorkerApiImpl.SHARED_PREF_NAME, Context.MODE_PRIVATE) + .getLong(BackgroundWorkerApiImpl.SHARED_PREF_CALLBACK_HANDLE, 0L) + + if (callbackHandle == 0L) { + // Without a valid callback handle, we cannot start the Flutter background execution + complete(Result.failure()) + return@ensureInitializationCompleteAsync + } + + // Start the Flutter engine with the specified callback as the entry point + val callback = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle) + if (callback == null) { + complete(Result.failure()) + return@ensureInitializationCompleteAsync + } + + // Register custom plugins + MainActivity.registerPlugins(ctx, engine!!) + flutterApi = + BackgroundWorkerFlutterApi(binaryMessenger = engine!!.dartExecutor.binaryMessenger) + BackgroundWorkerBgHostApi.setUp( + binaryMessenger = engine!!.dartExecutor.binaryMessenger, + api = this + ) + + engine!!.dartExecutor.executeDartCallback( + DartCallback(ctx.assets, loader.findAppBundlePath(), callback) + ) + } + + return completionHandler + } + + /** + * Called by the Flutter side when it has finished initialization and is ready to receive commands. + * Routes the appropriate task type (refresh or processing) to the corresponding Flutter method. + * This method acts as a bridge between the native Android background task system and Flutter. + */ + override fun onInitialized() { + val taskTypeIndex = inputData.getInt(BackgroundWorkerApiImpl.WORKER_DATA_TASK_TYPE, 0) + val taskType = BackgroundTaskType.entries[taskTypeIndex] + + when (taskType) { + BackgroundTaskType.LOCAL_SYNC -> flutterApi?.onLocalSync(null) { handleHostResult(it) } + BackgroundTaskType.UPLOAD -> flutterApi?.onAndroidUpload { handleHostResult(it) } + } + } + + /** + * Called when the system has to stop this worker because constraints are + * no longer met or the system needs resources for more important tasks + * This is also called when the worker has been explicitly cancelled or replaced + */ + override fun onStopped() { + Log.d(TAG, "About to stop BackupWorker") + + if (isComplete) { + return + } + + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + if (flutterApi != null) { + flutterApi?.cancel { + complete(Result.failure()) + } + } + } + + Handler(Looper.getMainLooper()).postDelayed({ + complete(Result.failure()) + }, 5000) + } + + private fun handleHostResult(result: kotlin.Result) { + if (isComplete) { + return + } + + result.fold( + onSuccess = { _ -> complete(Result.success()) }, + onFailure = { _ -> onStopped() } + ) + } + + /** + * Cleans up resources by destroying the Flutter engine context and invokes the completion handler. + * This method ensures that the background task is marked as complete, releases the Flutter engine, + * and notifies the caller of the task's success or failure. This is the final step in the + * background task lifecycle and should only be called once per task instance. + * + * - Parameter success: Indicates whether the background task completed successfully + */ + private fun complete(success: Result) { + isComplete = true + engine?.destroy() + flutterApi = null + completionHandler.set(success) + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt new file mode 100644 index 0000000000..7a3226f961 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt @@ -0,0 +1,92 @@ +package app.alextran.immich.background + +import android.content.Context +import android.provider.MediaStore +import android.util.Log +import androidx.core.content.edit +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +private const val TAG = "BackgroundUploadImpl" + +class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi { + private val ctx: Context = context.applicationContext + override fun enableSyncWorker() { + enqueueMediaObserver(ctx) + Log.i(TAG, "Scheduled media observer") + } + + override fun enableUploadWorker(callbackHandle: Long) { + updateUploadEnabled(ctx, true) + updateCallbackHandle(ctx, callbackHandle) + Log.i(TAG, "Scheduled background upload tasks") + } + + override fun disableUploadWorker() { + updateUploadEnabled(ctx, false) + WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME) + Log.i(TAG, "Cancelled background upload tasks") + } + + companion object { + private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1" + private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1" + + const val WORKER_DATA_TASK_TYPE = "taskType" + + const val SHARED_PREF_NAME = "Immich::Background" + const val SHARED_PREF_BACKUP_ENABLED = "Background::backup::enabled" + const val SHARED_PREF_CALLBACK_HANDLE = "Background::backup::callbackHandle" + + private fun updateUploadEnabled(context: Context, enabled: Boolean) { + context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit { + putBoolean(SHARED_PREF_BACKUP_ENABLED, enabled) + } + } + + private fun updateCallbackHandle(context: Context, callbackHandle: Long) { + context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit { + putLong(SHARED_PREF_CALLBACK_HANDLE, callbackHandle) + } + } + + fun enqueueMediaObserver(ctx: Context) { + val constraints = Constraints.Builder() + .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true) + .setTriggerContentUpdateDelay(5, TimeUnit.SECONDS) + .setTriggerContentMaxDelay(1, TimeUnit.MINUTES) + .build() + + val work = OneTimeWorkRequest.Builder(MediaObserver::class.java) + .setConstraints(constraints) + .build() + WorkManager.getInstance(ctx) + .enqueueUniqueWork(OBSERVER_WORKER_NAME, ExistingWorkPolicy.REPLACE, work) + + Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME") + } + + fun enqueueBackgroundWorker(ctx: Context, taskType: BackgroundTaskType) { + val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build() + + val data = Data.Builder() + data.putInt(WORKER_DATA_TASK_TYPE, taskType.ordinal) + val work = OneTimeWorkRequest.Builder(BackgroundWorker::class.java) + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) + .setInputData(data.build()).build() + WorkManager.getInstance(ctx) + .enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work) + + Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME") + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/MediaObserver.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/MediaObserver.kt new file mode 100644 index 0000000000..0ec6eeb3a5 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/MediaObserver.kt @@ -0,0 +1,34 @@ +package app.alextran.immich.background + +import android.content.Context +import android.util.Log +import androidx.work.Worker +import androidx.work.WorkerParameters + +class MediaObserver(context: Context, params: WorkerParameters) : Worker(context, params) { + private val ctx: Context = context.applicationContext + + override fun doWork(): Result { + Log.i("MediaObserver", "Content change detected, starting background worker") + + // Enqueue backup worker only if there are new media changes + if (triggeredContentUris.isNotEmpty()) { + val type = + if (isBackupEnabled(ctx)) BackgroundTaskType.UPLOAD else BackgroundTaskType.LOCAL_SYNC + BackgroundWorkerApiImpl.enqueueBackgroundWorker(ctx, type) + } + + // Re-enqueue itself to listen for future changes + BackgroundWorkerApiImpl.enqueueMediaObserver(ctx) + return Result.success() + } + + private fun isBackupEnabled(context: Context): Boolean { + val prefs = + context.getSharedPreferences( + BackgroundWorkerApiImpl.SHARED_PREF_NAME, + Context.MODE_PRIVATE + ) + return prefs.getBoolean(BackgroundWorkerApiImpl.SHARED_PREF_BACKUP_ENABLED, false) + } +} diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 827c9be881..087297ab71 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -16,6 +16,9 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */; }; + B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; }; + B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; }; D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; }; F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; F0B57D3A2DF764BD00DC5BCC /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */; }; @@ -92,6 +95,9 @@ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = ""; }; + B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkerApiImpl.swift; sourceTree = ""; }; + B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = ""; }; + B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = ""; }; E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; @@ -123,8 +129,6 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = Sync; sourceTree = ""; }; @@ -237,6 +241,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + B21E34A62E5AF9760031FDB9 /* Background */, B2CF7F8C2DDE4EBB00744BF6 /* Sync */, FA9973382CF6DF4B000EF859 /* Runner.entitlements */, 65DD438629917FAD0047FFA8 /* BackgroundSync */, @@ -254,6 +259,16 @@ path = Runner; sourceTree = ""; }; + B21E34A62E5AF9760031FDB9 /* Background */ = { + isa = PBXGroup; + children = ( + B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */, + B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */, + B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */, + ); + path = Background; + sourceTree = ""; + }; FAC6F8B62D287F120078CB2F /* ShareExtension */ = { isa = PBXGroup; children = ( @@ -490,10 +505,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -522,10 +541,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -540,10 +563,13 @@ files = ( 65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */, FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */, FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */, + B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */, FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */, 65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index dedda5bd12..04422eb2b4 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -19,13 +19,12 @@ import UIKit } GeneratedPluginRegistrant.register(with: self) - BackgroundServicePlugin.registerBackgroundProcessing() - - BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) - let controller: FlutterViewController = window?.rootViewController as! FlutterViewController - NativeSyncApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: NativeSyncApiImpl()) - ThumbnailApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: ThumbnailApiImpl()) + AppDelegate.registerPlugins(binaryMessenger: controller.binaryMessenger) + BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) + + BackgroundServicePlugin.registerBackgroundProcessing() + BackgroundWorkerApiImpl.registerBackgroundProcessing() BackgroundServicePlugin.setPluginRegistrantCallback { registry in if !registry.hasPlugin("org.cocoapods.path-provider-foundation") { @@ -51,4 +50,10 @@ import UIKit return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) { + NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl()) + ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailApiImpl()) + BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: BackgroundWorkerApiImpl()) + } } diff --git a/mobile/ios/Runner/Background/BackgroundWorker.g.swift b/mobile/ios/Runner/Background/BackgroundWorker.g.swift new file mode 100644 index 0000000000..e9513db8da --- /dev/null +++ b/mobile/ios/Runner/Background/BackgroundWorker.g.swift @@ -0,0 +1,245 @@ +// Autogenerated from Pigeon (v26.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func createConnectionError(withChannelName channelName: String) -> PigeonError { + return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "") +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + + +private class BackgroundWorkerPigeonCodecReader: FlutterStandardReader { +} + +private class BackgroundWorkerPigeonCodecWriter: FlutterStandardWriter { +} + +private class BackgroundWorkerPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return BackgroundWorkerPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return BackgroundWorkerPigeonCodecWriter(data: data) + } +} + +class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = BackgroundWorkerPigeonCodec(readerWriter: BackgroundWorkerPigeonCodecReaderWriter()) +} + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol BackgroundWorkerFgHostApi { + func enableSyncWorker() throws + func enableUploadWorker(callbackHandle: Int64) throws + func disableUploadWorker() throws +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class BackgroundWorkerFgHostApiSetup { + static var codec: FlutterStandardMessageCodec { BackgroundWorkerPigeonCodec.shared } + /// Sets up an instance of `BackgroundWorkerFgHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let enableSyncWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + enableSyncWorkerChannel.setMessageHandler { _, reply in + do { + try api.enableSyncWorker() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + enableSyncWorkerChannel.setMessageHandler(nil) + } + let enableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + enableUploadWorkerChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let callbackHandleArg = args[0] as! Int64 + do { + try api.enableUploadWorker(callbackHandle: callbackHandleArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + enableUploadWorkerChannel.setMessageHandler(nil) + } + let disableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + disableUploadWorkerChannel.setMessageHandler { _, reply in + do { + try api.disableUploadWorker() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + disableUploadWorkerChannel.setMessageHandler(nil) + } + } +} +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol BackgroundWorkerBgHostApi { + func onInitialized() throws +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class BackgroundWorkerBgHostApiSetup { + static var codec: FlutterStandardMessageCodec { BackgroundWorkerPigeonCodec.shared } + /// Sets up an instance of `BackgroundWorkerBgHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerBgHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let onInitializedChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.onInitialized\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + onInitializedChannel.setMessageHandler { _, reply in + do { + try api.onInitialized() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + onInitializedChannel.setMessageHandler(nil) + } + } +} +/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. +protocol BackgroundWorkerFlutterApiProtocol { + func onLocalSync(maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result) -> Void) + func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result) -> Void) + func onAndroidUpload(completion: @escaping (Result) -> Void) + func cancel(completion: @escaping (Result) -> Void) +} +class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol { + private let binaryMessenger: FlutterBinaryMessenger + private let messageChannelSuffix: String + init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") { + self.binaryMessenger = binaryMessenger + self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + } + var codec: BackgroundWorkerPigeonCodec { + return BackgroundWorkerPigeonCodec.shared + } + func onLocalSync(maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([maxSecondsArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(())) + } + } + } + func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([isRefreshArg, maxSecondsArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(())) + } + } + } + func onAndroidUpload(completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage(nil) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(())) + } + } + } + func cancel(completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.cancel\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage(nil) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(())) + } + } + } +} diff --git a/mobile/ios/Runner/Background/BackgroundWorker.swift b/mobile/ios/Runner/Background/BackgroundWorker.swift new file mode 100644 index 0000000000..db849d942b --- /dev/null +++ b/mobile/ios/Runner/Background/BackgroundWorker.swift @@ -0,0 +1,202 @@ +import BackgroundTasks +import Flutter + +enum BackgroundTaskType { case localSync, refreshUpload, processingUpload } + +/* + * DEBUG: Testing Background Tasks in Xcode + * + * To test background task functionality during development: + * 1. Pause the application in Xcode debugger + * 2. In the debugger console, enter one of the following commands: + + ## For local sync (short-running sync): + + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.localSync"] + + ## For background refresh (short-running sync): + + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.refreshUpload"] + + ## For background processing (long-running upload): + + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.processingUpload"] + + * To simulate task expiration (useful for testing expiration handlers): + + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.localSync"] + + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.refreshUpload"] + + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.processingUpload"] + + * 3. Resume the application to see the background code execute + * + * NOTE: This must be tested on a physical device, not in the simulator. + * In testing, only the background processing task can be reliably simulated. + * These commands submit the respective task to BGTaskScheduler for immediate processing. + * Use the expiration commands to test how the app handles iOS terminating background tasks. + */ + + +/// The background worker which creates a new Flutter VM, communicates with it +/// to run the backup job, and then finishes execution and calls back to its callback handler. +/// This class manages a separate Flutter engine instance for background execution, +/// independent of the main UI Flutter engine. +class BackgroundWorker: BackgroundWorkerBgHostApi { + private let taskType: BackgroundTaskType + /// The maximum number of seconds to run the task before timing out + private let maxSeconds: Int? + /// Callback function to invoke when the background task completes + private let completionHandler: (_ success: Bool) -> Void + + /// The Flutter engine created specifically for background execution. + /// This is a separate instance from the main Flutter engine that handles the UI. + /// It operates in its own isolate and doesn't share memory with the main engine. + /// Must be properly started, registered, and torn down during background execution. + private let engine = FlutterEngine(name: "BackgroundImmich") + + /// Used to call methods on the flutter side + private var flutterApi: BackgroundWorkerFlutterApi? + + /// Flag to track whether the background task has completed to prevent duplicate completions + private var isComplete = false + + /** + * Initializes a new background worker with the specified task type and execution constraints. + * Creates a new Flutter engine instance for background execution and sets up the necessary + * communication channels between native iOS and Flutter code. + * + * - Parameters: + * - taskType: The type of background task to execute (upload or sync task) + * - maxSeconds: Optional maximum execution time in seconds before the task is cancelled + * - completionHandler: Callback function invoked when the task completes, with success status + */ + init(taskType: BackgroundTaskType, maxSeconds: Int?, completionHandler: @escaping (_ success: Bool) -> Void) { + self.taskType = taskType + self.maxSeconds = maxSeconds + self.completionHandler = completionHandler + // Should be initialized only after the engine starts running + self.flutterApi = nil + } + + /** + * Starts the background Flutter engine and begins execution of the background task. + * Retrieves the callback handle from UserDefaults, looks up the Flutter callback, + * starts the engine, and sets up a timeout timer if specified. + */ + func run() { + // Retrieve the callback handle stored by the main Flutter app + // This handle points to the Flutter function that should be executed in the background + let callbackHandle = Int64(UserDefaults.standard.string( + forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey) ?? "0") ?? 0 + + if callbackHandle == 0 { + // Without a valid callback handle, we cannot start the Flutter background execution + complete(success: false) + return + } + + // Use the callback handle to retrieve the actual Flutter callback information + guard let callback = FlutterCallbackCache.lookupCallbackInformation(callbackHandle) else { + // The callback handle is invalid or the callback was not found + complete(success: false) + return + } + + // Start the Flutter engine with the specified callback as the entry point + let isRunning = engine.run( + withEntrypoint: callback.callbackName, + libraryURI: callback.callbackLibraryPath + ) + + // Verify that the Flutter engine started successfully + if !isRunning { + complete(success: false) + return + } + + // Register plugins in the new engine + GeneratedPluginRegistrant.register(with: engine) + // Register custom plugins + AppDelegate.registerPlugins(binaryMessenger: engine.binaryMessenger) + flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger) + BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self) + + // Set up a timeout timer if maxSeconds was specified to prevent runaway background tasks + if maxSeconds != nil { + // Schedule a timer to cancel the task after the specified timeout period + Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!), repeats: false) { _ in + self.cancel() + } + } + } + + /** + * Called by the Flutter side when it has finished initialization and is ready to receive commands. + * Routes the appropriate task type (refresh or processing) to the corresponding Flutter method. + * This method acts as a bridge between the native iOS background task system and Flutter. + */ + func onInitialized() throws { + switch self.taskType { + case .refreshUpload, .processingUpload: + flutterApi?.onIosUpload(isRefresh: self.taskType == .refreshUpload, + maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in + self.handleHostResult(result: result) + }) + case .localSync: + flutterApi?.onLocalSync(maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in + self.handleHostResult(result: result) + }) + } + } + + /** + * Cancels the currently running background task, either due to timeout or external request. + * Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure + * the completion handler is eventually called even if Flutter doesn't respond. + */ + func cancel() { + if isComplete { + return + } + + isComplete = true + flutterApi?.cancel { result in + self.complete(success: false) + } + + // Fallback safety mechanism: ensure completion is called within 2 seconds + // This prevents the background task from hanging indefinitely if Flutter doesn't respond + Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in + self.complete(success: false) + } + } + + /** + * Handles the result from Flutter API calls and determines the success/failure status. + * Converts Flutter's Result type to a simple boolean success indicator for task completion. + * + * - Parameter result: The result returned from a Flutter API call + */ + private func handleHostResult(result: Result) { + switch result { + case .success(): self.complete(success: true) + case .failure(_): self.cancel() + } + } + + /** + * Cleans up resources by destroying the Flutter engine context and invokes the completion handler. + * This method ensures that the background task is marked as complete, releases the Flutter engine, + * and notifies the caller of the task's success or failure. This is the final step in the + * background task lifecycle and should only be called once per task instance. + * + * - Parameter success: Indicates whether the background task completed successfully + */ + private func complete(success: Bool) { + isComplete = true + engine.destroyContext() + completionHandler(success) + } +} diff --git a/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift b/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift new file mode 100644 index 0000000000..f36085de0b --- /dev/null +++ b/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift @@ -0,0 +1,155 @@ +import BackgroundTasks + +class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi { + func enableSyncWorker() throws { + BackgroundWorkerApiImpl.scheduleLocalSync() + print("BackgroundUploadImpl:enableSyncWorker Local Sync worker scheduled") + } + + func enableUploadWorker(callbackHandle: Int64) throws { + BackgroundWorkerApiImpl.updateUploadEnabled(true) + // Store the callback handle for later use when starting background Flutter isolates + BackgroundWorkerApiImpl.updateUploadCallbackHandle(callbackHandle) + + BackgroundWorkerApiImpl.scheduleRefreshUpload() + BackgroundWorkerApiImpl.scheduleProcessingUpload() + print("BackgroundUploadImpl:enableUploadWorker Scheduled background upload tasks") + } + + func disableUploadWorker() throws { + BackgroundWorkerApiImpl.updateUploadEnabled(false) + BackgroundWorkerApiImpl.cancelUploadTasks() + print("BackgroundUploadImpl:disableUploadWorker Disabled background upload tasks") + } + + public static let backgroundUploadEnabledKey = "immich:background:backup:enabled" + public static let backgroundUploadCallbackHandleKey = "immich:background:backup:callbackHandle" + + private static let localSyncTaskID = "app.alextran.immich.background.localSync" + private static let refreshUploadTaskID = "app.alextran.immich.background.refreshUpload" + private static let processingUploadTaskID = "app.alextran.immich.background.processingUpload" + + private static func updateUploadEnabled(_ isEnabled: Bool) { + return UserDefaults.standard.set(isEnabled, forKey: BackgroundWorkerApiImpl.backgroundUploadEnabledKey) + } + + private static func updateUploadCallbackHandle(_ callbackHandle: Int64) { + return UserDefaults.standard.set(String(callbackHandle), forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey) + } + + private static func cancelUploadTasks() { + BackgroundWorkerApiImpl.updateUploadEnabled(false) + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: refreshUploadTaskID); + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: processingUploadTaskID); + } + + public static func registerBackgroundProcessing() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: processingUploadTaskID, using: nil) { task in + if task is BGProcessingTask { + handleBackgroundProcessing(task: task as! BGProcessingTask) + } + } + + BGTaskScheduler.shared.register( + forTaskWithIdentifier: refreshUploadTaskID, using: nil) { task in + if task is BGAppRefreshTask { + handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .refreshUpload) + } + } + + BGTaskScheduler.shared.register( + forTaskWithIdentifier: localSyncTaskID, using: nil) { task in + if task is BGAppRefreshTask { + handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .localSync) + } + } + } + + private static func scheduleLocalSync() { + let backgroundRefresh = BGAppRefreshTaskRequest(identifier: localSyncTaskID) + backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins + + do { + try BGTaskScheduler.shared.submit(backgroundRefresh) + } catch { + print("Could not schedule the local sync task \(error.localizedDescription)") + } + } + + private static func scheduleRefreshUpload() { + let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshUploadTaskID) + backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins + + do { + try BGTaskScheduler.shared.submit(backgroundRefresh) + } catch { + print("Could not schedule the refresh upload task \(error.localizedDescription)") + } + } + + private static func scheduleProcessingUpload() { + let backgroundProcessing = BGProcessingTaskRequest(identifier: processingUploadTaskID) + + backgroundProcessing.requiresNetworkConnectivity = true + backgroundProcessing.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 mins + + do { + try BGTaskScheduler.shared.submit(backgroundProcessing) + } catch { + print("Could not schedule the processing upload task \(error.localizedDescription)") + } + } + + private static func handleBackgroundRefresh(task: BGAppRefreshTask, taskType: BackgroundTaskType) { + scheduleRefreshUpload() + // Restrict the refresh task to run only for a maximum of 20 seconds + runBackgroundWorker(task: task, taskType: taskType, maxSeconds: 20) + } + + private static func handleBackgroundProcessing(task: BGProcessingTask) { + scheduleProcessingUpload() + // There are no restrictions for processing tasks. Although, the OS could signal expiration at any time + runBackgroundWorker(task: task, taskType: .processingUpload, maxSeconds: nil) + } + + /** + * Executes the background worker within the context of a background task. + * This method creates a BackgroundWorker, sets up task expiration handling, + * and manages the synchronization between the background task and the Flutter engine. + * + * - Parameters: + * - task: The iOS background task that provides the execution context + * - taskType: The type of background operation to perform (refresh or processing) + * - maxSeconds: Optional timeout for the operation in seconds + */ + private static func runBackgroundWorker(task: BGTask, taskType: BackgroundTaskType, maxSeconds: Int?) { + let semaphore = DispatchSemaphore(value: 0) + var isSuccess = true + + let backgroundWorker = BackgroundWorker(taskType: taskType, maxSeconds: maxSeconds) { success in + isSuccess = success + semaphore.signal() + } + + task.expirationHandler = { + DispatchQueue.main.async { + backgroundWorker.cancel() + } + isSuccess = false + + // Schedule a timer to signal the semaphore after 2 seconds + Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in + semaphore.signal() + } + } + + DispatchQueue.main.async { + backgroundWorker.run() + } + + semaphore.wait() + task.setTaskCompleted(success: isSuccess) + print("Background task completed with success: \(isSuccess)") + } +} diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 5db281ea86..1a3658ed16 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -6,6 +6,9 @@ $(CUSTOM_GROUP_ID) BGTaskSchedulerPermittedIdentifiers + app.alextran.immich.background.localSync + app.alextran.immich.background.refreshUpload + app.alextran.immich.background.processingUpload app.alextran.immich.backgroundFetch app.alextran.immich.backgroundProcessing @@ -78,7 +81,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.139.4 + 1.139.3 CFBundleSignature ???? CFBundleURLTypes @@ -105,7 +108,7 @@ CFBundleVersion - 218 + 217 FLTEnableImpeller ITSAppUsesNonExemptEncryption @@ -134,6 +137,9 @@ We need to access the camera to let you take beautiful video using this app NSFaceIDUsageDescription We need to use FaceID to allow access to your locked folder + NSLocalNetworkUsageDescription + We need local network permission to connect to the local server using IP address and + allow the casting feature to work NSLocationAlwaysAndWhenInUseUsageDescription We require this permission to access the local WiFi name for background upload mechanism NSLocationUsageDescription @@ -180,8 +186,5 @@ io.flutter.embedded_views_preview - NSLocalNetworkUsageDescription - We need local network permission to connect to the local server using IP address and - allow the casting feature to work - + \ No newline at end of file diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart new file mode 100644 index 0000000000..33c58cf743 --- /dev/null +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -0,0 +1,232 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; +import 'package:immich_mobile/platform/background_worker_api.g.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/services/auth.service.dart'; +import 'package:immich_mobile/services/localization.service.dart'; +import 'package:immich_mobile/services/upload.service.dart'; +import 'package:immich_mobile/utils/bootstrap.dart'; +import 'package:immich_mobile/utils/http_ssl_options.dart'; +import 'package:isar/isar.dart'; +import 'package:logging/logging.dart'; + +class BackgroundWorkerFgService { + final BackgroundWorkerFgHostApi _foregroundHostApi; + + const BackgroundWorkerFgService(this._foregroundHostApi); + + // TODO: Move this call to native side once old timeline is removed + Future enableSyncService() => _foregroundHostApi.enableSyncWorker(); + + Future enableUploadService() => _foregroundHostApi.enableUploadWorker( + PluginUtilities.getCallbackHandle(_backgroundSyncNativeEntrypoint)!.toRawHandle(), + ); + + Future disableUploadService() => _foregroundHostApi.disableUploadWorker(); +} + +class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { + late final ProviderContainer _ref; + final Isar _isar; + final Drift _drift; + final DriftLogger _driftLogger; + final BackgroundWorkerBgHostApi _backgroundHostApi; + final Logger _logger = Logger('BackgroundUploadBgService'); + + bool _isCleanedUp = false; + + BackgroundWorkerBgService({required Isar isar, required Drift drift, required DriftLogger driftLogger}) + : _isar = isar, + _drift = drift, + _driftLogger = driftLogger, + _backgroundHostApi = BackgroundWorkerBgHostApi() { + _ref = ProviderContainer( + overrides: [ + dbProvider.overrideWithValue(isar), + isarProvider.overrideWithValue(isar), + driftProvider.overrideWith(driftOverride(drift)), + ], + ); + BackgroundWorkerFlutterApi.setUp(this); + } + + bool get _isBackupEnabled => _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); + + Future init() async { + await loadTranslations(); + HttpSSLOptions.apply(applyNative: false); + await _ref.read(authServiceProvider).setOpenApiServiceEndpoint(); + + // Initialize the file downloader + await FileDownloader().configure( + globalConfig: [ + // maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3 + (Config.holdingQueue, (6, 6, 3)), + // On Android, if files are larger than 256MB, run in foreground service + (Config.runInForegroundIfFileLargerThan, 256), + ], + ); + await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false); + await FileDownloader().trackTasks(); + configureFileDownloaderNotifications(); + + // Notify the host that the background upload service has been initialized and is ready to use + await _backgroundHostApi.onInitialized(); + } + + @override + Future onLocalSync(int? maxSeconds) async { + _logger.info('Local background syncing started'); + final sw = Stopwatch()..start(); + + final timeout = maxSeconds != null ? Duration(seconds: maxSeconds) : null; + await _syncAssets(hashTimeout: timeout, syncRemote: false); + + sw.stop(); + _logger.info("Local sync completed in ${sw.elapsed.inSeconds}s"); + } + + /* We do the following on Android upload + * - Sync local assets + * - Hash local assets 3 / 6 minutes + * - Sync remote assets + * - Check and requeue upload tasks + */ + @override + Future onAndroidUpload() async { + _logger.info('Android background processing started'); + final sw = Stopwatch()..start(); + + await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6)); + await _handleBackup(processBulk: false); + + await _cleanup(); + + sw.stop(); + _logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s"); + } + + /* We do the following on background upload + * - Sync local assets + * - Hash local assets + * - Sync remote assets + * - Check and requeue upload tasks + * + * The native side will not send the maxSeconds value for processing tasks + */ + @override + Future onIosUpload(bool isRefresh, int? maxSeconds) async { + _logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s'); + final sw = Stopwatch()..start(); + + final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6); + await _syncAssets(hashTimeout: timeout); + + final backupFuture = _handleBackup(); + if (maxSeconds != null) { + await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {}); + } else { + await backupFuture; + } + + await _cleanup(); + + sw.stop(); + _logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s"); + } + + @override + Future cancel() async { + _logger.warning("Background upload cancelled"); + await _cleanup(); + } + + Future _cleanup() async { + if (_isCleanedUp) { + return; + } + + _isCleanedUp = true; + await _ref.read(backgroundSyncProvider).cancel(); + await _ref.read(backgroundSyncProvider).cancelLocal(); + await _isar.close(); + await _drift.close(); + await _driftLogger.close(); + _ref.dispose(); + } + + Future _handleBackup({bool processBulk = true}) async { + if (!_isBackupEnabled) { + return; + } + + final currentUser = _ref.read(currentUserProvider); + if (currentUser == null) { + return; + } + + if (processBulk) { + return _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); + } + + final activeTask = await _ref.read(uploadServiceProvider).getActiveTasks(currentUser.id); + if (activeTask.isNotEmpty) { + await _ref.read(uploadServiceProvider).resumeBackup(); + } else { + await _ref.read(uploadServiceProvider).startBackupSerial(currentUser.id); + } + } + + Future _syncAssets({Duration? hashTimeout, bool syncRemote = true}) async { + final futures = >[]; + + final localSyncFuture = _ref.read(backgroundSyncProvider).syncLocal().then((_) async { + if (_isCleanedUp) { + return; + } + + var hashFuture = _ref.read(backgroundSyncProvider).hashAssets(); + if (hashTimeout != null) { + hashFuture = hashFuture.timeout( + hashTimeout, + onTimeout: () { + // Consume cancellation errors as we want to continue processing + }, + ); + } + + return hashFuture; + }); + + futures.add(localSyncFuture); + if (syncRemote) { + final remoteSyncFuture = _ref.read(backgroundSyncProvider).syncRemote(); + futures.add(remoteSyncFuture); + } + + await Future.wait(futures); + } +} + +@pragma('vm:entry-point') +Future _backgroundSyncNativeEntrypoint() async { + WidgetsFlutterBinding.ensureInitialized(); + DartPluginRegistrant.ensureInitialized(); + + final (isar, drift, logDB) = await Bootstrap.initDB(); + await Bootstrap.initDomain(isar, drift, logDB, shouldBufferLogs: false); + await BackgroundWorkerBgService(isar: isar, drift: drift, driftLogger: logDB).init(); +} diff --git a/mobile/lib/domain/services/hash.service.dart b/mobile/lib/domain/services/hash.service.dart index a8eea2c25e..90720fdc76 100644 --- a/mobile/lib/domain/services/hash.service.dart +++ b/mobile/lib/domain/services/hash.service.dart @@ -15,6 +15,7 @@ class HashService { final DriftLocalAssetRepository _localAssetRepository; final StorageRepository _storageRepository; final NativeSyncApi _nativeSyncApi; + final bool Function()? _cancelChecker; final _log = Logger('HashService'); HashService({ @@ -22,13 +23,17 @@ class HashService { required DriftLocalAssetRepository localAssetRepository, required StorageRepository storageRepository, required NativeSyncApi nativeSyncApi, + bool Function()? cancelChecker, this.batchSizeLimit = kBatchHashSizeLimit, this.batchFileLimit = kBatchHashFileLimit, }) : _localAlbumRepository = localAlbumRepository, _localAssetRepository = localAssetRepository, _storageRepository = storageRepository, + _cancelChecker = cancelChecker, _nativeSyncApi = nativeSyncApi; + bool get isCancelled => _cancelChecker?.call() ?? false; + Future hashAssets() async { final Stopwatch stopwatch = Stopwatch()..start(); // Sorted by backupSelection followed by isCloud @@ -37,6 +42,11 @@ class HashService { ); for (final album in localAlbums) { + if (isCancelled) { + _log.warning("Hashing cancelled. Stopped processing albums."); + break; + } + final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id); if (assetsToHash.isNotEmpty) { await _hashAssets(assetsToHash); @@ -55,6 +65,11 @@ class HashService { final toHash = <_AssetToPath>[]; for (final asset in assetsToHash) { + if (isCancelled) { + _log.warning("Hashing cancelled. Stopped processing assets."); + return; + } + final file = await _storageRepository.getFileForAsset(asset.id); if (file == null) { continue; @@ -89,6 +104,11 @@ class HashService { ); for (int i = 0; i < hashes.length; i++) { + if (isCancelled) { + _log.warning("Hashing cancelled. Stopped processing batch."); + return; + } + final hash = hashes[i]; final asset = toHash[i].asset; if (hash?.length == 20) { diff --git a/mobile/lib/domain/services/log.service.dart b/mobile/lib/domain/services/log.service.dart index 1053d5e54f..d21cb7ab09 100644 --- a/mobile/lib/domain/services/log.service.dart +++ b/mobile/lib/domain/services/log.service.dart @@ -123,6 +123,11 @@ class LogService { _flushTimer = null; final buffer = [..._msgBuffer]; _msgBuffer.clear(); + + if (buffer.isEmpty) { + return; + } + await _logRepository.insertAll(buffer); } } diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index cbf4030788..d8042c707c 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -59,6 +59,28 @@ class BackgroundSyncManager { } } + Future cancelLocal() async { + final futures = []; + + if (_hashTask != null) { + futures.add(_hashTask!.future); + } + _hashTask?.cancel(); + _hashTask = null; + + if (_deviceAlbumSyncTask != null) { + futures.add(_deviceAlbumSyncTask!.future); + } + _deviceAlbumSyncTask?.cancel(); + _deviceAlbumSyncTask = null; + + try { + await Future.wait(futures); + } on CanceledError { + // Ignore cancellation errors + } + } + // No need to cancel the task, as it can also be run when the user logs out Future syncLocal({bool full = false}) { if (_deviceAlbumSyncTask != null) { diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 0cab21748c..21093df24d 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -12,10 +12,13 @@ import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/locales.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; +import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; @@ -23,6 +26,7 @@ import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/routing/app_navigation_observer.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/deep_link.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; @@ -165,36 +169,6 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve await ref.read(localNotificationService).setup(); } - void _configureFileDownloaderNotifications() { - FileDownloader().configureNotificationForGroup( - kDownloadGroupImage, - running: TaskNotification('downloading_media'.tr(), '${'file_name'.tr()}: {filename}'), - complete: TaskNotification('download_finished'.tr(), '${'file_name'.tr()}: {filename}'), - progressBar: true, - ); - - FileDownloader().configureNotificationForGroup( - kDownloadGroupVideo, - running: TaskNotification('downloading_media'.tr(), '${'file_name'.tr()}: {filename}'), - complete: TaskNotification('download_finished'.tr(), '${'file_name'.tr()}: {filename}'), - progressBar: true, - ); - - FileDownloader().configureNotificationForGroup( - kManualUploadGroup, - running: TaskNotification('uploading_media'.tr(), '${'file_name'.tr()}: {displayName}'), - complete: TaskNotification('upload_finished'.tr(), '${'file_name'.tr()}: {displayName}'), - progressBar: true, - ); - - FileDownloader().configureNotificationForGroup( - kBackupGroup, - running: TaskNotification('uploading_media'.tr(), '${'file_name'.tr()}: {displayName}'), - complete: TaskNotification('upload_finished'.tr(), '${'file_name'.tr()}: {displayName}'), - progressBar: true, - ); - } - Future _deepLinkBuilder(PlatformDeepLink deepLink) async { final deepLinkHandler = ref.read(deepLinkServiceProvider); final currentRouteName = ref.read(currentRouteNameProvider.notifier).state; @@ -221,7 +195,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve super.didChangeDependencies(); Intl.defaultLocale = context.locale.toLanguageTag(); WidgetsBinding.instance.addPostFrameCallback((_) { - _configureFileDownloaderNotifications(); + configureFileDownloaderNotifications(); }); } @@ -231,7 +205,16 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve initApp().then((_) => debugPrint("App Init Completed")); WidgetsBinding.instance.addPostFrameCallback((_) { // needs to be delayed so that EasyLocalization is working - ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); + if (Store.isBetaTimelineEnabled) { + ref.read(driftBackgroundUploadFgService).enableSyncService(); + if (ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup)) { + ref.read(backgroundServiceProvider).disableService(); + ref.read(driftBackgroundUploadFgService).enableUploadService(); + } + } else { + ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); + ref.read(driftBackgroundUploadFgService).disableUploadService(); + } }); ref.read(shareIntentUploadProvider.notifier).init(); diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index b125c35908..5140c62a0d 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -42,10 +43,12 @@ class _DriftBackupPageState extends ConsumerState { await ref.read(backgroundSyncProvider).syncRemote(); await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id); + await ref.read(driftBackgroundUploadFgService).enableUploadService(); await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id); } Future stopBackup() async { + await ref.read(driftBackgroundUploadFgService).disableUploadService(); await ref.read(driftBackupProvider.notifier).cancel(); } diff --git a/mobile/lib/pages/common/change_experience.page.dart b/mobile/lib/pages/common/change_experience.page.dart index 3e9747ce32..9064f32066 100644 --- a/mobile/lib/pages/common/change_experience.page.dart +++ b/mobile/lib/pages/common/change_experience.page.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; +import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/utils/migration.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -68,12 +69,15 @@ class _ChangeExperiencePageState extends ConsumerState { await migrateDeviceAssetToSqlite(ref.read(isarProvider), ref.read(driftProvider)); await migrateBackupAlbumsToSqlite(ref.read(isarProvider), ref.read(driftProvider)); await migrateStoreToSqlite(ref.read(isarProvider), ref.read(driftProvider)); + await ref.read(backgroundServiceProvider).disableService(); } } else { await ref.read(backgroundSyncProvider).cancel(); ref.read(websocketProvider.notifier).stopListeningToBetaEvents(); ref.read(websocketProvider.notifier).startListeningToOldEvents(); await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider)); + await ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); + await ref.read(driftBackgroundUploadFgService).disableUploadService(); } await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta); diff --git a/mobile/lib/platform/background_worker_api.g.dart b/mobile/lib/platform/background_worker_api.g.dart new file mode 100644 index 0000000000..646eb63b76 --- /dev/null +++ b/mobile/lib/platform/background_worker_api.g.dart @@ -0,0 +1,296 @@ +// Autogenerated from Pigeon (v26.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +List wrapResponse({Object? result, PlatformException? error, bool empty = false}) { + if (empty) { + return []; + } + if (error == null) { + return [result]; + } + return [error.code, error.message, error.details]; +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + default: + return super.readValueOfType(type, buffer); + } + } +} + +class BackgroundWorkerFgHostApi { + /// Constructor for [BackgroundWorkerFgHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + BackgroundWorkerFgHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future enableSyncWorker() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future enableUploadWorker(int callbackHandle) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([callbackHandle]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future disableUploadWorker() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } +} + +class BackgroundWorkerBgHostApi { + /// Constructor for [BackgroundWorkerBgHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + BackgroundWorkerBgHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future onInitialized() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.onInitialized$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } +} + +abstract class BackgroundWorkerFlutterApi { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + Future onLocalSync(int? maxSeconds); + + Future onIosUpload(bool isRefresh, int? maxSeconds); + + Future onAndroidUpload(); + + Future cancel(); + + static void setUp( + BackgroundWorkerFlutterApi? api, { + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) { + messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert( + message != null, + 'Argument for dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync was null.', + ); + final List args = (message as List?)!; + final int? arg_maxSeconds = (args[0] as int?); + try { + await api.onLocalSync(arg_maxSeconds); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert( + message != null, + 'Argument for dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload was null.', + ); + final List args = (message as List?)!; + final bool? arg_isRefresh = (args[0] as bool?); + assert( + arg_isRefresh != null, + 'Argument for dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload was null, expected non-null bool.', + ); + final int? arg_maxSeconds = (args[1] as int?); + try { + await api.onIosUpload(arg_isRefresh!, arg_maxSeconds); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + await api.onAndroidUpload(); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.cancel$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + await api.cancel(); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } + } +} diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 76cb383465..6035e53e5d 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/background_worker.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -17,6 +18,7 @@ import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; +import 'package:immich_mobile/platform/background_worker_api.g.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; @@ -34,6 +36,8 @@ import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; +final driftBackgroundUploadFgService = Provider((ref) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi())); + final backupProvider = StateNotifierProvider((ref) { return BackupNotifier( ref.watch(backupServiceProvider), diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index 220dbf81c3..c8b06ae102 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -27,8 +27,12 @@ class UploadRepository { ); } - void enqueueBackgroundAll(List tasks) { - FileDownloader().enqueueAll(tasks); + Future enqueueBackground(UploadTask task) { + return FileDownloader().enqueue(task); + } + + Future enqueueBackgroundAll(List tasks) { + return FileDownloader().enqueueAll(tasks); } Future deleteDatabaseRecords(String group) { diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index 9e5193c8cb..635604b096 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -78,8 +78,8 @@ class UploadService { _taskProgressController.close(); } - void enqueueTasks(List tasks) { - _uploadRepository.enqueueBackgroundAll(tasks); + Future enqueueTasks(List tasks) { + return _uploadRepository.enqueueBackgroundAll(tasks); } Future> getActiveTasks(String group) { @@ -113,7 +113,7 @@ class UploadService { } if (tasks.isNotEmpty) { - enqueueTasks(tasks); + await enqueueTasks(tasks); } } @@ -149,13 +149,37 @@ class UploadService { if (tasks.isNotEmpty && !shouldAbortQueuingTasks) { count += tasks.length; - enqueueTasks(tasks); + await enqueueTasks(tasks); onEnqueueTasks(EnqueueStatus(enqueueCount: count, totalCount: candidates.length)); } } } + // Enqueue All does not work from the background on Android yet. This method is a temporary workaround + // that enqueues tasks one by one. + Future startBackupSerial(String userId) async { + await _storageRepository.clearCache(); + + shouldAbortQueuingTasks = false; + + final candidates = await _backupRepository.getCandidates(userId); + if (candidates.isEmpty) { + return; + } + + for (final asset in candidates) { + if (shouldAbortQueuingTasks) { + break; + } + + final task = await _getUploadTask(asset); + if (task != null) { + await _uploadRepository.enqueueBackground(task); + } + } + } + /// Cancel all ongoing uploads and reset the upload queue /// /// Return the number of left over tasks in the queue diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index 480d918b4e..e7abc66040 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -1,6 +1,8 @@ import 'dart:io'; +import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; @@ -11,6 +13,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; @@ -22,6 +25,36 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart' import 'package:isar/isar.dart'; import 'package:path_provider/path_provider.dart'; +void configureFileDownloaderNotifications() { + FileDownloader().configureNotificationForGroup( + kDownloadGroupImage, + running: TaskNotification('downloading_media'.t(), '${'file_name'.t()}: {filename}'), + complete: TaskNotification('download_finished'.t(), '${'file_name'.t()}: {filename}'), + progressBar: true, + ); + + FileDownloader().configureNotificationForGroup( + kDownloadGroupVideo, + running: TaskNotification('downloading_media'.t(), '${'file_name'.t()}: {filename}'), + complete: TaskNotification('download_finished'.t(), '${'file_name'.t()}: {filename}'), + progressBar: true, + ); + + FileDownloader().configureNotificationForGroup( + kManualUploadGroup, + running: TaskNotification('uploading_media'.t(), 'backup_background_service_in_progress_notification'.t()), + complete: TaskNotification('upload_finished'.t(), 'backup_background_service_in_progress_notification'.t()), + groupNotificationId: kManualUploadGroup, + ); + + FileDownloader().configureNotificationForGroup( + kBackupGroup, + running: TaskNotification('uploading_media'.t(), 'backup_background_service_in_progress_notification'.t()), + complete: TaskNotification('upload_finished'.t(), 'backup_background_service_in_progress_notification'.t()), + groupNotificationId: kBackupGroup, + ); +} + abstract final class Bootstrap { static Future<(Isar isar, Drift drift, DriftLogger logDb)> initDB() async { final drift = Drift(); diff --git a/mobile/lib/utils/isolate.dart b/mobile/lib/utils/isolate.dart index 58e7ad7f25..cca1498e0f 100644 --- a/mobile/lib/utils/isolate.dart +++ b/mobile/lib/utils/isolate.dart @@ -57,7 +57,7 @@ Cancelable runInIsolateGentle({ log.severe("Error in runInIsolateGentle ${debugLabel == null ? '' : ' for $debugLabel'}", error, stack); } finally { try { - await LogService.I.flush(); + await LogService.I.dispose(); await logDb.close(); await ref.read(driftProvider).close(); @@ -72,8 +72,8 @@ Cancelable runInIsolateGentle({ } ref.dispose(); - } catch (error) { - debugPrint("Error closing resources in isolate: $error"); + } catch (error, stack) { + debugPrint("Error closing resources in isolate: $error, $stack"); } finally { ref.dispose(); // Delay to ensure all resources are released diff --git a/mobile/makefile b/mobile/makefile index 5a31481f45..1a20e769ef 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -8,8 +8,10 @@ build: pigeon: dart run pigeon --input pigeon/native_sync_api.dart dart run pigeon --input pigeon/thumbnail_api.dart + dart run pigeon --input pigeon/background_worker_api.dart dart format lib/platform/native_sync_api.g.dart dart format lib/platform/thumbnail_api.g.dart + dart format lib/platform/background_worker_api.g.dart watch: dart run build_runner watch --delete-conflicting-outputs diff --git a/mobile/pigeon/background_worker_api.dart b/mobile/pigeon/background_worker_api.dart new file mode 100644 index 0000000000..eb1b7a2c5e --- /dev/null +++ b/mobile/pigeon/background_worker_api.dart @@ -0,0 +1,48 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/background_worker_api.g.dart', + swiftOut: 'ios/Runner/Background/BackgroundWorker.g.swift', + swiftOptions: SwiftOptions(includeErrorClass: false), + kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.background'), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +@HostApi() +abstract class BackgroundWorkerFgHostApi { + void enableSyncWorker(); + + // Enables the background upload service with the given callback handle + void enableUploadWorker(int callbackHandle); + + // Disables the background upload service + void disableUploadWorker(); +} + +@HostApi() +abstract class BackgroundWorkerBgHostApi { + // Called from the background flutter engine when it has bootstrapped and established the + // required platform channels to notify the native side to start the background upload + void onInitialized(); +} + +@FlutterApi() +abstract class BackgroundWorkerFlutterApi { + // Android & iOS: Called when the local sync is triggered + @async + void onLocalSync(int? maxSeconds); + + // iOS Only: Called when the iOS background upload is triggered + @async + void onIosUpload(bool isRefresh, int? maxSeconds); + + // Android Only: Called when the Android background upload is triggered + @async + void onAndroidUpload(); + + @async + void cancel(); +} From 80fa5ec19884c5ca37c3df56c96aa769d16672cf Mon Sep 17 00:00:00 2001 From: xCJPECKOVERx Date: Thu, 28 Aug 2025 12:47:53 -0400 Subject: [PATCH 107/137] fix(web): Slideshow fade occurs when not in slideshow (#21326) - ensure slideshow transition only shows when both enabled and in a slideshow --- web/src/lib/components/asset-viewer/asset-viewer.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 452510f508..0737031635 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -491,7 +491,7 @@ onPreviousAsset={() => navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} {sharedLink} - haveFadeTransition={$slideshowState === SlideshowState.None || $slideshowTransition} + haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition} /> {/if} {:else} From 662d44536e85e1b18cbca6c181967637b27c201e Mon Sep 17 00:00:00 2001 From: Johann Date: Thu, 28 Aug 2025 18:54:11 +0200 Subject: [PATCH 108/137] feat(web): add geolocation utility (#20758) * feat(geolocation): add geolocation utility * feat(web): geolocation utility - fix code review - 1 * feat(web): geolocation utility - fix code review - 2 * chore: cleanup * chore: feedback * feat(web): add animation and text animation on locations change and action text on thumbnail * styling, messages and filtering * selected color * format i18n * fix lint --------- Co-authored-by: Jason Rasmussen Co-authored-by: Alex --- i18n/en.json | 13 + web/src/lib/assets/empty-5.svg | 1 + .../asset-viewer/detail-panel-location.svelte | 12 +- .../assets/thumbnail/thumbnail.svelte | 2 +- .../actions/change-location-action.svelte | 15 +- .../shared-components/change-location.svelte | 42 +-- .../shared-components/date-picker.svelte | 113 ++++++ .../geolocation/geolocation.svelte | 104 ++++++ .../utilities-page/utilities-menu.svelte | 32 +- web/src/lib/constants.ts | 1 + .../GeolocationUpdateConfirmModal.svelte | 33 ++ web/src/lib/utils/date-time.spec.ts | 23 +- web/src/lib/utils/date-time.ts | 30 ++ web/src/lib/utils/navigation.ts | 13 + web/src/lib/utils/string-utils.ts | 10 + .../(user)/utilities/geolocation/+page.svelte | 321 ++++++++++++++++++ .../(user)/utilities/geolocation/+page.ts | 17 + 17 files changed, 733 insertions(+), 49 deletions(-) create mode 100644 web/src/lib/assets/empty-5.svg create mode 100644 web/src/lib/components/shared-components/date-picker.svelte create mode 100644 web/src/lib/components/utilities-page/geolocation/geolocation.svelte create mode 100644 web/src/lib/modals/GeolocationUpdateConfirmModal.svelte create mode 100644 web/src/routes/(user)/utilities/geolocation/+page.svelte create mode 100644 web/src/routes/(user)/utilities/geolocation/+page.ts diff --git a/i18n/en.json b/i18n/en.json index ccd0c9d7fe..5d215e2c36 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -461,6 +461,7 @@ "app_bar_signout_dialog_title": "Sign out", "app_settings": "App Settings", "appears_in": "Appears in", + "apply_count": "Apply ({count, number})", "archive": "Archive", "archive_action_prompt": "{count} added to Archive", "archive_or_unarchive_photo": "Archive or unarchive photo", @@ -1073,12 +1074,18 @@ "gcast_enabled": "Google Cast", "gcast_enabled_description": "This feature loads external resources from Google in order to work.", "general": "General", + "geolocation_instruction_all_have_location": "All assets for this date already have location data. Try showing all assets or select a different date", + "geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map", + "geolocation_instruction_no_date": "Select a date to manage location data for photos and videos from that day", + "geolocation_instruction_no_photos": "No photos or videos found for this date. Select a different date to show them", "get_help": "Get Help", "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", "getting_started": "Getting Started", "go_back": "Go back", "go_to_folder": "Go to folder", "go_to_search": "Go to search", + "gps": "GPS", + "gps_missing": "No GPS", "grant_permission": "Grant permission", "group_albums_by": "Group albums by...", "group_country": "Group by country", @@ -1262,6 +1269,7 @@ "main_branch_warning": "You're using a development version; we strongly recommend using a release version!", "main_menu": "Main menu", "make": "Make", + "manage_geolocation": "Manage location", "manage_shared_links": "Manage shared links", "manage_sharing_with_partners": "Manage sharing with partners", "manage_the_app_settings": "Manage the app settings", @@ -1722,6 +1730,7 @@ "select_user_for_sharing_page_err_album": "Failed to create album", "selected": "Selected", "selected_count": "{count, plural, other {# selected}}", + "selected_gps_coordinates": "selected gps coordinates", "send_message": "Send message", "send_welcome_email": "Send welcome email", "server_endpoint": "Server Endpoint", @@ -1832,8 +1841,10 @@ "shift_to_permanent_delete": "press ⇧ to permanently delete asset", "show_album_options": "Show album options", "show_albums": "Show albums", + "show_all_assets": "Show all assets", "show_all_people": "Show all people", "show_and_hide_people": "Show & hide people", + "show_assets_without_location": "Show assets without location", "show_file_location": "Show file location", "show_gallery": "Show gallery", "show_hidden_people": "Show hidden people", @@ -1993,6 +2004,7 @@ "unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}", "untagged": "Untagged", "up_next": "Up next", + "update_location_action_prompt": "Update the location of {count} selected assets with:", "updated_at": "Updated", "updated_password": "Updated password", "upload": "Upload", @@ -2017,6 +2029,7 @@ "use_biometric": "Use biometric", "use_current_connection": "use current connection", "use_custom_date_range": "Use custom date range instead", + "use_this_location": "Click to use location", "user": "User", "user_has_been_deleted": "This user has been deleted.", "user_id": "User ID", diff --git a/web/src/lib/assets/empty-5.svg b/web/src/lib/assets/empty-5.svg new file mode 100644 index 0000000000..e9e24d0499 --- /dev/null +++ b/web/src/lib/assets/empty-5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/lib/components/asset-viewer/detail-panel-location.svelte b/web/src/lib/components/asset-viewer/detail-panel-location.svelte index 42cbefadf1..783eba9c5f 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-location.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-location.svelte @@ -16,18 +16,22 @@ let isShowChangeLocation = $state(false); - async function handleConfirmChangeLocation(gps: { lng: number; lat: number }) { + const onClose = async (point?: { lng: number; lat: number }) => { isShowChangeLocation = false; + if (!point) { + return; + } + try { asset = await updateAsset({ id: asset.id, - updateAssetDto: { latitude: gps.lat, longitude: gps.lng }, + updateAssetDto: { latitude: point.lat, longitude: point.lng }, }); } catch (error) { handleError(error, $t('errors.unable_to_change_location')); } - } + }; {#if asset.exifInfo?.country} @@ -85,6 +89,6 @@ {#if isShowChangeLocation} - (isShowChangeLocation = false)} /> + {/if} diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 9af9287c76..e01f2dc4f6 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -197,7 +197,7 @@
@@ -39,5 +44,5 @@ /> {/if} {#if isShowChangeLocation} - (isShowChangeLocation = false)} /> + {/if} diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte index 0829adaf4e..831fae02c2 100644 --- a/web/src/lib/components/shared-components/change-location.svelte +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -21,11 +21,11 @@ interface Props { asset?: AssetResponseDto | undefined; - onCancel: () => void; - onConfirm: (point: Point) => void; + point?: Point; + onClose: (point?: Point) => void; } - let { asset = undefined, onCancel, onConfirm }: Props = $props(); + let { asset = undefined, point: initialPoint, onClose }: Props = $props(); let places: PlacesResponseDto[] = $state([]); let suggestedPlaces: PlacesResponseDto[] = $state([]); @@ -38,14 +38,20 @@ let previousLocation = get(lastChosenLocation); - let assetLat = $derived(asset?.exifInfo?.latitude ?? undefined); - let assetLng = $derived(asset?.exifInfo?.longitude ?? undefined); + let assetLat = $derived(initialPoint?.lat ?? asset?.exifInfo?.latitude ?? undefined); + let assetLng = $derived(initialPoint?.lng ?? asset?.exifInfo?.longitude ?? undefined); let mapLat = $derived(assetLat ?? previousLocation?.lat ?? undefined); let mapLng = $derived(assetLng ?? previousLocation?.lng ?? undefined); let zoom = $derived(mapLat !== undefined && mapLng !== undefined ? 12.5 : 1); + $effect(() => { + if (mapElement && initialPoint) { + mapElement.addClipMapMarker(initialPoint.lng, initialPoint.lat); + } + }); + $effect(() => { if (places) { suggestedPlaces = places.slice(0, 5); @@ -55,14 +61,14 @@ } }); - let point: Point | null = $state(null); + let point: Point | null = $state(initialPoint ?? null); - const handleConfirm = () => { - if (point) { + const handleConfirm = (confirmed?: boolean) => { + if (point && confirmed) { lastChosenLocation.set(point); - onConfirm(point); + onClose(point); } else { - onCancel(); + onClose(); } }; @@ -109,6 +115,11 @@ point = { lng: longitude, lat: latitude }; mapElement?.addClipMapMarker(longitude, latitude); }; + + const onUpdate = (lat: number, lng: number) => { + point = { lat, lng }; + mapElement?.addClipMapMarker(lng, lat); + }; (confirmed ? handleConfirm() : onCancel())} + onClose={handleConfirm} > {#snippet promptSnippet()}
@@ -197,14 +208,7 @@
- { - point = { lat, lng }; - mapElement?.addClipMapMarker(lng, lat); - }} - /> +
{/snippet} diff --git a/web/src/lib/components/shared-components/date-picker.svelte b/web/src/lib/components/shared-components/date-picker.svelte new file mode 100644 index 0000000000..67b1ee73a9 --- /dev/null +++ b/web/src/lib/components/shared-components/date-picker.svelte @@ -0,0 +1,113 @@ + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
diff --git a/web/src/lib/components/utilities-page/geolocation/geolocation.svelte b/web/src/lib/components/utilities-page/geolocation/geolocation.svelte new file mode 100644 index 0000000000..0efde6df7e --- /dev/null +++ b/web/src/lib/components/utilities-page/geolocation/geolocation.svelte @@ -0,0 +1,104 @@ + + +
+
+ { + if (asset.exifInfo?.latitude && asset.exifInfo?.longitude) { + onLocation({ latitude: asset.exifInfo?.latitude, longitude: asset.exifInfo?.longitude }); + } else { + onSelectAsset(asset); + } + }} + onSelect={() => onSelectAsset(asset)} + onMouseEvent={() => onMouseEvent(asset)} + selected={assetInteraction.hasSelectedAsset(asset.id)} + selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} + thumbnailSize={boxWidth} + readonly={hasGps} + /> + + {#if hasGps} +
+ {$t('gps')} +
+ {:else} +
+ {$t('gps_missing')} +
+ {/if} +
+ +
+

+ {new Date(asset.localDateTime).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} +

+

+ {new Date(asset.localDateTime).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZone: 'UTC', + })} +

+ {#if hasGps} +

+ {asset.exifInfo?.country} +

+

+ {asset.exifInfo?.city} +

+ {/if} +
+
diff --git a/web/src/lib/components/utilities-page/utilities-menu.svelte b/web/src/lib/components/utilities-page/utilities-menu.svelte index 5484ce4ea0..97e205fcd1 100644 --- a/web/src/lib/components/utilities-page/utilities-menu.svelte +++ b/web/src/lib/components/utilities-page/utilities-menu.svelte @@ -1,29 +1,23 @@

{$t('organize_your_library').toUpperCase()}

- - - - {$t('review_duplicates')} - - - - - {$t('review_large_files')} - + {#each links as link (link.href)} + + + {link.label} + + {/each}
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index f2de6d5deb..a4cdb656b4 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -52,6 +52,7 @@ export enum AppRoute { UTILITIES = '/utilities', DUPLICATES = '/utilities/duplicates', LARGE_FILES = '/utilities/large-files', + GEOLOCATION = '/utilities/geolocation', FOLDERS = '/folders', TAGS = '/tags', diff --git a/web/src/lib/modals/GeolocationUpdateConfirmModal.svelte b/web/src/lib/modals/GeolocationUpdateConfirmModal.svelte new file mode 100644 index 0000000000..7bad707447 --- /dev/null +++ b/web/src/lib/modals/GeolocationUpdateConfirmModal.svelte @@ -0,0 +1,33 @@ + + + + +

+ {$t('update_location_action_prompt', { + values: { + count: assetCount, + }, + })} +

+ +

- {$t('latitude')}: {location.latitude}

+

- {$t('longitude')}: {location.longitude}

+
+ + + + + + +
diff --git a/web/src/lib/utils/date-time.spec.ts b/web/src/lib/utils/date-time.spec.ts index d96bef45d6..bca57863a9 100644 --- a/web/src/lib/utils/date-time.spec.ts +++ b/web/src/lib/utils/date-time.spec.ts @@ -1,5 +1,5 @@ import { writable } from 'svelte/store'; -import { getAlbumDateRange, timeToSeconds } from './date-time'; +import { buildDateRangeFromYearMonthAndDay, getAlbumDateRange, timeToSeconds } from './date-time'; describe('converting time to seconds', () => { it('parses hh:mm:ss correctly', () => { @@ -75,3 +75,24 @@ describe('getAlbumDate', () => { expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00+05:00' })).toEqual('Jan 1, 2021'); }); }); + +describe('buildDateRangeFromYearMonthAndDay', () => { + it('should build correct date range for a specific day', () => { + const result = buildDateRangeFromYearMonthAndDay(2023, 1, 8); + + expect(result.from).toContain('2023-01-08T00:00:00'); + expect(result.to).toContain('2023-01-09T00:00:00'); + }); + + it('should build correct date range for a month', () => { + const result = buildDateRangeFromYearMonthAndDay(2023, 2); + expect(result.from).toContain('2023-02-01T00:00:00'); + expect(result.to).toContain('2023-03-01T00:00:00'); + }); + + it('should build correct date range for a year', () => { + const result = buildDateRangeFromYearMonthAndDay(2023); + expect(result.from).toContain('2023-01-01T00:00:00'); + expect(result.to).toContain('2024-01-01T00:00:00'); + }); +}); diff --git a/web/src/lib/utils/date-time.ts b/web/src/lib/utils/date-time.ts index 8a50df9cfe..bf87d041cc 100644 --- a/web/src/lib/utils/date-time.ts +++ b/web/src/lib/utils/date-time.ts @@ -85,3 +85,33 @@ export const getAlbumDateRange = (album: { startDate?: string; endDate?: string */ export const asLocalTimeISO = (date: DateTime) => (date.setZone('utc', { keepLocalTime: true }) as DateTime).toISO(); + +/** + * Creates a date range for filtering assets based on year, month, and day parameters + */ +export const buildDateRangeFromYearMonthAndDay = (year: number, month?: number, day?: number) => { + const baseDate = DateTime.fromObject({ + year, + month: month || 1, + day: day || 1, + }); + + let from: DateTime; + let to: DateTime; + + if (day) { + from = baseDate.startOf('day'); + to = baseDate.plus({ days: 1 }).startOf('day'); + } else if (month) { + from = baseDate.startOf('month'); + to = baseDate.plus({ months: 1 }).startOf('month'); + } else { + from = baseDate.startOf('year'); + to = baseDate.plus({ years: 1 }).startOf('year'); + } + + return { + from: from.toISO() || undefined, + to: to.toISO() || undefined, + }; +}; diff --git a/web/src/lib/utils/navigation.ts b/web/src/lib/utils/navigation.ts index 642c8165df..c3fe051f12 100644 --- a/web/src/lib/utils/navigation.ts +++ b/web/src/lib/utils/navigation.ts @@ -145,3 +145,16 @@ export const clearQueryParam = async (queryParam: string, url: URL) => { await goto(url, { keepFocus: true }); } }; + +export const getQueryValue = (queryKey: string) => { + const url = globalThis.location.href; + const urlObject = new URL(url); + return urlObject.searchParams.get(queryKey); +}; + +export const setQueryValue = async (queryKey: string, queryValue: string) => { + const url = globalThis.location.href; + const urlObject = new URL(url); + urlObject.searchParams.set(queryKey, queryValue); + await goto(urlObject, { keepFocus: true }); +}; diff --git a/web/src/lib/utils/string-utils.ts b/web/src/lib/utils/string-utils.ts index 0170c34737..3795af40c7 100644 --- a/web/src/lib/utils/string-utils.ts +++ b/web/src/lib/utils/string-utils.ts @@ -5,3 +5,13 @@ export const removeAccents = (str: string) => { export const normalizeSearchString = (str: string) => { return removeAccents(str.toLocaleLowerCase()); }; + +export const buildDateString = (year: number, month?: number, day?: number) => { + return [ + year.toString(), + month && !Number.isNaN(month) ? month.toString() : undefined, + day && !Number.isNaN(day) ? day.toString() : undefined, + ] + .filter((date) => date !== undefined) + .join('-'); +}; diff --git a/web/src/routes/(user)/utilities/geolocation/+page.svelte b/web/src/routes/(user)/utilities/geolocation/+page.svelte new file mode 100644 index 0000000000..c251146b45 --- /dev/null +++ b/web/src/routes/(user)/utilities/geolocation/+page.svelte @@ -0,0 +1,321 @@ + + + + + + {#snippet buttons()} +
+ {#if filteredAssets.length > 0} + + {/if} +
+

{$t('selected_gps_coordinates')}

+ {location.latitude.toFixed(3)}, {location.longitude.toFixed(3)} +
+ + + +
+ {/snippet} + +
+
+ +
+ +
+ + +
+
+ + {#if isLoading} +
+ +
+ {/if} + + {#if filteredAssets && filteredAssets.length > 0} +
+ {#each filteredAssets as asset (asset.id)} + handleSelectAssets(asset)} + onMouseEvent={(asset) => assetMouseEventHandler(asset)} + onLocation={(selected) => { + location = selected; + locationUpdated = true; + setTimeout(() => { + locationUpdated = false; + }, 1000); + }} + /> + {/each} +
+ {:else} +
+ {#if partialDate == null} + + {:else if showOnlyAssetsWithoutLocation && filteredAssets.length === 0 && assets.length > 0} + + {:else} + + {/if} +
+ {/if} +
diff --git a/web/src/routes/(user)/utilities/geolocation/+page.ts b/web/src/routes/(user)/utilities/geolocation/+page.ts new file mode 100644 index 0000000000..f5c227a7ef --- /dev/null +++ b/web/src/routes/(user)/utilities/geolocation/+page.ts @@ -0,0 +1,17 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getQueryValue } from '$lib/utils/navigation'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(url); + const partialDate = getQueryValue('date'); + const $t = await getFormatter(); + + return { + partialDate, + meta: { + title: $t('manage_geolocation'), + }, + }; +}) satisfies PageLoad; From 8853079c5476eace3b359d6f6f0dff6b38957930 Mon Sep 17 00:00:00 2001 From: Sudheer Reddy Puthana Date: Thu, 28 Aug 2025 13:30:15 -0400 Subject: [PATCH 109/137] feat(mobile): add read only mode (#19368) * feat(mobile): Add Kid (Readonly) Mode toggle This commit introduces a "Kid (Readonly) Mode" feature. - Adds a `KidModeProvider` to manage the state of Kid Mode. - Implements a `KidModeCheckbox` widget in the app bar dialog to toggle Kid Mode. - When Kid Mode is enabled, - Disables selecting the multigrid & the bottom bar - Removes the top bar from view Signed-off-by: Sudheer Puthana Reverts the changes to devtools_options.yaml file Signed-off-by: Sudheer Puthana refactor: replace Kid Mode with Readonly Mode This commit replaces the "Kid Mode" feature with a more generic "Readonly Mode". - Renamed `KidModeProvider` to `ReadonlyModeProvider`. - Readonly Mode state is now persisted in app settings. - Added a new app setting `allowUserAvatarOverride` to toggle read-only mode. - Updated translations. - Added a message in the app bar dialog indicating when read-only mode is active. Signed-off-by: Sudheer Puthana Address comments - - Removes the `allowUserAvatarOverride` setting. - Hides the bottom gallery bar when read-only mode is enabled. - Adds an icon on the main app bar when read-only mode is enabled with a snackbar. Signed-off-by: Sudheer Puthana Update to snackbar - When toggling readonly mode from either the settings or the app bar, a snackbar notification will now appear. - The readonly mode message in the profile drawer has been restyled. - The upload button in the app bar is now hidden when readonly mode is enabled. Signed-off-by: Sudheer Puthana Removes clearing of snackbar Signed-off-by: Sudheer Puthana Address Comments - Consolidated snackbar messages for enabling/disabling readonly mode. - Ensured the "Select All" icon in asset group titles is hidden in readonly mode. Signed-off-by: Sudheer Puthana Adds in the missing translation keys for readonly_mode Signed-off-by: Sudheer Puthana Fix translation Signed-off-by: Sudheer Puthana Fix check failure for BorderRadius Signed-off-by: Sudheer Puthana Changes: - Adjusted AppBar background color in readonly mode. - Removes cross-out pencil icon button in favor of above. - Hides the "Edit" icon next to date/time, disable description and onTap for people and location when readonly mode is enabled. Signed-off-by: Sudheer Puthana Address comments from Alex - Moved readonly mode check to `GalleryAppBar` to hide the entire `TopControlAppBar` when readonly mode is enabled. - Changed `toggleReadonlyMode` in `ImmichAppBar` to directly toggle the state. Signed-off-by: Sudheer Puthana migrate readonly mode to new beta timeline remove readonly mode from legacy UI only show readonly functionality when on beta timeline simplify selection icon update generated provider chore: more formatting * fix: bad merge * chore: use Notifier for readonlyModeProvider * fix: drag select now honors readonly mode * fix: disable asset bottom sheet in readonly * fix: disable editing user icon when in readonly * chore: remove generated file * fix: disable tabs instead entire tab bar This solves the issues with the scrubber * chore: remove unneeded import * chore: lint * remove unused condition in bottomsheet --------- Co-authored-by: Brandon Wees Co-authored-by: Alex --- i18n/en.json | 5 ++ mobile/lib/domain/models/store.model.dart | 3 ++ .../pages/common/change_experience.page.dart | 2 + mobile/lib/pages/common/tab_shell.page.dart | 5 ++ .../asset_viewer/asset_viewer.page.dart | 3 +- .../asset_viewer/bottom_bar.widget.dart | 4 +- .../asset_viewer/top_app_bar.widget.dart | 4 +- .../widgets/timeline/fixed/segment.model.dart | 4 +- .../widgets/timeline/header.widget.dart | 27 ++++++----- .../widgets/timeline/timeline.widget.dart | 6 ++- .../readonly_mode.provider.dart | 36 ++++++++++++++ mobile/lib/services/app_settings.service.dart | 3 +- .../common/app_bar_dialog/app_bar_dialog.dart | 22 +++++++++ .../app_bar_dialog/app_bar_profile_info.dart | 47 ++++++++++++++----- .../widgets/common/immich_sliver_app_bar.dart | 25 ++++++++-- .../widgets/settings/advanced_settings.dart | 24 ++++++++++ 16 files changed, 187 insertions(+), 33 deletions(-) create mode 100644 mobile/lib/providers/infrastructure/readonly_mode.provider.dart diff --git a/i18n/en.json b/i18n/en.json index 5d215e2c36..c83aac618e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -396,6 +396,8 @@ "advanced_settings_prefer_remote_title": "Prefer remote images", "advanced_settings_proxy_headers_subtitle": "Define proxy headers Immich should send with each network request", "advanced_settings_proxy_headers_title": "Proxy Headers", + "advanced_settings_readonly_mode_subtitle": "Enables the read-only mode where the photos can be only viewed, things like selecting multiple images, sharing, casting, delete are all disabled. Enable/Disable read-only via user avatar from the main screen", + "advanced_settings_readonly_mode_title": "Read-only Mode", "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates", "advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web", @@ -1516,6 +1518,7 @@ "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", "profile_drawer_github": "GitHub", + "profile_drawer_readonly_mode": "Read-only mode enabled. Double-tap the user avatar icon to exit.", "profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.", "profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.", "profile_image_of_user": "Profile image of {user}", @@ -1561,6 +1564,8 @@ "rating_description": "Display the EXIF rating in the info panel", "reaction_options": "Reaction options", "read_changelog": "Read Changelog", + "readonly_mode_disabled": "Read-only mode disabled", + "readonly_mode_enabled": "Read-only mode enabled", "reassign": "Reassign", "reassigned_assets_to_existing_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to {name, select, null {an existing person} other {{name}}}", "reassigned_assets_to_new_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to a new person", diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index e4e316b814..6dcd81774a 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -67,6 +67,9 @@ enum StoreKey { loadOriginalVideo._(136), manageLocalMediaAndroid._(137), + // Read-only Mode settings + readonlyModeEnabled._(138), + // Experimental stuff photoManagerCustomFilter._(1000), betaPromptShown._(1001), diff --git a/mobile/lib/pages/common/change_experience.page.dart b/mobile/lib/pages/common/change_experience.page.dart index 9064f32066..9bb2895907 100644 --- a/mobile/lib/pages/common/change_experience.page.dart +++ b/mobile/lib/pages/common/change_experience.page.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/utils/migration.dart'; @@ -75,6 +76,7 @@ class _ChangeExperiencePageState extends ConsumerState { await ref.read(backgroundSyncProvider).cancel(); ref.read(websocketProvider.notifier).stopListeningToBetaEvents(); ref.read(websocketProvider.notifier).startListeningToOldEvents(); + ref.read(readonlyModeProvider.notifier).setReadonlyMode(false); await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider)); await ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); await ref.read(driftBackgroundUploadFgService).disableUploadService(); diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 983164831a..41b01ad3a3 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -54,6 +55,7 @@ class _TabShellPageState extends ConsumerState { @override Widget build(BuildContext context) { final isScreenLandscape = context.orientation == Orientation.landscape; + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final navigationDestinations = [ NavigationDestination( @@ -65,16 +67,19 @@ class _TabShellPageState extends ConsumerState { label: 'search'.tr(), icon: const Icon(Icons.search_rounded), selectedIcon: Icon(Icons.search, color: context.primaryColor), + enabled: !isReadonlyModeEnabled, ), NavigationDestination( label: 'albums'.tr(), icon: const Icon(Icons.photo_album_outlined), selectedIcon: Icon(Icons.photo_album_rounded, color: context.primaryColor), + enabled: !isReadonlyModeEnabled, ), NavigationDestination( label: 'library'.tr(), icon: const Icon(Icons.space_dashboard_outlined), selectedIcon: Icon(Icons.space_dashboard_rounded, color: context.primaryColor), + enabled: !isReadonlyModeEnabled, ), ]; diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 6c78cfac3e..5e906b820f 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -24,6 +24,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -308,7 +309,7 @@ class _AssetViewerState extends ConsumerState { bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height); } - if (distanceToOrigin > openThreshold && !showingBottomSheet) { + if (distanceToOrigin > openThreshold && !showingBottomSheet && !ref.read(readonlyModeProvider)) { _openBottomSheet(ctx); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 732afee7f9..e581e32df0 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_acti import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart'; @@ -26,6 +27,7 @@ class ViewerBottomBar extends ConsumerWidget { return const SizedBox.shrink(); } + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final user = ref.watch(currentUserProvider); final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final isSheetOpen = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); @@ -60,7 +62,7 @@ class ViewerBottomBar extends ConsumerWidget { duration: Durations.short2, child: AnimatedSwitcher( duration: Durations.short4, - child: isSheetOpen + child: isSheetOpen || isReadonlyModeEnabled ? const SizedBox.shrink() : Theme( data: context.themeData.copyWith( diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index 411e279460..570df1afbb 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -34,6 +35,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final user = ref.watch(currentUserProvider); final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final isInLockedView = ref.watch(inLockedViewProvider); + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final previousRouteName = ref.watch(previousRouteNameProvider); final showViewInTimelineButton = @@ -94,7 +96,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { iconTheme: const IconThemeData(size: 22, color: Colors.white), actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), shape: const Border(), - actions: isShowingSheet + actions: isShowingSheet || isReadonlyModeEnabled ? null : isInLockedView ? lockedViewActions diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index 05f96d49de..5eda738e76 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -190,11 +191,12 @@ class _AssetTileWidget extends ConsumerWidget { final lockSelection = _getLockSelectionStatus(ref); final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator)); + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); return RepaintBoundary( child: GestureDetector( onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset), - onLongPress: () => lockSelection ? null : _handleOnLongPress(ref, asset), + onLongPress: () => lockSelection || isReadonlyModeEnabled ? null : _handleOnLongPress(ref, asset), child: ThumbnailTile( asset, lockSelection: lockSelection, diff --git a/mobile/lib/presentation/widgets/timeline/header.widget.dart b/mobile/lib/presentation/widgets/timeline/header.widget.dart index 8e383a1477..3eff305251 100644 --- a/mobile/lib/presentation/widgets/timeline/header.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/header.widget.dart @@ -7,9 +7,10 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -class TimelineHeader extends StatelessWidget { +class TimelineHeader extends HookConsumerWidget { final Bucket bucket; final HeaderType header; final double height; @@ -36,13 +37,12 @@ class TimelineHeader extends StatelessWidget { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { if (bucket is! TimeBucket || header == HeaderType.none) { return const SizedBox.shrink(); } final date = (bucket as TimeBucket).date; - final isMonthHeader = header == HeaderType.month || header == HeaderType.monthAndDay; final isDayHeader = header == HeaderType.day || header == HeaderType.monthAndDay; @@ -98,16 +98,19 @@ class _BulkSelectIconButton extends ConsumerWidget { bucketAssets = []; } + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isAllSelected = ref.watch(bucketSelectionProvider(bucketAssets)); - return IconButton( - onPressed: () { - ref.read(multiSelectProvider.notifier).toggleBucketSelection(assetOffset, bucket.assetCount); - ref.read(hapticFeedbackProvider.notifier).heavyImpact(); - }, - icon: isAllSelected - ? Icon(Icons.check_circle_rounded, size: 26, color: context.primaryColor) - : Icon(Icons.check_circle_outline_rounded, size: 26, color: context.colorScheme.onSurfaceSecondary), - ); + return isReadonlyModeEnabled + ? const SizedBox.shrink() + : IconButton( + onPressed: () { + ref.read(multiSelectProvider.notifier).toggleBucketSelection(assetOffset, bucket.assetCount); + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + }, + icon: isAllSelected + ? Icon(Icons.check_circle_rounded, size: 26, color: context.primaryColor) + : Icon(Icons.check_circle_outline_rounded, size: 26, color: context.colorScheme.onSurfaceSecondary), + ); } } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index c859ae0e80..125f8505a1 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -19,6 +19,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -256,6 +257,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { final maxHeight = ref.watch(timelineArgsProvider.select((args) => args.maxHeight)); final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable)); final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); return PopScope( canPop: !isMultiSelectEnabled, @@ -342,9 +344,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { ), }, child: TimelineDragRegion( - onStart: _setDragStartIndex, + onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null, onAssetEnter: _handleDragAssetEnter, - onEnd: _stopDrag, + onEnd: !isReadonlyModeEnabled ? _stopDrag : null, onScroll: _dragScroll, onScrollStart: () { // Minimize the bottom sheet when drag selection starts diff --git a/mobile/lib/providers/infrastructure/readonly_mode.provider.dart b/mobile/lib/providers/infrastructure/readonly_mode.provider.dart new file mode 100644 index 0000000000..9e96c3cfc4 --- /dev/null +++ b/mobile/lib/providers/infrastructure/readonly_mode.provider.dart @@ -0,0 +1,36 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; + +class ReadOnlyModeNotifier extends Notifier { + late AppSettingsService _appSettingService; + + @override + bool build() { + _appSettingService = ref.read(appSettingsServiceProvider); + final readonlyMode = _appSettingService.getSetting(AppSettingsEnum.readonlyModeEnabled); + return readonlyMode; + } + + void setMode(bool value) { + _appSettingService.setSetting(AppSettingsEnum.readonlyModeEnabled, value); + state = value; + + if (value) { + ref.read(appRouterProvider).navigate(const MainTimelineRoute()); + } + } + + void setReadonlyMode(bool isEnabled) { + state = isEnabled; + setMode(state); + } + + void toggleReadonlyMode() { + state = !state; + setMode(state); + } +} + +final readonlyModeProvider = NotifierProvider(() => ReadOnlyModeNotifier()); diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 8a4b0c6719..d98b14408f 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -49,7 +49,8 @@ enum AppSettingsEnum { betaTimeline(StoreKey.betaTimeline, null, false), enableBackup(StoreKey.enableBackup, null, false), useCellularForUploadVideos(StoreKey.useWifiForUploadVideos, null, false), - useCellularForUploadPhotos(StoreKey.useWifiForUploadPhotos, null, false); + useCellularForUploadPhotos(StoreKey.useWifiForUploadPhotos, null, false), + readonlyModeEnabled(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index ccfc374fef..b204058859 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_profile_info.dart'; @@ -33,6 +34,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { final horizontalPadding = isHorizontal ? 100.0 : 20.0; final user = ref.watch(currentUserProvider); final isLoggingOut = useState(false); + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); useEffect(() { ref.read(backupProvider.notifier).updateDiskInfo(); @@ -214,6 +216,25 @@ class ImmichAppBarDialog extends HookConsumerWidget { ); } + buildReadonlyMessage() { + return Padding( + padding: const EdgeInsets.only(left: 10.0, right: 10.0), + child: ListTile( + dense: true, + visualDensity: VisualDensity.standard, + contentPadding: const EdgeInsets.only(left: 20, right: 20), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), + minLeadingWidth: 20, + tileColor: theme.primaryColor.withAlpha(80), + title: Text( + "profile_drawer_readonly_mode", + style: theme.textTheme.labelLarge?.copyWith(color: theme.textTheme.labelLarge?.color?.withAlpha(250)), + textAlign: TextAlign.center, + ).tr(), + ), + ); + } + return Dismissible( behavior: HitTestBehavior.translucent, direction: DismissDirection.down, @@ -238,6 +259,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { const AppBarProfileInfoBox(), buildStorageInformation(), const AppBarServerInfo(), + if (isReadonlyModeEnabled) buildReadonlyMessage(), buildAppLogButton(), buildSettingButton(), buildSignOutButton(), diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index b1f5b192dd..a9c7a467c2 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -1,9 +1,12 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -17,6 +20,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final authState = ref.watch(authProvider); final uploadProfileImageStatus = ref.watch(uploadProfileImageProvider).status; + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final user = ref.watch(currentUserProvider); buildUserProfileImage() { @@ -55,6 +59,25 @@ class AppBarProfileInfoBox extends HookConsumerWidget { } } + void toggleReadonlyMode() { + // read only mode is only supported int he beta experience + // TODO: remove this check when the beta UI goes stable + if (!Store.isBetaTimelineEnabled) return; + + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); + ref.read(readonlyModeProvider.notifier).toggleReadonlyMode(); + + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + (isReadonlyModeEnabled ? "readonly_mode_disabled" : "readonly_mode_enabled").tr(), + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + ), + ); + } + return Padding( padding: const EdgeInsets.symmetric(horizontal: 10.0), child: Container( @@ -67,23 +90,25 @@ class AppBarProfileInfoBox extends HookConsumerWidget { minLeadingWidth: 50, leading: GestureDetector( onTap: pickUserProfileImage, + onDoubleTap: toggleReadonlyMode, child: Stack( clipBehavior: Clip.none, children: [ buildUserProfileImage(), - Positioned( - bottom: -5, - right: -8, - child: Material( - color: context.colorScheme.surfaceContainerHighest, - elevation: 3, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(50.0))), - child: Padding( - padding: const EdgeInsets.all(5.0), - child: Icon(Icons.camera_alt_outlined, color: context.primaryColor, size: 14), + if (!isReadonlyModeEnabled) + Positioned( + bottom: -5, + right: -8, + child: Material( + color: context.colorScheme.surfaceContainerHighest, + elevation: 3, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(50.0))), + child: Padding( + padding: const EdgeInsets.all(5.0), + child: Icon(Icons.camera_alt_outlined, color: context.primaryColor, size: 14), + ), ), ), - ), ], ), ), diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 06a97d1ce5..78fa607666 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/sync_status.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -42,6 +43,7 @@ class ImmichSliverAppBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); return SliverAnimatedOpacity( @@ -57,7 +59,7 @@ class ImmichSliverAppBar extends ConsumerWidget { centerTitle: false, title: title ?? const _ImmichLogoWithText(), actions: [ - if (isCasting) + if (isCasting && !isReadonlyModeEnabled) Padding( padding: const EdgeInsets.only(right: 12), child: IconButton( @@ -70,12 +72,13 @@ class ImmichSliverAppBar extends ConsumerWidget { const _SyncStatusIndicator(), if (actions != null) ...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)), - if (kDebugMode || kProfileMode) + if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled) IconButton( icon: const Icon(Icons.science_rounded), onPressed: () => context.pushRoute(const FeatInDevRoute()), ), - if (showUploadButton) const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()), + if (showUploadButton && !isReadonlyModeEnabled) + const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()), const Padding(padding: EdgeInsets.only(right: 20), child: _ProfileIndicator()), ], ), @@ -137,8 +140,24 @@ class _ProfileIndicator extends ConsumerWidget { final user = ref.watch(currentUserProvider); const widgetSize = 30.0; + void toggleReadonlyMode() { + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); + ref.read(readonlyModeProvider.notifier).toggleReadonlyMode(); + + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + (isReadonlyModeEnabled ? "readonly_mode_disabled" : "readonly_mode_enabled").tr(), + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + ), + ); + } + return InkWell( onTap: () => showDialog(context: context, useRootNavigator: false, builder: (ctx) => const ImmichAppBarDialog()), + onDoubleTap: () => toggleReadonlyMode(), borderRadius: const BorderRadius.all(Radius.circular(12)), child: Badge( label: Container( diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index 3f196b840b..cd2fa93b85 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -6,7 +6,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; @@ -31,6 +34,7 @@ class AdvancedSettings extends HookConsumerWidget { final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert); final useAlternatePMFilter = useAppSettingsState(AppSettingsEnum.photoManagerCustomFilter); + final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled); final logLevel = Level.LEVELS[levelId.value].name; @@ -102,6 +106,26 @@ class AdvancedSettings extends HookConsumerWidget { title: "advanced_settings_enable_alternate_media_filter_title".tr(), subtitle: "advanced_settings_enable_alternate_media_filter_subtitle".tr(), ), + // TODO: Remove this check when beta timeline goes stable + if (Store.isBetaTimelineEnabled) + SettingsSwitchListTile( + valueNotifier: readonlyModeEnabled, + title: "advanced_settings_readonly_mode_title".tr(), + subtitle: "advanced_settings_readonly_mode_subtitle".tr(), + onChanged: (value) { + readonlyModeEnabled.value = value; + ref.read(readonlyModeProvider.notifier).setReadonlyMode(value); + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + (value ? "readonly_mode_enabled" : "readonly_mode_disabled").tr(), + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + ), + ); + }, + ), ]; return SettingsSubPageScaffold(settings: advancedSettings); From b6223af5cab601e8ad9e4b332e580becdc83faa3 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 18:50:45 +0000 Subject: [PATCH 110/137] chore: version v1.140.0 --- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package.json | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package.json | 2 +- web/package.json | 2 +- 12 files changed, 16 insertions(+), 12 deletions(-) diff --git a/cli/package.json b/cli/package.json index 8962ad4645..b11cebccea 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.84", + "version": "2.2.85", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index b884b358f0..8988cda910 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.140.0", + "url": "https://v1.140.0.archive.immich.app" + }, { "label": "v1.139.4", "url": "https://v1.139.4.archive.immich.app" diff --git a/e2e/package.json b/e2e/package.json index beddd8e49d..559ddf00ee 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.139.4", + "version": "1.140.0", "description": "", "main": "index.js", "type": "module", diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 1be349f808..dee51026d5 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 3009, - "android.injected.version.name" => "1.139.4", + "android.injected.version.code" => 3010, + "android.injected.version.name" => "1.140.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 60a5f24eef..85b3c451d7 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -22,7 +22,7 @@ platform :ios do path: "./Runner.xcodeproj", ) increment_version_number( - version_number: "1.139.4" + version_number: "1.140.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 27a0c6fcbe..6e9b23ed8f 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.139.4 +- API version: 1.140.0 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 7d40c80a26..b3f659af24 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.139.4+3009 +version: 1.140.0+3010 environment: sdk: '>=3.8.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 44b4e0da4f..2692aa7593 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9789,7 +9789,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.139.4", + "version": "1.140.0", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 5b0b693c85..e67bcdaac6 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.139.4", + "version": "1.140.0", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 3213b5e240..c80e8d6a4b 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.139.4 + * 1.140.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package.json b/server/package.json index 5ac0a8f043..217fc80dfe 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.139.4", + "version": "1.140.0", "description": "", "author": "", "private": true, diff --git a/web/package.json b/web/package.json index dffc246da2..f60f55bdd1 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.139.4", + "version": "1.140.0", "license": "GNU Affero General Public License version 3", "type": "module", "scripts": { From 460e1d4715a9910ce451c01f5c8acc857a2beb5f Mon Sep 17 00:00:00 2001 From: Sergey Katsubo Date: Fri, 29 Aug 2025 03:22:40 +0300 Subject: [PATCH 111/137] fix(server): folder sort order (#21383) --- server/src/queries/view.repository.sql | 2 ++ server/src/repositories/view-repository.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/server/src/queries/view.repository.sql b/server/src/queries/view.repository.sql index 81f5ca20b8..31da10123f 100644 --- a/server/src/queries/view.repository.sql +++ b/server/src/queries/view.repository.sql @@ -12,6 +12,8 @@ where and "fileCreatedAt" is not null and "fileModifiedAt" is not null and "localDateTime" is not null +order by + "directoryPath" asc -- ViewRepository.getAssetsByOriginalPath select diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts index 93c1280191..ceab79f6eb 100644 --- a/server/src/repositories/view-repository.ts +++ b/server/src/repositories/view-repository.ts @@ -20,6 +20,7 @@ export class ViewRepository { .where('fileCreatedAt', 'is not', null) .where('fileModifiedAt', 'is not', null) .where('localDateTime', 'is not', null) + .orderBy('directoryPath', 'asc') .execute(); return results.map((row) => row.directoryPath.replaceAll(/\/$/g, '')); From 94872414817bc3bdd4c728668603cf1d67f7aab3 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 28 Aug 2025 20:23:40 -0400 Subject: [PATCH 112/137] fix(server): refresh faces query (#21380) --- server/src/queries/asset.job.repository.sql | 3 +-- server/src/repositories/asset-job.repository.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index df8163be3e..ef1b5fe79e 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -468,9 +468,8 @@ where "asset"."visibility" != $1 and "asset"."deletedAt" is null and "job_status"."previewAt" is not null - and "job_status"."facesRecognizedAt" is null order by - "asset"."createdAt" desc + "asset"."fileCreatedAt" desc -- AssetJobRepository.streamForMigrationJob select diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 0500bb867f..f7715b027c 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -334,9 +334,9 @@ export class AssetJobRepository { @GenerateSql({ params: [], stream: true }) streamForDetectFacesJob(force?: boolean) { return this.assetsWithPreviews() - .$if(!force, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null)) + .$if(force === false, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null)) .select(['asset.id']) - .orderBy('asset.createdAt', 'desc') + .orderBy('asset.fileCreatedAt', 'desc') .stream(); } From 147accd9579b6d7800a62cdbe5824a1ded1da85b Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 28 Aug 2025 22:07:29 -0400 Subject: [PATCH 113/137] fix: fix docker perms for dev (#21359) --- .../server/container-compose-overrides.yml | 2 +- .github/workflows/test.yml | 3 +- Makefile | 38 ++++++++++++++----- docker/docker-compose.dev.yml | 2 +- misc/release/pump-version.sh | 2 +- 5 files changed, 33 insertions(+), 14 deletions(-) diff --git a/.devcontainer/server/container-compose-overrides.yml b/.devcontainer/server/container-compose-overrides.yml index 539caa0dd1..abf34ad68c 100644 --- a/.devcontainer/server/container-compose-overrides.yml +++ b/.devcontainer/server/container-compose-overrides.yml @@ -26,7 +26,7 @@ services: env_file: !reset [] init: env_file: !reset [] - command: sh -c 'for path in /data /data/upload /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done' + command: sh -c 'find /data -maxdepth 1 ! -path "/data/postgres" -type d -exec chown ${UID:-1000}:${GID:-1000} {} + 2>/dev/null || true; for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done' immich-machine-learning: env_file: !reset [] database: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 39b4b12b1a..e3d2c9b0dc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -569,7 +569,8 @@ jobs: - name: Build the app run: pnpm --filter immich build - name: Run API generation - run: make open-api + run: ./bin/generate-open-api.sh + working-directory: open-api - name: Find file changes uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 id: verify-changed-files diff --git a/Makefile b/Makefile index 31a00ee6be..13da918683 100644 --- a/Makefile +++ b/Makefile @@ -60,20 +60,37 @@ VOLUME_DIRS = \ ./e2e/node_modules \ ./docs/node_modules \ ./server/node_modules \ - ./server/dist \ ./open-api/typescript-sdk/node_modules \ ./.github/node_modules \ ./node_modules \ ./cli/node_modules -# create empty directories and chown to current user +# Include .env file if it exists +-include docker/.env + +# Helper function to chown, on error suggest remediation and exit +define safe_chown + if chown $(2) $(or $(UID),1000):$(or $(GID),1000) "$(1)" 2>/dev/null; then \ + true; \ + else \ + echo "Permission denied when changing owner of volumes and upload location. Try running 'sudo make prepare-volumes' first."; \ + exit 1; \ + fi; +endef +# create empty directories and chown prepare-volumes: - @for dir in $(VOLUME_DIRS); do \ - mkdir -p $$dir; \ - done - @if [ -n "$(VOLUME_DIRS)" ]; then \ - chown -R $$(id -u):$$(id -g) $(VOLUME_DIRS); \ - fi + @$(foreach dir,$(VOLUME_DIRS),mkdir -p $(dir);) + @$(foreach dir,$(VOLUME_DIRS),$(call safe_chown,$(dir),-R)) +ifneq ($(UPLOAD_LOCATION),) +ifeq ($(filter /%,$(UPLOAD_LOCATION)),) + @mkdir -p "docker/$(UPLOAD_LOCATION)" + @$(call safe_chown,docker/$(UPLOAD_LOCATION),) +else + @mkdir -p "$(UPLOAD_LOCATION)" + @$(call safe_chown,$(UPLOAD_LOCATION),) +endif +endif + MODULES = e2e server web cli sdk docs .github @@ -150,8 +167,9 @@ clean: find . -name ".svelte-kit" -type d -prune -exec rm -rf '{}' + find . -name "coverage" -type d -prune -exec rm -rf '{}' + find . -name ".pnpm-store" -type d -prune -exec rm -rf '{}' + - command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true - command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml rm -v -f || true + command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml down -v --remove-orphans || true + command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml down -v --remove-orphans || true + setup-server-dev: install-server setup-web-dev: install-sdk build-sdk install-web diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 2c003270e4..372352d12a 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -189,7 +189,7 @@ services: env_file: - .env user: 0:0 - command: sh -c 'for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done' + command: sh -c 'find /data -maxdepth 1 -type d -exec chown ${UID:-1000}:${GID:-1000} {} + 2>/dev/null || true; for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done' volumes: - pnpm-store:/usr/src/app/.pnpm-store - server-node_modules:/usr/src/app/server/node_modules diff --git a/misc/release/pump-version.sh b/misc/release/pump-version.sh index 789805255b..35ce9a1f33 100755 --- a/misc/release/pump-version.sh +++ b/misc/release/pump-version.sh @@ -65,7 +65,7 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then pnpm install --frozen-lockfile --prefix server pnpm --prefix server run build - make open-api + ( cd ./open-api && bash ./bin/generate-open-api.sh ) jq --arg version "$NEXT_SERVER" '.version = $version' open-api/typescript-sdk/package.json > open-api/typescript-sdk/package.json.tmp && mv open-api/typescript-sdk/package.json.tmp open-api/typescript-sdk/package.json From f5954f4c9b24665920c8b97ff2f812abb7a378e9 Mon Sep 17 00:00:00 2001 From: Sergey Katsubo Date: Fri, 29 Aug 2025 18:24:21 +0300 Subject: [PATCH 114/137] chore(docs): Avoid /data in external library examples (#21357) * Avoid /data for external libraries * Remove mention of microservice containers * Update docs/docs/features/libraries.md Co-authored-by: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> --------- Co-authored-by: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> --- docs/docs/features/libraries.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index f274ca3c70..e68bcdc272 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -33,7 +33,7 @@ Sometimes, an external library will not scan correctly. This can happen if Immic - Are the permissions set correctly? - Make sure you are using forward slashes (`/`) and not backward slashes. -To validate that Immich can reach your external library, start a shell inside the container. Run `docker exec -it immich_server bash` to a bash shell. If your import path is `/data/import/photos`, check it with `ls /data/import/photos`. Do the same check for the same in any microservices containers. +To validate that Immich can reach your external library, start a shell inside the container. Run `docker exec -it immich_server bash` to a bash shell. If your import path is `/mnt/photos`, check it with `ls /mnt/photos`. If you are using a dedicated microservices container, make sure to add the same mount point and check for availability within the microservices container as well. ### Exclusion Patterns From f75c9dfe374c0a34ead94c12f08db75553032969 Mon Sep 17 00:00:00 2001 From: Aaron Liu Date: Fri, 29 Aug 2025 16:54:42 -0400 Subject: [PATCH 115/137] fix(devcontainer): logging typo (#21415) --- .devcontainer/server/container-start-backend.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/server/container-start-backend.sh b/.devcontainer/server/container-start-backend.sh index d0176a7d66..35fa60f89b 100755 --- a/.devcontainer/server/container-start-backend.sh +++ b/.devcontainer/server/container-start-backend.sh @@ -11,7 +11,7 @@ run_cmd pnpm --filter immich install log "Starting Nest API Server" log "" cd "${IMMICH_WORKSPACE}/server" || ( - log "Immich workspace not found"jj + log "Immich workspace not found" exit 1 ) From 303307e1ac9949e1a4c56bb1cf27e4969d46135b Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 29 Aug 2025 20:33:58 -0400 Subject: [PATCH 116/137] fix(mobile): memory lane query (#21422) --- mobile/lib/infrastructure/repositories/memory.repository.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/lib/infrastructure/repositories/memory.repository.dart b/mobile/lib/infrastructure/repositories/memory.repository.dart index b5bed18ad5..0dcf7200cc 100644 --- a/mobile/lib/infrastructure/repositories/memory.repository.dart +++ b/mobile/lib/infrastructure/repositories/memory.repository.dart @@ -15,8 +15,8 @@ class DriftMemoryRepository extends DriftDatabaseRepository { final query = _db.select(_db.memoryEntity).join([ - leftOuterJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)), - leftOuterJoin( + innerJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)), + innerJoin( _db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.memoryAssetEntity.assetId) & _db.remoteAssetEntity.deletedAt.isNull() & From b3372064e0620e215a4ad98f6fa045419fa654bb Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Sat, 30 Aug 2025 13:33:11 -0500 Subject: [PATCH 117/137] fix: default zoom level when location is not set (#21428) --- web/src/lib/components/shared-components/change-location.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte index 831fae02c2..2dfab6b631 100644 --- a/web/src/lib/components/shared-components/change-location.svelte +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -44,7 +44,7 @@ let mapLat = $derived(assetLat ?? previousLocation?.lat ?? undefined); let mapLng = $derived(assetLng ?? previousLocation?.lng ?? undefined); - let zoom = $derived(mapLat !== undefined && mapLng !== undefined ? 12.5 : 1); + let zoom = $derived(mapLat && mapLng ? 12.5 : 1); $effect(() => { if (mapElement && initialPoint) { From 225af973c1d651788c80bed7afcf0e13e885aee5 Mon Sep 17 00:00:00 2001 From: Snowknight26 Date: Sat, 30 Aug 2025 13:39:25 -0500 Subject: [PATCH 118/137] fix(web): Prevent changing asset location triggering keyboard shortcuts (#21451) fix(web): Prevent changing asset location triggering asset keyboard shortcuts --- .../__test__/number-range-input.spec.ts | 28 ++++++++++++++++++- .../coordinates-input.svelte | 8 ++++-- .../number-range-input.svelte | 5 +++- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/web/src/lib/components/shared-components/__test__/number-range-input.spec.ts b/web/src/lib/components/shared-components/__test__/number-range-input.spec.ts index be09d2a35c..dc325bd52c 100644 --- a/web/src/lib/components/shared-components/__test__/number-range-input.spec.ts +++ b/web/src/lib/components/shared-components/__test__/number-range-input.spec.ts @@ -1,18 +1,24 @@ import NumberRangeInput from '$lib/components/shared-components/number-range-input.svelte'; import { render, type RenderResult } from '@testing-library/svelte'; import userEvent from '@testing-library/user-event'; +import type { Mock } from 'vitest'; describe('NumberRangeInput component', () => { const user = userEvent.setup(); let sut: RenderResult; let input: HTMLInputElement; + let onInput: Mock; + let onKeyDown: Mock; beforeEach(() => { + onInput = vi.fn(); + onKeyDown = vi.fn(); sut = render(NumberRangeInput, { id: '', min: -90, max: 90, - onInput: () => {}, + onInput, + onKeyDown, }); input = sut.getByRole('spinbutton') as HTMLInputElement; }); @@ -21,35 +27,55 @@ describe('NumberRangeInput component', () => { expect(input.value).toBe(''); await sut.rerender({ value: 10 }); expect(input.value).toBe('10'); + expect(onInput).not.toHaveBeenCalled(); + expect(onKeyDown).not.toHaveBeenCalled(); }); it('restricts minimum value', async () => { await user.type(input, '-91'); expect(input.value).toBe('-90'); + expect(onInput).toHaveBeenCalled(); + expect(onKeyDown).toHaveBeenCalled(); }); it('restricts maximum value', async () => { await user.type(input, '09990'); expect(input.value).toBe('90'); + expect(onInput).toHaveBeenCalled(); + expect(onKeyDown).toHaveBeenCalled(); }); it('allows entering negative numbers', async () => { await user.type(input, '-10'); expect(input.value).toBe('-10'); + expect(onInput).toHaveBeenCalled(); + expect(onKeyDown).toHaveBeenCalled(); }); it('allows entering zero', async () => { await user.type(input, '0'); expect(input.value).toBe('0'); + expect(onInput).toHaveBeenCalled(); + expect(onKeyDown).toHaveBeenCalled(); }); it('allows entering decimal numbers', async () => { await user.type(input, '-0.09001'); expect(input.value).toBe('-0.09001'); + expect(onInput).toHaveBeenCalled(); + expect(onKeyDown).toHaveBeenCalled(); }); it('ignores text input', async () => { await user.type(input, 'test'); expect(input.value).toBe(''); + expect(onInput).toHaveBeenCalled(); + expect(onKeyDown).toHaveBeenCalled(); + }); + + it('test', async () => { + await user.type(input, 'd'); + expect(onInput).not.toHaveBeenCalled(); + expect(onKeyDown).toHaveBeenCalled(); }); }); diff --git a/web/src/lib/components/shared-components/coordinates-input.svelte b/web/src/lib/components/shared-components/coordinates-input.svelte index 9e71197dd5..9b35d1a485 100644 --- a/web/src/lib/components/shared-components/coordinates-input.svelte +++ b/web/src/lib/components/shared-components/coordinates-input.svelte @@ -20,6 +20,10 @@ } }; + const onKeyDown = (event: KeyboardEvent) => { + event.stopPropagation(); + }; + const onPaste = (event: ClipboardEvent) => { const pastedText = event.clipboardData?.getData('text/plain'); if (!pastedText) { @@ -42,10 +46,10 @@
- +
- +
diff --git a/web/src/lib/components/shared-components/number-range-input.svelte b/web/src/lib/components/shared-components/number-range-input.svelte index 95a9a12a98..13b3d18cc0 100644 --- a/web/src/lib/components/shared-components/number-range-input.svelte +++ b/web/src/lib/components/shared-components/number-range-input.svelte @@ -1,6 +1,6 @@