From 2db907239f8f1127d472263ba3860338218c95ea Mon Sep 17 00:00:00 2001 From: Yaros Date: Wed, 24 Jun 2026 13:18:42 +0200 Subject: [PATCH] fix: update ocr & faces after asset edit --- .../domain/services/sync_stream.service.dart | 18 +++++ .../repositories/sync_stream.repository.dart | 65 +++++++++++++++++++ mobile/lib/providers/websocket.provider.dart | 28 +++++++- mobile/lib/services/api.service.dart | 2 + 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 5a197dc88a..80a9dd592a 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -446,6 +446,7 @@ class SyncStreamService { await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit'); await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit'); + await _refreshAssetOcrAndFaces(asset.id); _logger.info( 'Successfully processed AssetEditReadyV1 event for asset ${asset.id} with ${assetEdits.length} edits', @@ -484,6 +485,7 @@ class SyncStreamService { await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit'); await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit'); + await _refreshAssetOcrAndFaces(asset.id); _logger.info( 'Successfully processed AssetEditReadyV2 event for asset ${asset.id} with ${assetEdits.length} edits', @@ -493,6 +495,22 @@ class SyncStreamService { } } + Future _refreshAssetOcrAndFaces(String assetId) async { + try { + final ocr = await _api.assetsApi.getAssetOcr(assetId); + await _syncStreamRepository.replaceAssetOcr(assetId, ocr ?? const []); + } catch (error, stackTrace) { + _logger.severe("Error refreshing OCR for asset $assetId", error, stackTrace); + } + + try { + final faces = await _api.facesApi.getFaces(assetId); + await _syncStreamRepository.replaceAssetFaces(assetId, faces ?? const []); + } catch (error, stackTrace) { + _logger.severe("Error refreshing faces for asset $assetId", error, stackTrace); + } + } + Future _handleRemoteDeleted(Iterable remoteIds) async { if (remoteIds.isEmpty) { return Future.value(); diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 8de76f1412..a4ddac8edd 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -896,6 +896,71 @@ class SyncStreamRepository extends DriftDatabaseRepository { } } + /// Replaces all OCR rows for [assetId] with [data] (e.g. after an asset edit re-runs OCR). + Future replaceAssetOcr(String assetId, Iterable data) async { + try { + await _db.batch((batch) { + batch.deleteWhere(_db.assetOcrEntity, (row) => row.assetId.equals(assetId)); + + for (final ocr in data) { + batch.insert( + _db.assetOcrEntity, + AssetOcrEntityCompanion( + id: Value(ocr.id), + assetId: Value(ocr.assetId), + recognizedText: Value(ocr.text), + x1: Value(ocr.x1), + y1: Value(ocr.y1), + x2: Value(ocr.x2), + y2: Value(ocr.y2), + x3: Value(ocr.x3), + y3: Value(ocr.y3), + x4: Value(ocr.x4), + y4: Value(ocr.y4), + boxScore: Value(ocr.boxScore), + textScore: Value(ocr.textScore), + isVisible: const Value(true), + ), + ); + } + }); + } catch (error, stack) { + _logger.severe('Error: replaceAssetOcr', error, stack); + rethrow; + } + } + + Future replaceAssetFaces(String assetId, Iterable data) async { + try { + await _db.batch((batch) { + batch.deleteWhere(_db.assetFaceEntity, (row) => row.assetId.equals(assetId)); + + for (final face in data) { + batch.insert( + _db.assetFaceEntity, + AssetFaceEntityCompanion( + id: Value(face.id), + assetId: Value(assetId), + personId: Value(face.person?.id), + imageWidth: Value(face.imageWidth), + imageHeight: Value(face.imageHeight), + boundingBoxX1: Value(face.boundingBoxX1), + boundingBoxY1: Value(face.boundingBoxY1), + boundingBoxX2: Value(face.boundingBoxX2), + boundingBoxY2: Value(face.boundingBoxY2), + sourceType: Value(face.sourceType.orElse(null)?.value ?? SourceType.machineLearning.value), + isVisible: const Value(true), + deletedAt: const Value(null), + ), + ); + } + }); + } catch (error, stack) { + _logger.severe('Error: replaceAssetFaces', error, stack); + rethrow; + } + } + Future pruneAssets() async { try { await _db.transaction(() async { diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 8d9bd5bfe3..e9da5f7090 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/infrastructure/repositories/network.repository.dar import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/utils/debounce.dart'; @@ -181,11 +182,34 @@ class WebsocketNotifier extends StateNotifier { } void _handleSyncAssetEditReadyV1(dynamic data) { - unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data)); + final assetId = _assetIdFromEditReady(data); + unawaited( + _ref.read(backgroundSyncProvider).syncWebsocketEditV1(data).whenComplete(() => _onAssetEditApplied(assetId)), + ); } void _handleSyncAssetEditReadyV2(dynamic data) { - unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data)); + final assetId = _assetIdFromEditReady(data); + unawaited( + _ref.read(backgroundSyncProvider).syncWebsocketEditV2(data).whenComplete(() => _onAssetEditApplied(assetId)), + ); + } + + String? _assetIdFromEditReady(dynamic data) { + if (data is Map && data['asset'] is Map) { + final id = (data['asset'] as Map)['id']; + return id is String ? id : null; + } + return null; + } + + /// The edit handler refreshes OCR/faces in the drift DB from a background isolate, + /// so the main-isolate UI providers must be invalidated here to re-read the new data. + void _onAssetEditApplied(String? assetId) { + if (assetId == null) { + return; + } + _ref.invalidate(ocrAssetProvider(assetId)); } void _processBatchedAssetUploadReadyV1() { diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 59ef935f2f..2e5b8c0029 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -36,6 +36,7 @@ class ApiService { late MemoriesApi memoriesApi; late SessionsApi sessionsApi; late TagsApi tagsApi; + late FacesApi facesApi; ApiService() { // The below line ensures that the api clients are initialized when the service is instantiated @@ -77,6 +78,7 @@ class ApiService { memoriesApi = MemoriesApi(_apiClient); sessionsApi = SessionsApi(_apiClient); tagsApi = TagsApi(_apiClient); + facesApi = FacesApi(_apiClient); } Future resolveAndSetEndpoint(String serverUrl) async {