From 4773f75f429e3cb9ef8409fdfbbb597da6407c9c Mon Sep 17 00:00:00 2001 From: bwees Date: Fri, 16 Jan 2026 14:41:34 -0600 Subject: [PATCH] fix: use edit websocket event to update thumb/timeline --- .../domain/services/sync_stream.service.dart | 36 +++++++++++++++++++ mobile/lib/domain/utils/background_sync.dart | 15 ++++++++ .../widgets/images/image_provider.dart | 8 +++-- .../widgets/images/remote_image_provider.dart | 20 ++++++----- .../widgets/images/thumbnail.widget.dart | 11 ++++-- mobile/lib/providers/websocket.provider.dart | 7 ++++ mobile/lib/utils/image_url_builder.dart | 4 ++- .../src/repositories/websocket.repository.ts | 2 +- server/src/services/job.service.ts | 24 ++++++++++++- .../lib/managers/edit/edit-manager.svelte.ts | 2 +- web/src/lib/stores/websocket.ts | 2 +- 11 files changed, 112 insertions(+), 19 deletions(-) diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index e14321a780..d5029abac8 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 637ae20cb8..6840bae595 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/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index ff4c8578f3..b821ec375c 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 = null; } 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,7 +135,8 @@ 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 : null; + return assetId != null ? RemoteThumbProvider(assetId: assetId, thumbhash: thumbhash) : null; } bool _shouldUseLocalAsset(BaseAsset asset) => diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index d9a736861f..e0d4c0ef6c 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, 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, 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 92b1bb2544..836ed98e04 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, + 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/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 6a1083bfcc..f9473ce440 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/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index 4059f5baa2..079f0e51fa 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/server/src/repositories/websocket.repository.ts b/server/src/repositories/websocket.repository.ts index c2da06786c..bfed556895 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/services/job.service.ts b/server/src/services/job.service.ts index 5cca0a8f8e..0f8698f160 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, + editCount: asset.editCount, + }, + }); } break; diff --git a/web/src/lib/managers/edit/edit-manager.svelte.ts b/web/src/lib/managers/edit/edit-manager.svelte.ts index b8ebea1cf0..ef326f2661 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 5b985e3050..c2e4eb614b 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({