mirror of
https://github.com/immich-app/immich.git
synced 2025-01-04 02:46:47 +01: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
5 changed files with 148 additions and 47 deletions
|
@ -68,46 +68,46 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
child: album.thumbnail.value == null
|
child: album.thumbnail.value == null
|
||||||
? buildEmptyThumbnail()
|
? buildEmptyThumbnail()
|
||||||
: buildAlbumThumbnail(),
|
: buildAlbumThumbnail(),
|
||||||
),
|
),
|
||||||
Padding(
|
Expanded(
|
||||||
padding: const EdgeInsets.only(
|
child: Padding(
|
||||||
left: 8.0,
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
right: 8.0,
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Text(
|
||||||
children: [
|
album.name,
|
||||||
Text(
|
overflow: TextOverflow.ellipsis,
|
||||||
album.name,
|
style: const TextStyle(
|
||||||
style: const TextStyle(
|
fontWeight: FontWeight.bold,
|
||||||
fontWeight: FontWeight.bold,
|
),
|
||||||
),
|
),
|
||||||
),
|
Row(
|
||||||
Row(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: [
|
||||||
children: [
|
Text(
|
||||||
Text(
|
album.assetCount == 1
|
||||||
album.assetCount == 1
|
? 'album_thumbnail_card_item'
|
||||||
? 'album_thumbnail_card_item'
|
: 'album_thumbnail_card_items',
|
||||||
: 'album_thumbnail_card_items',
|
style: const TextStyle(
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
).tr(args: ['${album.assetCount}']),
|
|
||||||
if (album.shared)
|
|
||||||
const Text(
|
|
||||||
'album_thumbnail_card_shared',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
).tr(),
|
).tr(args: ['${album.assetCount}']),
|
||||||
],
|
if (album.shared)
|
||||||
),
|
const Text(
|
||||||
],
|
'album_thumbnail_card_shared',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.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/asset.dart';
|
||||||
import 'package:immich_mobile/shared/models/server_info/server_version.model.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/models/store.dart';
|
||||||
import 'package:immich_mobile/shared/providers/asset.provider.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/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/shared/services/sync.service.dart';
|
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||||
import 'package:immich_mobile/utils/debounce.dart';
|
import 'package:immich_mobile/utils/debounce.dart';
|
||||||
|
@ -14,13 +17,33 @@ import 'package:socket_io_client/socket_io_client.dart';
|
||||||
|
|
||||||
enum PendingAction {
|
enum PendingAction {
|
||||||
assetDelete,
|
assetDelete,
|
||||||
|
assetUploaded,
|
||||||
|
assetHidden,
|
||||||
}
|
}
|
||||||
|
|
||||||
class PendingChange {
|
class PendingChange {
|
||||||
|
final String id;
|
||||||
final PendingAction action;
|
final PendingAction action;
|
||||||
final dynamic value;
|
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 {
|
class WebsocketState {
|
||||||
|
@ -131,6 +154,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||||
socket.on('on_asset_trash', _handleServerUpdates);
|
socket.on('on_asset_trash', _handleServerUpdates);
|
||||||
socket.on('on_asset_restore', _handleServerUpdates);
|
socket.on('on_asset_restore', _handleServerUpdates);
|
||||||
socket.on('on_asset_update', _handleServerUpdates);
|
socket.on('on_asset_update', _handleServerUpdates);
|
||||||
|
socket.on('on_asset_hidden', _handleOnAssetHidden);
|
||||||
socket.on('on_new_release', _handleReleaseUpdates);
|
socket.on('on_new_release', _handleReleaseUpdates);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
|
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
|
||||||
|
@ -163,35 +187,78 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void addPendingChange(PendingAction action, dynamic value) {
|
void addPendingChange(PendingAction action, dynamic value) {
|
||||||
|
final now = DateTime.now();
|
||||||
state = state.copyWith(
|
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
|
final deleteChanges = state.pendingChanges
|
||||||
.where((c) => c.action == PendingAction.assetDelete)
|
.where((c) => c.action == PendingAction.assetDelete)
|
||||||
.toList();
|
.toList();
|
||||||
if (deleteChanges.isNotEmpty) {
|
if (deleteChanges.isNotEmpty) {
|
||||||
List<String> remoteIds =
|
List<String> remoteIds =
|
||||||
deleteChanges.map((a) => a.value.toString()).toList();
|
deleteChanges.map((a) => a.value.toString()).toList();
|
||||||
_ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
|
await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
pendingChanges: state.pendingChanges
|
pendingChanges: state.pendingChanges
|
||||||
.where((c) => c.action != PendingAction.assetDelete)
|
.whereNot((c) => deleteChanges.contains(c))
|
||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleOnUploadSuccess(dynamic data) {
|
Future<void> _handlePendingUploaded() async {
|
||||||
final dto = AssetResponseDto.fromJson(data);
|
final uploadedChanges = state.pendingChanges
|
||||||
if (dto != null) {
|
.where((c) => c.action == PendingAction.assetUploaded)
|
||||||
final newAsset = Asset.remote(dto);
|
.toList();
|
||||||
_ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
|
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 _) {
|
void _handleOnConfigUpdate(dynamic _) {
|
||||||
_ref.read(serverInfoProvider.notifier).getServerFeatures();
|
_ref.read(serverInfoProvider.notifier).getServerFeatures();
|
||||||
_ref.read(serverInfoProvider.notifier).getServerConfig();
|
_ref.read(serverInfoProvider.notifier).getServerConfig();
|
||||||
|
@ -202,10 +269,14 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||||
_ref.read(assetProvider.notifier).getAllAsset();
|
_ref.read(assetProvider.notifier).getAllAsset();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleOnAssetDelete(dynamic data) {
|
void _handleOnUploadSuccess(dynamic data) =>
|
||||||
addPendingChange(PendingAction.assetDelete, data);
|
addPendingChange(PendingAction.assetUploaded, data);
|
||||||
_debounce(handlePendingChanges);
|
|
||||||
}
|
void _handleOnAssetDelete(dynamic data) =>
|
||||||
|
addPendingChange(PendingAction.assetDelete, data);
|
||||||
|
|
||||||
|
void _handleOnAssetHidden(dynamic data) =>
|
||||||
|
addPendingChange(PendingAction.assetHidden, data);
|
||||||
|
|
||||||
_handleReleaseUpdates(dynamic data) {
|
_handleReleaseUpdates(dynamic data) {
|
||||||
// Json guard
|
// Json guard
|
||||||
|
|
|
@ -3,6 +3,7 @@ import {
|
||||||
assetStub,
|
assetStub,
|
||||||
newAlbumRepositoryMock,
|
newAlbumRepositoryMock,
|
||||||
newAssetRepositoryMock,
|
newAssetRepositoryMock,
|
||||||
|
newCommunicationRepositoryMock,
|
||||||
newCryptoRepositoryMock,
|
newCryptoRepositoryMock,
|
||||||
newJobRepositoryMock,
|
newJobRepositoryMock,
|
||||||
newMediaRepositoryMock,
|
newMediaRepositoryMock,
|
||||||
|
@ -19,8 +20,10 @@ import { constants } from 'fs/promises';
|
||||||
import { when } from 'jest-when';
|
import { when } from 'jest-when';
|
||||||
import { JobName } from '../job';
|
import { JobName } from '../job';
|
||||||
import {
|
import {
|
||||||
|
CommunicationEvent,
|
||||||
IAlbumRepository,
|
IAlbumRepository,
|
||||||
IAssetRepository,
|
IAssetRepository,
|
||||||
|
ICommunicationRepository,
|
||||||
ICryptoRepository,
|
ICryptoRepository,
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
IMediaRepository,
|
IMediaRepository,
|
||||||
|
@ -46,6 +49,7 @@ describe(MetadataService.name, () => {
|
||||||
let mediaMock: jest.Mocked<IMediaRepository>;
|
let mediaMock: jest.Mocked<IMediaRepository>;
|
||||||
let personMock: jest.Mocked<IPersonRepository>;
|
let personMock: jest.Mocked<IPersonRepository>;
|
||||||
let storageMock: jest.Mocked<IStorageRepository>;
|
let storageMock: jest.Mocked<IStorageRepository>;
|
||||||
|
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
||||||
let sut: MetadataService;
|
let sut: MetadataService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
@ -57,6 +61,7 @@ describe(MetadataService.name, () => {
|
||||||
metadataMock = newMetadataRepositoryMock();
|
metadataMock = newMetadataRepositoryMock();
|
||||||
moveMock = newMoveRepositoryMock();
|
moveMock = newMoveRepositoryMock();
|
||||||
personMock = newPersonRepositoryMock();
|
personMock = newPersonRepositoryMock();
|
||||||
|
communicationMock = newCommunicationRepositoryMock();
|
||||||
storageMock = newStorageRepositoryMock();
|
storageMock = newStorageRepositoryMock();
|
||||||
mediaMock = newMediaRepositoryMock();
|
mediaMock = newMediaRepositoryMock();
|
||||||
|
|
||||||
|
@ -70,6 +75,7 @@ describe(MetadataService.name, () => {
|
||||||
configMock,
|
configMock,
|
||||||
mediaMock,
|
mediaMock,
|
||||||
moveMock,
|
moveMock,
|
||||||
|
communicationMock,
|
||||||
personMock,
|
personMock,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -172,6 +178,23 @@ describe(MetadataService.name, () => {
|
||||||
expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false });
|
expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false });
|
||||||
expect(albumMock.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id);
|
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', () => {
|
describe('handleQueueMetadataExtraction', () => {
|
||||||
|
|
|
@ -9,9 +9,11 @@ import { Subscription } from 'rxjs';
|
||||||
import { usePagination } from '../domain.util';
|
import { usePagination } from '../domain.util';
|
||||||
import { IBaseJob, IEntityJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
|
import { IBaseJob, IEntityJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
|
||||||
import {
|
import {
|
||||||
|
CommunicationEvent,
|
||||||
ExifDuration,
|
ExifDuration,
|
||||||
IAlbumRepository,
|
IAlbumRepository,
|
||||||
IAssetRepository,
|
IAssetRepository,
|
||||||
|
ICommunicationRepository,
|
||||||
ICryptoRepository,
|
ICryptoRepository,
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
IMediaRepository,
|
IMediaRepository,
|
||||||
|
@ -104,6 +106,7 @@ export class MetadataService {
|
||||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||||
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
||||||
@Inject(IMoveRepository) moveRepository: IMoveRepository,
|
@Inject(IMoveRepository) moveRepository: IMoveRepository,
|
||||||
|
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
||||||
@Inject(IPersonRepository) personRepository: IPersonRepository,
|
@Inject(IPersonRepository) personRepository: IPersonRepository,
|
||||||
) {
|
) {
|
||||||
this.configCore = SystemConfigCore.create(configRepository);
|
this.configCore = SystemConfigCore.create(configRepository);
|
||||||
|
@ -167,6 +170,9 @@ export class MetadataService {
|
||||||
await this.assetRepository.save({ id: motionAsset.id, isVisible: false });
|
await this.assetRepository.save({ id: motionAsset.id, isVisible: false });
|
||||||
await this.albumRepository.removeAsset(motionAsset.id);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ export enum CommunicationEvent {
|
||||||
ASSET_DELETE = 'on_asset_delete',
|
ASSET_DELETE = 'on_asset_delete',
|
||||||
ASSET_TRASH = 'on_asset_trash',
|
ASSET_TRASH = 'on_asset_trash',
|
||||||
ASSET_UPDATE = 'on_asset_update',
|
ASSET_UPDATE = 'on_asset_update',
|
||||||
|
ASSET_HIDDEN = 'on_asset_hidden',
|
||||||
ASSET_RESTORE = 'on_asset_restore',
|
ASSET_RESTORE = 'on_asset_restore',
|
||||||
PERSON_THUMBNAIL = 'on_person_thumbnail',
|
PERSON_THUMBNAIL = 'on_person_thumbnail',
|
||||||
SERVER_VERSION = 'on_server_version',
|
SERVER_VERSION = 'on_server_version',
|
||||||
|
|
Loading…
Reference in a new issue