mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:29:32 -05:00 
			
		
		
		
	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>
This commit is contained in:
		
							parent
							
								
									2814de4420
								
							
						
					
					
						commit
						f53b70571b
					
				@ -68,21 +68,20 @@ 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,
 | 
			
		||||
              ),
 | 
			
		||||
            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,
 | 
			
		||||
                      ),
 | 
			
		||||
@ -110,6 +109,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
 | 
			
		||||
                  ],
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
 | 
			
		||||
@ -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,34 +187,77 @@ 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);
 | 
			
		||||
  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);
 | 
			
		||||
      _ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
 | 
			
		||||
          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();
 | 
			
		||||
@ -202,10 +269,14 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
 | 
			
		||||
    _ref.read(assetProvider.notifier).getAllAsset();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _handleOnAssetDelete(dynamic data) {
 | 
			
		||||
  void _handleOnUploadSuccess(dynamic data) =>
 | 
			
		||||
      addPendingChange(PendingAction.assetUploaded, data);
 | 
			
		||||
 | 
			
		||||
  void _handleOnAssetDelete(dynamic data) =>
 | 
			
		||||
      addPendingChange(PendingAction.assetDelete, data);
 | 
			
		||||
    _debounce(handlePendingChanges);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _handleOnAssetHidden(dynamic data) =>
 | 
			
		||||
      addPendingChange(PendingAction.assetHidden, data);
 | 
			
		||||
 | 
			
		||||
  _handleReleaseUpdates(dynamic data) {
 | 
			
		||||
    // Json guard
 | 
			
		||||
 | 
			
		||||
@ -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', () => {
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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',
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user