From 1b56bb84f941449d02ca5e738ff94ff33eb77b37 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Mon, 19 Jan 2026 12:22:53 -0600 Subject: [PATCH] fix: mobile edit handling (#25315) Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> --- .../drift_schemas/main/drift_schema_v17.json | Bin 0 -> 43664 bytes .../domain/models/asset/base_asset.model.dart | 9 +- .../models/asset/local_asset.model.dart | 3 + .../models/asset/remote_asset.model.dart | 3 + .../domain/services/local_sync.service.dart | 1 + .../lib/domain/services/search.service.dart | 1 + .../domain/services/sync_stream.service.dart | 36 +++++++ mobile/lib/domain/utils/background_sync.dart | 15 +++ .../entities/local_asset.entity.dart | 1 + .../entities/merged_asset.drift | 6 +- .../entities/merged_asset.drift.dart | Bin 8420 -> 8549 bytes .../entities/remote_asset.entity.dart | 3 + .../entities/remote_asset.entity.drift.dart | Bin 57589 -> 59935 bytes .../entities/trashed_local_asset.entity.dart | 1 + .../repositories/db.repository.dart | 5 +- .../repositories/db.repository.steps.dart | Bin 203269 -> 218059 bytes .../repositories/sync_stream.repository.dart | 1 + .../repositories/timeline.repository.dart | 2 + .../pages/drift_asset_troubleshoot.page.dart | 1 + .../presentation/pages/drift_place.page.dart | 2 +- .../widgets/album/album_selector.widget.dart | 31 ++++-- .../widgets/album/album_tile.dart | 54 +++++++---- .../widgets/images/image_provider.dart | 10 +- .../widgets/images/remote_image_provider.dart | 20 ++-- .../widgets/images/thumbnail.widget.dart | 11 ++- .../widgets/memory/memory_lane.widget.dart | 6 +- mobile/lib/providers/cast.provider.dart | 1 + mobile/lib/providers/websocket.provider.dart | 7 ++ .../repositories/file_media.repository.dart | 1 + mobile/lib/utils/image_url_builder.dart | 4 +- mobile/lib/utils/openapi_patching.dart | 2 +- mobile/openapi/lib/model/sync_asset_v1.dart | Bin 8844 -> 8834 bytes .../sync_stream_repository_test.dart | 2 +- mobile/test/drift/main/generated/schema.dart | Bin 1921 -> 2015 bytes .../test/drift/main/generated/schema_v17.dart | Bin 0 -> 271709 bytes mobile/test/fixtures/asset.stub.dart | 2 + mobile/test/fixtures/sync_stream.stub.dart | 2 +- .../background_upload.service_test.dart | 4 + mobile/test/test_utils.dart | 2 + mobile/test/test_utils/medium_factory.dart | 1 + .../test/utils/action_button_utils_test.dart | 2 + open-api/immich-openapi-specs.json | 8 +- server/src/database.ts | 2 +- server/src/dtos/asset-response.dto.ts | 4 +- server/src/dtos/sync.dto.ts | 4 +- server/src/queries/sync.repository.sql | 12 +-- .../src/repositories/websocket.repository.ts | 2 +- server/src/schema/functions.ts | 11 ++- .../1768757482271-SwitchToIsEdited.ts | 89 ++++++++++++++++++ server/src/schema/tables/asset-edit.table.ts | 6 +- server/src/schema/tables/asset.table.ts | 4 +- server/src/services/job.service.ts | 26 ++++- server/test/fixtures/asset.stub.ts | 50 +++++----- server/test/fixtures/shared-link.stub.ts | 2 +- server/test/medium.factory.ts | 2 +- .../asset-edit.repository.spec.ts | 44 +++++---- .../specs/sync/sync-album-asset.spec.ts | 2 +- .../test/medium/specs/sync/sync-asset.spec.ts | 2 +- .../specs/sync/sync-partner-asset.spec.ts | 2 +- server/test/small.factory.ts | 2 +- .../asset-viewer/asset-viewer-nav-bar.svelte | 32 ++++--- .../asset-viewer/asset-viewer.svelte | 13 +-- .../lib/managers/edit/edit-manager.svelte.ts | 2 +- web/src/lib/stores/websocket.ts | 2 +- 64 files changed, 420 insertions(+), 155 deletions(-) create mode 100644 mobile/drift_schemas/main/drift_schema_v17.json create mode 100644 mobile/test/drift/main/generated/schema_v17.dart create mode 100644 server/src/schema/migrations/1768757482271-SwitchToIsEdited.ts diff --git a/mobile/drift_schemas/main/drift_schema_v17.json b/mobile/drift_schemas/main/drift_schema_v17.json new file mode 100644 index 0000000000000000000000000000000000000000..a26b7b57ad8b594a6d38732216cb8b3d90a30276 GIT binary patch literal 43664 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 literal 0 HcmV?d00001 diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index 5774a13c9..310e30ea6 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -22,6 +22,7 @@ sealed class BaseAsset { final int? durationInSeconds; final bool isFavorite; final String? livePhotoVideoId; + final bool isEdited; const BaseAsset({ required this.name, @@ -34,6 +35,7 @@ sealed class BaseAsset { this.durationInSeconds, this.isFavorite = false, this.livePhotoVideoId, + required this.isEdited, }); bool get isImage => type == AssetType.image; @@ -71,6 +73,7 @@ sealed class BaseAsset { height: ${height ?? ""}, durationInSeconds: ${durationInSeconds ?? ""}, isFavorite: $isFavorite, + isEdited: $isEdited, }'''; } @@ -85,7 +88,8 @@ sealed class BaseAsset { width == other.width && height == other.height && durationInSeconds == other.durationInSeconds && - isFavorite == other.isFavorite; + isFavorite == other.isFavorite && + isEdited == other.isEdited; } return false; } @@ -99,6 +103,7 @@ sealed class BaseAsset { width.hashCode ^ height.hashCode ^ durationInSeconds.hashCode ^ - isFavorite.hashCode; + isFavorite.hashCode ^ + isEdited.hashCode; } } diff --git a/mobile/lib/domain/models/asset/local_asset.model.dart b/mobile/lib/domain/models/asset/local_asset.model.dart index b7ef635f2..887dfd383 100644 --- a/mobile/lib/domain/models/asset/local_asset.model.dart +++ b/mobile/lib/domain/models/asset/local_asset.model.dart @@ -28,6 +28,7 @@ class LocalAsset extends BaseAsset { this.adjustmentTime, this.latitude, this.longitude, + required super.isEdited, }) : remoteAssetId = remoteId; @override @@ -107,6 +108,7 @@ class LocalAsset extends BaseAsset { DateTime? adjustmentTime, double? latitude, double? longitude, + bool? isEdited, }) { return LocalAsset( id: id ?? this.id, @@ -125,6 +127,7 @@ class LocalAsset extends BaseAsset { adjustmentTime: adjustmentTime ?? this.adjustmentTime, latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, + isEdited: isEdited ?? this.isEdited, ); } } diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index 4974dc911..43d49506e 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -28,6 +28,7 @@ class RemoteAsset extends BaseAsset { this.visibility = AssetVisibility.timeline, super.livePhotoVideoId, this.stackId, + required super.isEdited, }) : localAssetId = localId; @override @@ -104,6 +105,7 @@ class RemoteAsset extends BaseAsset { AssetVisibility? visibility, String? livePhotoVideoId, String? stackId, + bool? isEdited, }) { return RemoteAsset( id: id ?? this.id, @@ -122,6 +124,7 @@ class RemoteAsset extends BaseAsset { visibility: visibility ?? this.visibility, livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, stackId: stackId ?? this.stackId, + isEdited: isEdited ?? this.isEdited, ); } } diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index 8b324cf6c..e4a129d32 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -436,5 +436,6 @@ extension PlatformToLocalAsset on PlatformAsset { adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true), latitude: latitude, longitude: longitude, + isEdited: false, ); } diff --git a/mobile/lib/domain/services/search.service.dart b/mobile/lib/domain/services/search.service.dart index 6ccc5a97b..a3f935c49 100644 --- a/mobile/lib/domain/services/search.service.dart +++ b/mobile/lib/domain/services/search.service.dart @@ -77,6 +77,7 @@ extension on AssetResponseDto { thumbHash: thumbhash, localId: null, type: type.toAssetType(), + isEdited: isEdited, ); } } diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index e14321a78..d5029abac 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -247,6 +247,42 @@ class SyncStreamService { } } + Future handleWsAssetEditReadyV1Batch(List batchData) async { + if (batchData.isEmpty) return; + + _logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events'); + + final List assets = []; + + try { + for (final data in batchData) { + if (data is! Map) { + continue; + } + + final payload = data; + final assetData = payload['asset']; + + if (assetData == null) { + continue; + } + + final asset = SyncAssetV1.fromJson(assetData); + + if (asset != null) { + assets.add(asset); + } + } + + if (assets.isNotEmpty) { + await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit'); + _logger.info('Successfully processed ${assets.length} edited assets'); + } + } catch (error, stackTrace) { + _logger.severe("Error processing AssetEditReadyV1 websocket batch events", error, stackTrace); + } + } + Future _handleRemoteTrashed(Iterable checksums) async { if (checksums.isEmpty) { return Future.value(); diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index 637ae20cb..6840bae59 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -196,6 +196,16 @@ class BackgroundSyncManager { }); } + Future syncWebsocketEditBatch(List batchData) { + if (_syncWebsocketTask != null) { + return _syncWebsocketTask!.future; + } + _syncWebsocketTask = _handleWsAssetEditReadyV1Batch(batchData); + return _syncWebsocketTask!.whenComplete(() { + _syncWebsocketTask = null; + }); + } + Future syncLinkedAlbum() { if (_linkedAlbumSyncTask != null) { return _linkedAlbumSyncTask!.future; @@ -231,3 +241,8 @@ Cancelable _handleWsAssetUploadReadyV1Batch(List batchData) => ru computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetUploadReadyV1Batch(batchData), debugLabel: 'websocket-batch', ); + +Cancelable _handleWsAssetEditReadyV1Batch(List batchData) => runInIsolateGentle( + computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1Batch(batchData), + debugLabel: 'websocket-edit', +); diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.dart b/mobile/lib/infrastructure/entities/local_asset.entity.dart index 6591f922a..9d154a501 100644 --- a/mobile/lib/infrastructure/entities/local_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/local_asset.entity.dart @@ -47,5 +47,6 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData { latitude: latitude, longitude: longitude, cloudId: iCloudId, + isEdited: false, ); } diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift b/mobile/lib/infrastructure/entities/merged_asset.drift index 93d7f0c90..1db22b558 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift +++ b/mobile/lib/infrastructure/entities/merged_asset.drift @@ -25,7 +25,8 @@ SELECT NULL as i_cloud_id, NULL as latitude, NULL as longitude, - NULL as adjustmentTime + NULL as adjustmentTime, + rae.is_edited FROM remote_asset_entity rae LEFT JOIN @@ -61,7 +62,8 @@ SELECT lae.i_cloud_id, lae.latitude, lae.longitude, - lae.adjustment_time + lae.adjustment_time, + 0 as is_edited FROM local_asset_entity lae WHERE NOT EXISTS ( diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift.dart b/mobile/lib/infrastructure/entities/merged_asset.drift.dart index 169004b45de01ecf0b298de79a1bf9f005866bf3..f71aa8eb54f0c3448210e0b38e11443a47cf5c0e 100644 GIT binary patch delta 152 zcmaFj_|$2`H8y@7g`&h%z0BhH)RfGU)Rf7$MHDCR6u!0jB6}R8fR2KJf@81(Tz2z9 z5jLjHrrhCdstQ0+SFjc text().nullable()(); + BoolColumn get isEdited => boolean().withDefault(const Constant(false))(); + @override Set get primaryKey => {id}; } @@ -66,5 +68,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData { livePhotoVideoId: livePhotoVideoId, localId: localId, stackId: stackId, + isEdited: isEdited, ); } diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart index eab7f95f642917deb80b6b5d42a445c58ee9619f..2d9e8b235e73920b83807e081803353f22094630 100644 GIT binary patch delta 1225 zcmaJ1Yn-T8SakZ7$S^N{LF!C357P7O1j2pZ!v(2pY)Ix`*062#N1~ui;FBY3F=Yn;YPMU!ET4B!g5=^DlfUoYR^1$y(AqK9n9`5du zzj_Ku1-pM#0IQfxkeH=V_cjRU0+IbD)uG0Yfci5=kbSLU^M zc5)7~ivgN0hDJ}mgpHa85x)i$Zar8p$|3CWNn=$8bSL-0cuNj;cU5F$36$|*pezq& zdy4sGZSnb?7q)LDf)27l+AK$jhfsLhr-s+P(qb~g7+dvb)JA^4>ED8<2Kus-Tj2I3 z2hYc(igPix5=@gGTv&@6hK`9<9iCAK6Xh1eP7bfRE~H(()%FG9-?mT&u*N?^LDcjHc%(I6PCRChf>> zPX~N8^IHVXv-xoch*%gK(ZZKiIsP?QMZxd`S@I%=x8aI=>c5NoABHG+zMz0>3t0)L zKRkLznR($JBqW3McI4~kI8)UB_|;>Kf|GduNl2vOC!)#n>f^Br5n%Vy3CfV_q#$cU x0yy&Qtql5?#R6~r3w6rI*8g1}s9h<9BjW{N8_I!DK!&GRTmEG_R<6Al{Q+N=!My+g delta 251 zcmVuvwS!M z4wIKoBeUg3tOAoNK@GFQNseAyjG*ZSv$Cc#1+y8f83U8TMjEqVuOkEmDLV=vlM$5{ zlRT^lv(K|k2D50oas!h=7n6_$3A3WcIRmo@ z$`}K)P0gPJlh)S}vmnvU1G6C2m;$rb*H;6xSlinNvytO~1Cv4J7Lyyk9 16; + int get schemaVersion => 17; @override MigrationStrategy get migration => MigrationStrategy( @@ -201,6 +201,9 @@ class Drift extends $Drift implements IDatabaseRepository { await m.createIndex(v16.idxLocalAssetCloudId); await m.createTable(v16.remoteAssetCloudIdEntity); }, + from16To17: (m, v17) async { + await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited); + }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index 10cba0821a1a93e1a5a1e764194d239d158e64ab..fe7d1d4f0d5dc0edcdde4407f3efe5642715adef 100644 GIT binary patch delta 337 zcmZqO!gG2#Z$k@X3)2?nnaq=QX9_bKPJd{{sLo_)KHYyhlOCh__K7E%_OdV;Sxje4 zW|U$yn*316X0!g=ON`V1zho4d{ELm7(O~+=vrMtu9dW`$#N?*RXfe6aMFps7 zdtn^Y2IlDjznRWY4p8Koe6KlSvcp}z?cC;!tjv=I8bl{=(BoloP01{oEZ8c?nhIp3 zP7c(Uo-S~Yk$rN$pV;IF)qKo`28NR_nmMq5q$eA6%z-e@cLZ;r#mqd7W%^@wW^)m9 zh2rFl)Z9c(g|wplTtl;vd_(i?dK}D3-0aDT#i! lSq11v4`JpH_>3`=U_QyHq=00y6%t#AOF=dZ7To( delta 46 zcmV+}0MY-;rwxU)41k0IgaWh!nFF`(I|G*lx6(iZAPcvxOatfyx8hL)#{mW@ED9+* E3aPOWKL7v# diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 95239a469..c92ce427d 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -200,6 +200,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { libraryId: Value(asset.libraryId), width: Value(asset.width), height: Value(asset.height), + isEdited: Value(asset.isEdited), ); batch.insert( diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index e625b57c1..f57ef04b0 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -70,6 +70,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { durationInSeconds: row.durationInSeconds, livePhotoVideoId: row.livePhotoVideoId, stackId: row.stackId, + isEdited: row.isEdited, ) : LocalAsset( id: row.localId!, @@ -88,6 +89,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { latitude: row.latitude, longitude: row.longitude, adjustmentTime: row.adjustmentTime, + isEdited: row.isEdited, ), ) .get(); diff --git a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart index 579b4c1d5..9da21c72e 100644 --- a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart +++ b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart @@ -118,6 +118,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection ), _PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()), _PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId), + _PropertyItem(label: 'Is Edited', value: asset.isEdited.toString()), ]); } diff --git a/mobile/lib/presentation/pages/drift_place.page.dart b/mobile/lib/presentation/pages/drift_place.page.dart index d042f5267..10b9ca7ae 100644 --- a/mobile/lib/presentation/pages/drift_place.page.dart +++ b/mobile/lib/presentation/pages/drift_place.page.dart @@ -167,7 +167,7 @@ class _PlaceTile extends StatelessWidget { child: SizedBox( width: 80, height: 80, - child: Thumbnail.remote(remoteId: place.$2, fit: BoxFit.cover), + child: Thumbnail.remote(remoteId: place.$2, fit: BoxFit.cover, thumbhash: ""), ), ), ); diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index 101f0d31d..4f37f8834 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -14,14 +14,15 @@ import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/album_filter.utils.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -666,6 +667,8 @@ class _GridAlbumCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? ""); + return GestureDetector( onTap: () => onAlbumSelected(album), child: Card( @@ -684,12 +687,22 @@ class _GridAlbumCard extends ConsumerWidget { borderRadius: const BorderRadius.vertical(top: Radius.circular(15)), child: SizedBox( width: double.infinity, - child: album.thumbnailAssetId != null - ? Thumbnail.remote(remoteId: album.thumbnailAssetId!) - : Container( - color: context.colorScheme.surfaceContainerHighest, - child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey), - ), + child: FutureBuilder( + future: albumThumbnailAsset, + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data != null) { + return Thumbnail.remote( + remoteId: album.thumbnailAssetId!, + thumbhash: snapshot.data!.thumbHash ?? "", + ); + } + + return Container( + color: context.colorScheme.surfaceContainerHighest, + child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey), + ); + }, + ), ), ), ), diff --git a/mobile/lib/presentation/widgets/album/album_tile.dart b/mobile/lib/presentation/widgets/album/album_tile.dart index 561b018ef..1aeadf61b 100644 --- a/mobile/lib/presentation/widgets/album/album_tile.dart +++ b/mobile/lib/presentation/widgets/album/album_tile.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -class AlbumTile extends StatelessWidget { +class AlbumTile extends ConsumerWidget { const AlbumTile({super.key, required this.album, required this.isOwner, this.onAlbumSelected}); final RemoteAlbum album; @@ -14,7 +16,9 @@ class AlbumTile extends StatelessWidget { final Function(RemoteAlbum)? onAlbumSelected; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? ""); + return LargeLeadingTile( title: Text( album.name, @@ -29,23 +33,35 @@ class AlbumTile extends StatelessWidget { ), onTap: () => onAlbumSelected?.call(album), leadingPadding: const EdgeInsets.only(right: 16), - leading: album.thumbnailAssetId != null - ? ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(15)), - child: SizedBox(width: 80, height: 80, child: Thumbnail.remote(remoteId: album.thumbnailAssetId!)), - ) - : SizedBox( - width: 80, - height: 80, - child: Container( - decoration: BoxDecoration( - color: context.colorScheme.surfaceContainer, - borderRadius: const BorderRadius.all(Radius.circular(16)), - border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1), - ), - child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey), - ), - ), + leading: FutureBuilder( + future: albumThumbnailAsset, + builder: (context, snapshot) { + return snapshot.hasData && snapshot.data != null + ? ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(15)), + child: SizedBox( + width: 80, + height: 80, + child: Thumbnail.remote( + remoteId: album.thumbnailAssetId!, + thumbhash: snapshot.data!.thumbHash ?? "", + ), + ), + ) + : SizedBox( + width: 80, + height: 80, + child: Container( + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainer, + borderRadius: const BorderRadius.all(Radius.circular(16)), + border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1), + ), + child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey), + ), + ); + }, + ), ); } } diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index e77803c20..ad7d53af1 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -112,14 +112,17 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080 provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type); } else { final String assetId; + final String thumbhash; if (asset is LocalAsset && asset.hasRemote) { assetId = asset.remoteId!; + thumbhash = ""; } else if (asset is RemoteAsset) { assetId = asset.id; + thumbhash = asset.thumbHash ?? ""; } else { throw ArgumentError("Unsupported asset type: ${asset.runtimeType}"); } - provider = RemoteFullImageProvider(assetId: assetId); + provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash); } return provider; @@ -132,8 +135,9 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai } final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId; - return assetId != null ? RemoteThumbProvider(assetId: assetId) : null; + final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : ""; + return assetId != null ? RemoteThumbProvider(assetId: assetId, thumbhash: thumbhash) : null; } bool _shouldUseLocalAsset(BaseAsset asset) => - asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)); + asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)) && !asset.isEdited; diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index d9a736861..b550e53c2 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -16,8 +16,9 @@ class RemoteThumbProvider extends CancellableImageProvider with CancellableImageProviderMixin { static final cacheManager = RemoteThumbnailCacheManager(); final String assetId; + final String thumbhash; - RemoteThumbProvider({required this.assetId}); + RemoteThumbProvider({required this.assetId, required this.thumbhash}); @override Future obtainKey(ImageConfiguration configuration) { @@ -38,7 +39,7 @@ class RemoteThumbProvider extends CancellableImageProvider Stream _codec(RemoteThumbProvider key, ImageDecoderCallback decode) { final request = this.request = RemoteImageRequest( - uri: getThumbnailUrlForRemoteId(key.assetId), + uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash), headers: ApiService.getRequestHeaders(), cacheManager: cacheManager, ); @@ -49,22 +50,23 @@ class RemoteThumbProvider extends CancellableImageProvider bool operator ==(Object other) { if (identical(this, other)) return true; if (other is RemoteThumbProvider) { - return assetId == other.assetId; + return assetId == other.assetId && thumbhash == other.thumbhash; } return false; } @override - int get hashCode => assetId.hashCode; + int get hashCode => assetId.hashCode ^ thumbhash.hashCode; } class RemoteFullImageProvider extends CancellableImageProvider with CancellableImageProviderMixin { static final cacheManager = RemoteThumbnailCacheManager(); final String assetId; + final String thumbhash; - RemoteFullImageProvider({required this.assetId}); + RemoteFullImageProvider({required this.assetId, required this.thumbhash}); @override Future obtainKey(ImageConfiguration configuration) { @@ -75,7 +77,7 @@ class RemoteFullImageProvider extends CancellableImageProvider [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Asset Id', key.assetId), @@ -94,7 +96,7 @@ class RemoteFullImageProvider extends CancellableImageProvider assetId.hashCode; + int get hashCode => assetId.hashCode ^ thumbhash.hashCode; } diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index 92b1bb254..f878c214a 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -21,9 +21,14 @@ class Thumbnail extends StatefulWidget { const Thumbnail({this.imageProvider, this.fit = BoxFit.cover, this.thumbhashProvider, super.key}); - Thumbnail.remote({required String remoteId, this.fit = BoxFit.cover, Size size = kThumbnailResolution, super.key}) - : imageProvider = RemoteThumbProvider(assetId: remoteId), - thumbhashProvider = null; + Thumbnail.remote({ + required String remoteId, + required String thumbhash, + this.fit = BoxFit.cover, + Size size = kThumbnailResolution, + super.key, + }) : imageProvider = RemoteThumbProvider(assetId: remoteId, thumbhash: thumbhash), + thumbhashProvider = null; Thumbnail.fromAsset({ required BaseAsset? asset, diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart index e85a6c05f..62889b10c 100644 --- a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart @@ -60,7 +60,11 @@ class DriftMemoryCard extends ConsumerWidget { child: SizedBox( width: 205, height: 200, - child: Thumbnail.remote(remoteId: memory.assets[0].id, fit: BoxFit.cover), + child: Thumbnail.remote( + remoteId: memory.assets[0].id, + thumbhash: memory.assets[0].thumbHash ?? "", + fit: BoxFit.cover, + ), ), ), Positioned( diff --git a/mobile/lib/providers/cast.provider.dart b/mobile/lib/providers/cast.provider.dart index 75a2a35fb..1cd5ded48 100644 --- a/mobile/lib/providers/cast.provider.dart +++ b/mobile/lib/providers/cast.provider.dart @@ -69,6 +69,7 @@ class CastNotifier extends StateNotifier { : AssetType.other, createdAt: asset.fileCreatedAt, updatedAt: asset.updatedAt, + isEdited: false, ); _gCastService.loadMedia(remoteAsset, reload); diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 6a1083bfc..f9473ce44 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -144,6 +144,7 @@ class WebsocketNotifier extends StateNotifier { socket.on('on_asset_hidden', _handleOnAssetHidden); } else { socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady); + socket.on('AssetEditReadyV1', _handleSyncAssetEditReady); } socket.on('on_config_update', _handleOnConfigUpdate); @@ -192,10 +193,12 @@ class WebsocketNotifier extends StateNotifier { void stopListeningToBetaEvents() { state.socket?.off('AssetUploadReadyV1'); + state.socket?.off('AssetEditReadyV1'); } void startListeningToBetaEvents() { state.socket?.on('AssetUploadReadyV1', _handleSyncAssetUploadReady); + state.socket?.on('AssetEditReadyV1', _handleSyncAssetEditReady); } void listenUploadEvent() { @@ -315,6 +318,10 @@ class WebsocketNotifier extends StateNotifier { _batchDebouncer.run(_processBatchedAssetUploadReady); } + void _handleSyncAssetEditReady(dynamic data) { + unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditBatch([data])); + } + void _processBatchedAssetUploadReady() { if (_batchedAssetUploadReady.isEmpty) { return; diff --git a/mobile/lib/repositories/file_media.repository.dart b/mobile/lib/repositories/file_media.repository.dart index 654be78fb..3a3e50f37 100644 --- a/mobile/lib/repositories/file_media.repository.dart +++ b/mobile/lib/repositories/file_media.repository.dart @@ -25,6 +25,7 @@ class FileMediaRepository { type: AssetType.image, createdAt: entity.createDateTime, updatedAt: entity.modifiedDateTime, + isEdited: false, ); } diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index 4059f5baa..079f0e51f 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -50,8 +50,10 @@ String getThumbnailUrlForRemoteId( final String id, { AssetMediaSize type = AssetMediaSize.thumbnail, bool edited = true, + String? thumbhash, }) { - return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}&edited=$edited'; + final url = '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}&edited=$edited'; + return thumbhash != null ? '$url&c=${Uri.encodeComponent(thumbhash)}' : url; } String getPlaybackUrlForRemoteId(final String id) { diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 02ff26510..090889ff3 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -49,7 +49,7 @@ dynamic upgradeDto(dynamic value, String targetType) { } case 'SyncAssetV1': if (value is Map) { - addDefault(value, 'editCount', 0); + addDefault(value, 'isEdited', false); } case 'ServerFeaturesDto': if (value is Map) { diff --git a/mobile/openapi/lib/model/sync_asset_v1.dart b/mobile/openapi/lib/model/sync_asset_v1.dart index 6e9fa95df0fe8584a7fecf06f79179a8403a7a73..1d0e735396fd02fee0d3d1083aa10268d2d7c064 100644 GIT binary patch delta 187 zcmeBiZF1dkgK_d##!@!dl+2RUl*tbnMK)(J)iF-~!5qg57TfH}a+7hg8`}gyh!O=` zTZPPGh}2QG9Hz-Woco|EzjC@WO%CFT;D@NRRe`Bn$}@v$@;*M!&4v8m7{wv7>ahy8 r3MCnt#dM0+Z&F9_!V diff --git a/mobile/test/drift/main/generated/schema_v17.dart b/mobile/test/drift/main/generated/schema_v17.dart new file mode 100644 index 0000000000000000000000000000000000000000..042c069ecddbf4da1b1484031112947631a59833 GIT binary patch literal 271709 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 literal 0 HcmV?d00001 diff --git a/mobile/test/fixtures/asset.stub.dart b/mobile/test/fixtures/asset.stub.dart index 8d9201199..f3d6ab42a 100644 --- a/mobile/test/fixtures/asset.stub.dart +++ b/mobile/test/fixtures/asset.stub.dart @@ -64,6 +64,7 @@ abstract final class LocalAssetStub { type: AssetType.image, createdAt: DateTime(2025), updatedAt: DateTime(2025, 2), + isEdited: false, ); static final image2 = LocalAsset( @@ -72,5 +73,6 @@ abstract final class LocalAssetStub { type: AssetType.image, createdAt: DateTime(2000), updatedAt: DateTime(20021), + isEdited: false, ); } diff --git a/mobile/test/fixtures/sync_stream.stub.dart b/mobile/test/fixtures/sync_stream.stub.dart index 9ab6a5685..c2254c0a0 100644 --- a/mobile/test/fixtures/sync_stream.stub.dart +++ b/mobile/test/fixtures/sync_stream.stub.dart @@ -128,7 +128,7 @@ abstract final class SyncStreamStub { visibility: AssetVisibility.timeline, width: null, height: null, - editCount: 0, + isEdited: false, ), ack: ack, ); diff --git a/mobile/test/services/background_upload.service_test.dart b/mobile/test/services/background_upload.service_test.dart index d0374c398..41dc46823 100644 --- a/mobile/test/services/background_upload.service_test.dart +++ b/mobile/test/services/background_upload.service_test.dart @@ -194,6 +194,7 @@ void main() { latitude: 37.7749, longitude: -122.4194, adjustmentTime: DateTime(2026, 1, 2), + isEdited: false, ); final mockEntity = MockAssetEntity(); @@ -242,6 +243,7 @@ void main() { cloudId: 'cloud-id-123', latitude: 37.7749, longitude: -122.4194, + isEdited: false, ); final mockEntity = MockAssetEntity(); @@ -279,6 +281,7 @@ void main() { createdAt: DateTime(2025, 1, 1), updatedAt: DateTime(2025, 1, 2), cloudId: null, // No cloudId + isEdited: false, ); final mockEntity = MockAssetEntity(); @@ -320,6 +323,7 @@ void main() { cloudId: 'cloud-id-livephoto', latitude: 37.7749, longitude: -122.4194, + isEdited: false, ); final mockEntity = MockAssetEntity(); diff --git a/mobile/test/test_utils.dart b/mobile/test/test_utils.dart index 498607e3d..9d94e7105 100644 --- a/mobile/test/test_utils.dart +++ b/mobile/test/test_utils.dart @@ -131,6 +131,7 @@ abstract final class TestUtils { isFavorite: false, width: width, height: height, + isEdited: false, ); } @@ -154,6 +155,7 @@ abstract final class TestUtils { width: width, height: height, orientation: orientation, + isEdited: false, ); } } diff --git a/mobile/test/test_utils/medium_factory.dart b/mobile/test/test_utils/medium_factory.dart index 19ad7166c..b6f39ac3b 100644 --- a/mobile/test/test_utils/medium_factory.dart +++ b/mobile/test/test_utils/medium_factory.dart @@ -27,6 +27,7 @@ class MediumFactory { type: type ?? AssetType.image, createdAt: createdAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)), updatedAt: updatedAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)), + isEdited: false, ); } diff --git a/mobile/test/utils/action_button_utils_test.dart b/mobile/test/utils/action_button_utils_test.dart index d93d59d3c..4152155d2 100644 --- a/mobile/test/utils/action_button_utils_test.dart +++ b/mobile/test/utils/action_button_utils_test.dart @@ -23,6 +23,7 @@ LocalAsset createLocalAsset({ createdAt: createdAt ?? DateTime.now(), updatedAt: updatedAt ?? DateTime.now(), isFavorite: isFavorite, + isEdited: false, ); } @@ -45,6 +46,7 @@ RemoteAsset createRemoteAsset({ createdAt: createdAt ?? DateTime.now(), updatedAt: updatedAt ?? DateTime.now(), isFavorite: isFavorite, + isEdited: false, ); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1535b509c..491d67efe 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -21291,9 +21291,6 @@ "nullable": true, "type": "string" }, - "editCount": { - "type": "integer" - }, "fileCreatedAt": { "format": "date-time", "nullable": true, @@ -21311,6 +21308,9 @@ "id": { "type": "string" }, + "isEdited": { + "type": "boolean" + }, "isFavorite": { "type": "boolean" }, @@ -21364,11 +21364,11 @@ "checksum", "deletedAt", "duration", - "editCount", "fileCreatedAt", "fileModifiedAt", "height", "id", + "isEdited", "isFavorite", "libraryId", "livePhotoVideoId", diff --git a/server/src/database.ts b/server/src/database.ts index 61a08df14..7a64eb06b 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -395,7 +395,7 @@ export const columns = { 'asset.libraryId', 'asset.width', 'asset.height', - 'asset.editCount', + 'asset.isEdited', ], syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'], syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'], diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 5d66c0c08..92ee3c587 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -139,7 +139,7 @@ export type MapAsset = { type: AssetType; width: number | null; height: number | null; - editCount: number; + isEdited: boolean; }; export class AssetStackResponseDto { @@ -248,6 +248,6 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset resized: true, width: entity.width, height: entity.height, - isEdited: entity.editCount > 0, + isEdited: entity.isEdited, }; } diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index f775a2211..0d1ab0e74 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -121,8 +121,8 @@ export class SyncAssetV1 { width!: number | null; @ApiProperty({ type: 'integer' }) height!: number | null; - @ApiProperty({ type: 'integer' }) - editCount!: number; + @ApiProperty({ type: 'boolean' }) + isEdited!: boolean; } @ExtraModel() diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index c57530050..f817ad57b 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -71,7 +71,7 @@ select "asset"."libraryId", "asset"."width", "asset"."height", - "asset"."editCount", + "asset"."isEdited", "album_asset"."updateId" from "album_asset" as "album_asset" @@ -104,7 +104,7 @@ select "asset"."libraryId", "asset"."width", "asset"."height", - "asset"."editCount", + "asset"."isEdited", "asset"."updateId" from "asset" as "asset" @@ -143,7 +143,7 @@ select "asset"."libraryId", "asset"."width", "asset"."height", - "asset"."editCount" + "asset"."isEdited" from "album_asset" as "album_asset" inner join "asset" on "asset"."id" = "album_asset"."assetId" @@ -459,7 +459,7 @@ select "asset"."libraryId", "asset"."width", "asset"."height", - "asset"."editCount", + "asset"."isEdited", "asset"."updateId" from "asset" as "asset" @@ -755,7 +755,7 @@ select "asset"."libraryId", "asset"."width", "asset"."height", - "asset"."editCount", + "asset"."isEdited", "asset"."updateId" from "asset" as "asset" @@ -807,7 +807,7 @@ select "asset"."libraryId", "asset"."width", "asset"."height", - "asset"."editCount", + "asset"."isEdited", "asset"."updateId" from "asset" as "asset" diff --git a/server/src/repositories/websocket.repository.ts b/server/src/repositories/websocket.repository.ts index c2da06786..bfed55689 100644 --- a/server/src/repositories/websocket.repository.ts +++ b/server/src/repositories/websocket.repository.ts @@ -37,7 +37,7 @@ export interface ClientEventMap { AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }]; AppRestartV1: [AppRestartEvent]; - AssetEditReadyV1: [{ assetId: string }]; + AssetEditReadyV1: [{ asset: SyncAssetV1 }]; } export type AuthFn = (client: Socket) => Promise; diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 8988bf38d..d7dabfef4 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -263,8 +263,9 @@ export const asset_edit_insert = registerFunction({ body: ` BEGIN UPDATE asset - SET "editCount" = "editCount" + 1 - WHERE "id" = NEW."assetId"; + SET "isEdited" = true + FROM inserted_edit + WHERE asset.id = inserted_edit."assetId" AND NOT asset."isEdited"; RETURN NULL; END `, @@ -277,8 +278,10 @@ export const asset_edit_delete = registerFunction({ body: ` BEGIN UPDATE asset - SET "editCount" = "editCount" - 1 - WHERE "id" = OLD."assetId"; + SET "isEdited" = false + FROM deleted_edit + WHERE asset.id = deleted_edit."assetId" AND asset."isEdited" + AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id); RETURN NULL; END `, diff --git a/server/src/schema/migrations/1768757482271-SwitchToIsEdited.ts b/server/src/schema/migrations/1768757482271-SwitchToIsEdited.ts new file mode 100644 index 000000000..0660b7303 --- /dev/null +++ b/server/src/schema/migrations/1768757482271-SwitchToIsEdited.ts @@ -0,0 +1,89 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION asset_edit_insert() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + UPDATE asset + SET "isEdited" = true + FROM inserted_edit + WHERE asset.id = inserted_edit."assetId" AND NOT asset."isEdited"; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE FUNCTION asset_edit_delete() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + UPDATE asset + SET "isEdited" = false + FROM deleted_edit + WHERE asset.id = deleted_edit."assetId" AND asset."isEdited" + AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id); + RETURN NULL; + END + $$;`.execute(db); + await sql`ALTER TABLE "asset" ADD "isEdited" boolean NOT NULL DEFAULT false;`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_edit_delete" + AFTER DELETE ON "asset_edit" + REFERENCING OLD TABLE AS "deleted_edit" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION asset_edit_delete();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_edit_insert" + AFTER INSERT ON "asset_edit" + REFERENCING NEW TABLE AS "inserted_edit" + FOR EACH STATEMENT + EXECUTE FUNCTION asset_edit_insert();`.execute(db); + await sql`ALTER TABLE "asset" DROP COLUMN "editCount";`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"asset_edit_insert","sql":"CREATE OR REPLACE FUNCTION asset_edit_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = true\\n FROM inserted_edit\\n WHERE asset.id = inserted_edit.\\"assetId\\" AND NOT asset.\\"isEdited\\";\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_asset_edit_insert';`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"asset_edit_delete","sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\" \\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"trigger","name":"asset_edit_delete","sql":"CREATE OR REPLACE TRIGGER \\"asset_edit_delete\\"\\n AFTER DELETE ON \\"asset_edit\\"\\n REFERENCING OLD TABLE AS \\"deleted_edit\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_edit_delete();"}'::jsonb WHERE "name" = 'trigger_asset_edit_delete';`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"trigger","name":"asset_edit_insert","sql":"CREATE OR REPLACE TRIGGER \\"asset_edit_insert\\"\\n AFTER INSERT ON \\"asset_edit\\"\\n REFERENCING NEW TABLE AS \\"inserted_edit\\"\\n FOR EACH STATEMENT\\n EXECUTE FUNCTION asset_edit_insert();"}'::jsonb WHERE "name" = 'trigger_asset_edit_insert';`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION public.asset_edit_insert() + RETURNS trigger + LANGUAGE plpgsql +AS $function$ + BEGIN + UPDATE asset + SET "editCount" = "editCount" + 1 + WHERE "id" = NEW."assetId"; + RETURN NULL; + END + $function$ +`.execute(db); + await sql`CREATE OR REPLACE FUNCTION public.asset_edit_delete() + RETURNS trigger + LANGUAGE plpgsql +AS $function$ + BEGIN + UPDATE asset + SET "editCount" = "editCount" - 1 + WHERE "id" = OLD."assetId"; + RETURN NULL; + END + $function$ +`.execute(db); + await sql`ALTER TABLE "asset" ADD "editCount" integer NOT NULL DEFAULT 0;`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_edit_delete" + AFTER DELETE ON "asset_edit" + REFERENCING OLD TABLE AS "old" + FOR EACH ROW + WHEN ((pg_trigger_depth() = 0)) + EXECUTE FUNCTION asset_edit_delete();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_edit_insert" + AFTER INSERT ON "asset_edit" + FOR EACH ROW + EXECUTE FUNCTION asset_edit_insert();`.execute(db); + await sql`ALTER TABLE "asset" DROP COLUMN "isEdited";`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION asset_edit_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"editCount\\" = \\"editCount\\" + 1\\n WHERE \\"id\\" = NEW.\\"assetId\\";\\n RETURN NULL;\\n END\\n $$;","name":"asset_edit_insert","type":"function"}'::jsonb WHERE "name" = 'function_asset_edit_insert';`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"editCount\\" = \\"editCount\\" - 1\\n WHERE \\"id\\" = OLD.\\"assetId\\";\\n RETURN NULL;\\n END\\n $$;","name":"asset_edit_delete","type":"function"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE TRIGGER \\"asset_edit_delete\\"\\n AFTER DELETE ON \\"asset_edit\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH ROW\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_edit_delete();","name":"asset_edit_delete","type":"trigger"}'::jsonb WHERE "name" = 'trigger_asset_edit_delete';`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE TRIGGER \\"asset_edit_insert\\"\\n AFTER INSERT ON \\"asset_edit\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION asset_edit_insert();","name":"asset_edit_insert","type":"trigger"}'::jsonb WHERE "name" = 'trigger_asset_edit_insert';`.execute(db); +} diff --git a/server/src/schema/tables/asset-edit.table.ts b/server/src/schema/tables/asset-edit.table.ts index 4c4bf45cf..ad0b443b6 100644 --- a/server/src/schema/tables/asset-edit.table.ts +++ b/server/src/schema/tables/asset-edit.table.ts @@ -12,11 +12,11 @@ import { } from 'src/sql-tools'; @Table('asset_edit') -@AfterInsertTrigger({ scope: 'row', function: asset_edit_insert }) +@AfterInsertTrigger({ scope: 'statement', function: asset_edit_insert, referencingNewTableAs: 'inserted_edit' }) @AfterDeleteTrigger({ - scope: 'row', + scope: 'statement', function: asset_edit_delete, - referencingOldTableAs: 'old', + referencingOldTableAs: 'deleted_edit', when: 'pg_trigger_depth() = 0', }) export class AssetEditTable { diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index fb21b67af..0b3da710a 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -144,6 +144,6 @@ export class AssetTable { @Column({ type: 'integer', nullable: true }) height!: number | null; - @Column({ type: 'integer', default: 0 }) - editCount!: Generated; + @Column({ type: 'boolean', default: false }) + isEdited!: Generated; } diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 5cca0a8f8..2a47745a6 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -100,7 +100,29 @@ export class JobService extends BaseService { const asset = await this.assetRepository.getById(item.data.id); if (asset) { - this.websocketRepository.clientSend('AssetEditReadyV1', asset.ownerId, { assetId: item.data.id }); + this.websocketRepository.clientSend('AssetEditReadyV1', asset.ownerId, { + asset: { + id: asset.id, + ownerId: asset.ownerId, + originalFileName: asset.originalFileName, + thumbhash: asset.thumbhash ? hexOrBufferToBase64(asset.thumbhash) : null, + checksum: hexOrBufferToBase64(asset.checksum), + fileCreatedAt: asset.fileCreatedAt, + fileModifiedAt: asset.fileModifiedAt, + localDateTime: asset.localDateTime, + duration: asset.duration, + type: asset.type, + deletedAt: asset.deletedAt, + isFavorite: asset.isFavorite, + visibility: asset.visibility, + livePhotoVideoId: asset.livePhotoVideoId, + stackId: asset.stackId, + libraryId: asset.libraryId, + width: asset.width, + height: asset.height, + isEdited: asset.isEdited, + }, + }); } break; @@ -153,7 +175,7 @@ export class JobService extends BaseService { libraryId: asset.libraryId, width: asset.width, height: asset.height, - editCount: asset.editCount, + isEdited: asset.isEdited, }, exif: { assetId: exif.assetId, diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 21ffbda59..0a6108a65 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -86,7 +86,7 @@ export const assetStub = { make: 'FUJIFILM', model: 'X-T50', lensModel: 'XF27mm F2.8 R WR', - editCount: 0, + isEdited: false, ...asset, }), noResizePath: Object.freeze({ @@ -126,7 +126,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), noWebpPath: Object.freeze({ @@ -168,7 +168,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), noThumbhash: Object.freeze({ @@ -207,7 +207,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), primaryImage: Object.freeze({ @@ -256,7 +256,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), image: Object.freeze({ @@ -303,7 +303,7 @@ export const assetStub = { width: null, visibility: AssetVisibility.Timeline, edits: [], - editCount: 0, + isEdited: false, }), trashed: Object.freeze({ @@ -347,7 +347,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), trashedOffline: Object.freeze({ @@ -391,7 +391,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), archived: Object.freeze({ id: 'asset-id', @@ -434,7 +434,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), external: Object.freeze({ @@ -477,7 +477,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), image1: Object.freeze({ @@ -520,7 +520,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), imageFrom2015: Object.freeze({ @@ -562,7 +562,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), video: Object.freeze({ @@ -606,7 +606,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), livePhotoMotionAsset: Object.freeze({ @@ -627,7 +627,7 @@ export const assetStub = { width: null, height: null, edits: [] as AssetEditActionItem[], - editCount: 0, + isEdited: false, } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif; edits: AssetEditActionItem[] }), livePhotoStillAsset: Object.freeze({ @@ -649,7 +649,7 @@ export const assetStub = { width: null, height: null, edits: [] as AssetEditActionItem[], - editCount: 0, + isEdited: false, } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }), livePhotoWithOriginalFileName: Object.freeze({ @@ -673,7 +673,7 @@ export const assetStub = { width: null, height: null, edits: [] as AssetEditActionItem[], - editCount: 0, + isEdited: false, } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }), withLocation: Object.freeze({ @@ -721,7 +721,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), sidecar: Object.freeze({ @@ -760,7 +760,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), sidecarWithoutExt: Object.freeze({ @@ -796,7 +796,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), hasEncodedVideo: Object.freeze({ @@ -839,7 +839,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), hasFileExtension: Object.freeze({ @@ -879,7 +879,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), imageDng: Object.freeze({ @@ -923,7 +923,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), imageHif: Object.freeze({ @@ -967,7 +967,7 @@ export const assetStub = { width: null, height: null, edits: [], - editCount: 0, + isEdited: false, }), panoramaTif: Object.freeze({ @@ -1068,7 +1068,7 @@ export const assetStub = { }, }, ] as AssetEditActionItem[], - editCount: 1, + isEdited: true, }), withoutEdits: Object.freeze({ @@ -1116,6 +1116,6 @@ export const assetStub = { width: 2160, visibility: AssetVisibility.Timeline, edits: [], - editCount: 0, + isEdited: false, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index a080b505d..0f1605743 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -159,7 +159,7 @@ export const sharedLinkStub = { visibility: AssetVisibility.Timeline, width: 500, height: 500, - editCount: 0, + isEdited: false, }, ], albumId: null, diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index acca3092c..ac3ffed79 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -537,7 +537,7 @@ const assetInsert = (asset: Partial> = {}) => { fileModifiedAt: now, localDateTime: now, visibility: AssetVisibility.Timeline, - editCount: 0, + isEdited: false, }; return { diff --git a/server/test/medium/specs/repositories/asset-edit.repository.spec.ts b/server/test/medium/specs/repositories/asset-edit.repository.spec.ts index da025299f..512c6c73f 100644 --- a/server/test/medium/specs/repositories/asset-edit.repository.spec.ts +++ b/server/test/medium/specs/repositories/asset-edit.repository.spec.ts @@ -24,32 +24,32 @@ beforeAll(async () => { describe(AssetEditRepository.name, () => { describe('replaceAll', () => { - it('should increment editCount on insert', async () => { + it('should set isEdited on insert', async () => { const { ctx, sut } = setup(); const { user } = await ctx.newUser(); const { asset } = await ctx.newAsset({ ownerId: user.id }); await expect( - ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), - ).resolves.toEqual({ editCount: 0 }); + ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(), + ).resolves.toEqual({ isEdited: false }); await sut.replaceAll(asset.id, [ { action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } }, ]); await expect( - ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), - ).resolves.toEqual({ editCount: 1 }); + ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(), + ).resolves.toEqual({ isEdited: true }); }); - it('should increment editCount when inserting multiple edits', async () => { + it('should set isEdited when inserting multiple edits', async () => { const { ctx, sut } = setup(); const { user } = await ctx.newUser(); const { asset } = await ctx.newAsset({ ownerId: user.id }); await expect( - ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), - ).resolves.toEqual({ editCount: 0 }); + ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(), + ).resolves.toEqual({ isEdited: false }); await sut.replaceAll(asset.id, [ { action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } }, @@ -58,18 +58,18 @@ describe(AssetEditRepository.name, () => { ]); await expect( - ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), - ).resolves.toEqual({ editCount: 3 }); + ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(), + ).resolves.toEqual({ isEdited: true }); }); - it('should decrement editCount', async () => { + it('should keep isEdited when removing some edits', async () => { const { ctx, sut } = setup(); const { user } = await ctx.newUser(); const { asset } = await ctx.newAsset({ ownerId: user.id }); await expect( - ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), - ).resolves.toEqual({ editCount: 0 }); + ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(), + ).resolves.toEqual({ isEdited: false }); await sut.replaceAll(asset.id, [ { action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } }, @@ -77,23 +77,27 @@ describe(AssetEditRepository.name, () => { { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, ]); + await expect( + ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(), + ).resolves.toEqual({ isEdited: true }); + await sut.replaceAll(asset.id, [ { action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } }, ]); await expect( - ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), - ).resolves.toEqual({ editCount: 1 }); + ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(), + ).resolves.toEqual({ isEdited: true }); }); - it('should set editCount to 0 if all edits are deleted', async () => { + it('should set isEdited to false if all edits are deleted', async () => { const { ctx, sut } = setup(); const { user } = await ctx.newUser(); const { asset } = await ctx.newAsset({ ownerId: user.id }); await expect( - ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), - ).resolves.toEqual({ editCount: 0 }); + ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(), + ).resolves.toEqual({ isEdited: false }); await sut.replaceAll(asset.id, [ { action: AssetEditAction.Crop, parameters: { height: 1, width: 1, x: 1, y: 1 } }, @@ -104,8 +108,8 @@ describe(AssetEditRepository.name, () => { await sut.replaceAll(asset.id, []); await expect( - ctx.database.selectFrom('asset').select('editCount').where('id', '=', asset.id).executeTakeFirstOrThrow(), - ).resolves.toEqual({ editCount: 0 }); + ctx.database.selectFrom('asset').select('isEdited').where('id', '=', asset.id).executeTakeFirstOrThrow(), + ).resolves.toEqual({ isEdited: false }); }); }); }); diff --git a/server/test/medium/specs/sync/sync-album-asset.spec.ts b/server/test/medium/specs/sync/sync-album-asset.spec.ts index b271956dc..123b6f948 100644 --- a/server/test/medium/specs/sync/sync-album-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-album-asset.spec.ts @@ -83,7 +83,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { libraryId: asset.libraryId, width: asset.width, height: asset.height, - editCount: asset.editCount, + isEdited: asset.isEdited, }, type: SyncEntityType.AlbumAssetCreateV1, }, diff --git a/server/test/medium/specs/sync/sync-asset.spec.ts b/server/test/medium/specs/sync/sync-asset.spec.ts index 839923ce1..a1a898d9b 100644 --- a/server/test/medium/specs/sync/sync-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-asset.spec.ts @@ -64,7 +64,7 @@ describe(SyncEntityType.AssetV1, () => { libraryId: asset.libraryId, width: asset.width, height: asset.height, - editCount: asset.editCount, + isEdited: asset.isEdited, }, type: 'AssetV1', }, diff --git a/server/test/medium/specs/sync/sync-partner-asset.spec.ts b/server/test/medium/specs/sync/sync-partner-asset.spec.ts index af3816054..345d4a1e2 100644 --- a/server/test/medium/specs/sync/sync-partner-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-partner-asset.spec.ts @@ -63,7 +63,7 @@ describe(SyncRequestType.PartnerAssetsV1, () => { type: asset.type, visibility: asset.visibility, duration: asset.duration, - editCount: asset.editCount, + isEdited: asset.isEdited, stackId: null, livePhotoVideoId: null, libraryId: asset.libraryId, diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 9d998f5ae..83d3a4777 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -253,7 +253,7 @@ const assetFactory = (asset: Partial = {}) => ({ visibility: AssetVisibility.Timeline, width: null, height: null, - editCount: 0, + isEdited: false, ...asset, }); diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 8d30439dc..b0396f939 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -7,6 +7,7 @@ import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte'; import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte'; import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte'; + import EditAction from '$lib/components/asset-viewer/actions/edit-action.svelte'; import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte'; import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte'; import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte'; @@ -19,6 +20,7 @@ import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; + import { ProjectionType } from '$lib/constants'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { Route } from '$lib/route'; import { getGlobalActions } from '$lib/services/app.service'; @@ -71,7 +73,7 @@ onUndoDelete?: OnUndoDelete; onRunJob: (name: AssetJobName) => void; onPlaySlideshow: () => void; - // onEdit: () => void; + onEdit: () => void; onClose?: () => void; playOriginalVideo: boolean; setPlayOriginalVideo: (value: boolean) => void; @@ -91,7 +93,7 @@ onRunJob, onPlaySlideshow, onClose, - // onEdit, + onEdit, playOriginalVideo = false, setPlayOriginalVideo, }: Props = $props(); @@ -126,17 +128,17 @@ const sharedLink = getSharedLink(); // TODO: Enable when edits are ready for release - // let showEditorButton = $derived( - // isOwner && - // asset.type === AssetTypeEnum.Image && - // !( - // asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || - // (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) - // ) && - // !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) && - // !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.svg')) && - // !asset.livePhotoVideoId, - // ); + let showEditorButton = $derived( + isOwner && + asset.type === AssetTypeEnum.Image && + !( + asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || + (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) + ) && + !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) && + !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.svg')) && + !asset.livePhotoVideoId, + ); {/if} - + {/if} {#if isOwner} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 58811ff97..334c7c103 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -254,12 +254,12 @@ }); }; - // const showEditor = () => { - // if (assetViewerManager.isShowActivityPanel) { - // assetViewerManager.isShowActivityPanel = false; - // } - // isShowEditor = !isShowEditor; - // }; + const showEditor = () => { + if (assetViewerManager.isShowActivityPanel) { + assetViewerManager.isShowActivityPanel = false; + } + isShowEditor = !isShowEditor; + }; const handleRunJob = async (name: AssetJobName) => { try { @@ -466,6 +466,7 @@ preAction={handlePreAction} onAction={handleAction} {onUndoDelete} + onEdit={showEditor} onRunJob={handleRunJob} onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)} onClose={onClose ? () => onClose(asset) : undefined} diff --git a/web/src/lib/managers/edit/edit-manager.svelte.ts b/web/src/lib/managers/edit/edit-manager.svelte.ts index b8ebea1cf..ef326f266 100644 --- a/web/src/lib/managers/edit/edit-manager.svelte.ts +++ b/web/src/lib/managers/edit/edit-manager.svelte.ts @@ -115,7 +115,7 @@ export class EditManager { // Setup the websocket listener before sending the edit request const editCompleted = waitForWebsocketEvent( 'AssetEditReadyV1', - (event) => event.assetId === this.currentAsset!.id, + (event) => event.asset.id === this.currentAsset!.id, 10_000, ); diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 124634131..75fa57bb2 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -31,7 +31,7 @@ export interface Events { on_notification: (notification: NotificationDto) => void; AppRestartV1: (event: AppRestartEvent) => void; - AssetEditReadyV1: (data: { assetId: string }) => void; + AssetEditReadyV1: (data: { asset: { id: string } }) => void; } const websocket: Socket = io({