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 000000000..a26b7b57a Binary files /dev/null and b/mobile/drift_schemas/main/drift_schema_v17.json differ 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 169004b45..f71aa8eb5 100644 Binary files a/mobile/lib/infrastructure/entities/merged_asset.drift.dart and b/mobile/lib/infrastructure/entities/merged_asset.drift.dart differ diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index dcc885a2a..4dc0fa568 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.dart @@ -44,6 +44,8 @@ class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin TextColumn get libraryId => 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 eab7f95f6..2d9e8b235 100644 Binary files a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart and b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart index 2eaff5d5f..d23958852 100644 --- a/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/trashed_local_asset.entity.dart @@ -45,5 +45,6 @@ extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityD height: height, width: width, orientation: orientation, + isEdited: false, ); } diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 482f7f04b..ad15401be 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository { } @override - int get schemaVersion => 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 10cba0821..fe7d1d4f0 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.steps.dart and b/mobile/lib/infrastructure/repositories/db.repository.steps.dart differ 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 6e9fa95df..1d0e73539 100644 Binary files a/mobile/openapi/lib/model/sync_asset_v1.dart and b/mobile/openapi/lib/model/sync_asset_v1.dart differ diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart index 5f139df40..a26683213 100644 --- a/mobile/test/domain/repositories/sync_stream_repository_test.dart +++ b/mobile/test/domain/repositories/sync_stream_repository_test.dart @@ -44,7 +44,7 @@ SyncAssetV1 _createAsset({ livePhotoVideoId: null, stackId: null, thumbhash: null, - editCount: 0, + isEdited: false, ); } diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index 571d5c028..89eef9c83 100644 Binary files a/mobile/test/drift/main/generated/schema.dart and b/mobile/test/drift/main/generated/schema.dart differ 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 000000000..042c069ec Binary files /dev/null and b/mobile/test/drift/main/generated/schema_v17.dart differ 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({