浏览代码

fix: notify mobile app when live photos are linked (#5504)

* fix(mobile): album thumbnail list tile overflow on large album title

* fix: notify clients about live photo linked event

* refactor: notify clients during meta extraction

---------

Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
shenlong 1 年之前
父节点
当前提交
f53b70571b

+ 33 - 33
mobile/lib/modules/album/ui/album_thumbnail_listtile.dart

@@ -68,46 +68,46 @@ class AlbumThumbnailListTile extends StatelessWidget {
           crossAxisAlignment: CrossAxisAlignment.start,
           children: [
             ClipRRect(
-              borderRadius: BorderRadius.circular(8),
+              borderRadius: const BorderRadius.all(Radius.circular(8)),
               child: album.thumbnail.value == null
                   ? buildEmptyThumbnail()
                   : buildAlbumThumbnail(),
             ),
-            Padding(
-              padding: const EdgeInsets.only(
-                left: 8.0,
-                right: 8.0,
-              ),
-              child: Column(
-                crossAxisAlignment: CrossAxisAlignment.start,
-                children: [
-                  Text(
-                    album.name,
-                    style: const TextStyle(
-                      fontWeight: FontWeight.bold,
+            Expanded(
+              child: Padding(
+                padding: const EdgeInsets.symmetric(horizontal: 8.0),
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    Text(
+                      album.name,
+                      overflow: TextOverflow.ellipsis,
+                      style: const TextStyle(
+                        fontWeight: FontWeight.bold,
+                      ),
                     ),
-                  ),
-                  Row(
-                    mainAxisSize: MainAxisSize.min,
-                    children: [
-                      Text(
-                        album.assetCount == 1
-                            ? 'album_thumbnail_card_item'
-                            : 'album_thumbnail_card_items',
-                        style: const TextStyle(
-                          fontSize: 12,
-                        ),
-                      ).tr(args: ['${album.assetCount}']),
-                      if (album.shared)
-                        const Text(
-                          'album_thumbnail_card_shared',
-                          style: TextStyle(
+                    Row(
+                      mainAxisSize: MainAxisSize.min,
+                      children: [
+                        Text(
+                          album.assetCount == 1
+                              ? 'album_thumbnail_card_item'
+                              : 'album_thumbnail_card_items',
+                          style: const TextStyle(
                             fontSize: 12,
                           ),
-                        ).tr(),
-                    ],
-                  ),
-                ],
+                        ).tr(args: ['${album.assetCount}']),
+                        if (album.shared)
+                          const Text(
+                            'album_thumbnail_card_shared',
+                            style: TextStyle(
+                              fontSize: 12,
+                            ),
+                          ).tr(),
+                      ],
+                    ),
+                  ],
+                ),
               ),
             ),
           ],

+ 85 - 14
mobile/lib/shared/providers/websocket.provider.dart

@@ -1,10 +1,13 @@
+import 'package:collection/collection.dart';
 import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/server_info/server_version.model.dart';
 import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
+import 'package:immich_mobile/shared/providers/db.provider.dart';
 import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:immich_mobile/shared/services/sync.service.dart';
 import 'package:immich_mobile/utils/debounce.dart';
@@ -14,13 +17,33 @@ import 'package:socket_io_client/socket_io_client.dart';
 
 enum PendingAction {
   assetDelete,
+  assetUploaded,
+  assetHidden,
 }
 
 class PendingChange {
+  final String id;
   final PendingAction action;
   final dynamic value;
 
-  const PendingChange(this.action, this.value);
+  const PendingChange(
+    this.id,
+    this.action,
+    this.value,
+  );
+
+  @override
+  String toString() => 'PendingChange(id: $id, action: $action, value: $value)';
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is PendingChange && other.id == id && other.action == action;
+  }
+
+  @override
+  int get hashCode => id.hashCode ^ action.hashCode;
 }
 
 class WebsocketState {
@@ -131,6 +154,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
         socket.on('on_asset_trash', _handleServerUpdates);
         socket.on('on_asset_restore', _handleServerUpdates);
         socket.on('on_asset_update', _handleServerUpdates);
+        socket.on('on_asset_hidden', _handleOnAssetHidden);
         socket.on('on_new_release', _handleReleaseUpdates);
       } catch (e) {
         debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
@@ -163,35 +187,78 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
   }
 
   void addPendingChange(PendingAction action, dynamic value) {
+    final now = DateTime.now();
     state = state.copyWith(
-      pendingChanges: [...state.pendingChanges, PendingChange(action, value)],
+      pendingChanges: [
+        ...state.pendingChanges,
+        PendingChange(now.millisecondsSinceEpoch.toString(), action, value),
+      ],
     );
+    _debounce(handlePendingChanges);
   }
 
-  void handlePendingChanges() {
+  Future<void> _handlePendingDeletes() async {
     final deleteChanges = state.pendingChanges
         .where((c) => c.action == PendingAction.assetDelete)
         .toList();
     if (deleteChanges.isNotEmpty) {
       List<String> remoteIds =
           deleteChanges.map((a) => a.value.toString()).toList();
-      _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
+      await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
       state = state.copyWith(
         pendingChanges: state.pendingChanges
-            .where((c) => c.action != PendingAction.assetDelete)
+            .whereNot((c) => deleteChanges.contains(c))
             .toList(),
       );
     }
   }
 
-  void _handleOnUploadSuccess(dynamic data) {
-    final dto = AssetResponseDto.fromJson(data);
-    if (dto != null) {
-      final newAsset = Asset.remote(dto);
-      _ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
+  Future<void> _handlePendingUploaded() async {
+    final uploadedChanges = state.pendingChanges
+        .where((c) => c.action == PendingAction.assetUploaded)
+        .toList();
+    if (uploadedChanges.isNotEmpty) {
+      List<AssetResponseDto?> remoteAssets = uploadedChanges
+          .map((a) => AssetResponseDto.fromJson(a.value))
+          .toList();
+      for (final dto in remoteAssets) {
+        if (dto != null) {
+          final newAsset = Asset.remote(dto);
+          await _ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
+        }
+      }
+      state = state.copyWith(
+        pendingChanges: state.pendingChanges
+            .whereNot((c) => uploadedChanges.contains(c))
+            .toList(),
+      );
     }
   }
 
+  Future<void> _handlingPendingHidden() async {
+    final hiddenChanges = state.pendingChanges
+        .where((c) => c.action == PendingAction.assetHidden)
+        .toList();
+    if (hiddenChanges.isNotEmpty) {
+      List<String> remoteIds =
+          hiddenChanges.map((a) => a.value.toString()).toList();
+      final db = _ref.watch(dbProvider);
+      await db.writeTxn(() => db.assets.deleteAllByRemoteId(remoteIds));
+
+      state = state.copyWith(
+        pendingChanges: state.pendingChanges
+            .whereNot((c) => hiddenChanges.contains(c))
+            .toList(),
+      );
+    }
+  }
+
+  void handlePendingChanges() async {
+    await _handlePendingUploaded();
+    await _handlePendingDeletes();
+    await _handlingPendingHidden();
+  }
+
   void _handleOnConfigUpdate(dynamic _) {
     _ref.read(serverInfoProvider.notifier).getServerFeatures();
     _ref.read(serverInfoProvider.notifier).getServerConfig();
@@ -202,10 +269,14 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
     _ref.read(assetProvider.notifier).getAllAsset();
   }
 
-  void _handleOnAssetDelete(dynamic data) {
-    addPendingChange(PendingAction.assetDelete, data);
-    _debounce(handlePendingChanges);
-  }
+  void _handleOnUploadSuccess(dynamic data) =>
+      addPendingChange(PendingAction.assetUploaded, data);
+
+  void _handleOnAssetDelete(dynamic data) =>
+      addPendingChange(PendingAction.assetDelete, data);
+
+  void _handleOnAssetHidden(dynamic data) =>
+      addPendingChange(PendingAction.assetHidden, data);
 
   _handleReleaseUpdates(dynamic data) {
     // Json guard

+ 23 - 0
server/src/domain/metadata/metadata.service.spec.ts

@@ -3,6 +3,7 @@ import {
   assetStub,
   newAlbumRepositoryMock,
   newAssetRepositoryMock,
+  newCommunicationRepositoryMock,
   newCryptoRepositoryMock,
   newJobRepositoryMock,
   newMediaRepositoryMock,
@@ -19,8 +20,10 @@ import { constants } from 'fs/promises';
 import { when } from 'jest-when';
 import { JobName } from '../job';
 import {
+  CommunicationEvent,
   IAlbumRepository,
   IAssetRepository,
+  ICommunicationRepository,
   ICryptoRepository,
   IJobRepository,
   IMediaRepository,
@@ -46,6 +49,7 @@ describe(MetadataService.name, () => {
   let mediaMock: jest.Mocked<IMediaRepository>;
   let personMock: jest.Mocked<IPersonRepository>;
   let storageMock: jest.Mocked<IStorageRepository>;
+  let communicationMock: jest.Mocked<ICommunicationRepository>;
   let sut: MetadataService;
 
   beforeEach(async () => {
@@ -57,6 +61,7 @@ describe(MetadataService.name, () => {
     metadataMock = newMetadataRepositoryMock();
     moveMock = newMoveRepositoryMock();
     personMock = newPersonRepositoryMock();
+    communicationMock = newCommunicationRepositoryMock();
     storageMock = newStorageRepositoryMock();
     mediaMock = newMediaRepositoryMock();
 
@@ -70,6 +75,7 @@ describe(MetadataService.name, () => {
       configMock,
       mediaMock,
       moveMock,
+      communicationMock,
       personMock,
     );
   });
@@ -172,6 +178,23 @@ describe(MetadataService.name, () => {
       expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false });
       expect(albumMock.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id);
     });
+
+    it('should notify clients on live photo link', async () => {
+      assetMock.getByIds.mockResolvedValue([
+        {
+          ...assetStub.livePhotoStillAsset,
+          exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity,
+        },
+      ]);
+      assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
+
+      await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true);
+      expect(communicationMock.send).toHaveBeenCalledWith(
+        CommunicationEvent.ASSET_HIDDEN,
+        assetStub.livePhotoMotionAsset.ownerId,
+        assetStub.livePhotoMotionAsset.id,
+      );
+    });
   });
 
   describe('handleQueueMetadataExtraction', () => {

+ 6 - 0
server/src/domain/metadata/metadata.service.ts

@@ -9,9 +9,11 @@ import { Subscription } from 'rxjs';
 import { usePagination } from '../domain.util';
 import { IBaseJob, IEntityJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
 import {
+  CommunicationEvent,
   ExifDuration,
   IAlbumRepository,
   IAssetRepository,
+  ICommunicationRepository,
   ICryptoRepository,
   IJobRepository,
   IMediaRepository,
@@ -104,6 +106,7 @@ export class MetadataService {
     @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
     @Inject(IMediaRepository) private mediaRepository: IMediaRepository,
     @Inject(IMoveRepository) moveRepository: IMoveRepository,
+    @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
     @Inject(IPersonRepository) personRepository: IPersonRepository,
   ) {
     this.configCore = SystemConfigCore.create(configRepository);
@@ -167,6 +170,9 @@ export class MetadataService {
     await this.assetRepository.save({ id: motionAsset.id, isVisible: false });
     await this.albumRepository.removeAsset(motionAsset.id);
 
+    // Notify clients to hide the linked live photo asset
+    this.communicationRepository.send(CommunicationEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id);
+
     return true;
   }
 

+ 1 - 0
server/src/domain/repositories/communication.repository.ts

@@ -5,6 +5,7 @@ export enum CommunicationEvent {
   ASSET_DELETE = 'on_asset_delete',
   ASSET_TRASH = 'on_asset_trash',
   ASSET_UPDATE = 'on_asset_update',
+  ASSET_HIDDEN = 'on_asset_hidden',
   ASSET_RESTORE = 'on_asset_restore',
   PERSON_THUMBNAIL = 'on_person_thumbnail',
   SERVER_VERSION = 'on_server_version',