Merge branch 'main' of github.com:immich-app/immich into feat/handle-icloud-asset

This commit is contained in:
Alex 2023-12-06 08:56:44 -06:00
commit 674fdb6328
No known key found for this signature in database
GPG key ID: 53CD082B3A5E1082
8 changed files with 153 additions and 52 deletions

View file

@ -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(),
],
),
],
),
),
),
],

View file

@ -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

View file

@ -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', () => {

View file

@ -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;
}

View file

@ -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',

6
web/package-lock.json generated
View file

@ -11866,9 +11866,9 @@
"dev": true
},
"node_modules/vite": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz",
"integrity": "sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==",
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.1.tgz",
"integrity": "sha512-AXXFaAJ8yebyqzoNB9fu2pHoo/nWX+xZlaRwoeYUxEqBO+Zj4msE5G+BhGBll9lYEKv9Hfks52PAF2X7qDYXQA==",
"dev": true,
"dependencies": {
"esbuild": "^0.18.10",

View file

@ -111,7 +111,7 @@
<section
transition:fly={{ x: 360, duration: 100, easing: linear }}
class="absolute top-0 z-[2001] h-full w-[360px] overflow-x-hidden p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg"
class="absolute top-0 z-[2001] h-full w-[360px] overflow-x-hidden p-2 bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg"
>
<div class="flex place-items-center justify-between gap-2">
{#if !searchFaces}

View file

@ -160,7 +160,7 @@
<section
transition:fly={{ x: 360, duration: 100, easing: linear }}
class="absolute top-0 z-[2000] h-full w-[360px] overflow-x-hidden p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg"
class="absolute top-0 z-[2000] h-full w-[360px] overflow-x-hidden p-2 bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg"
>
<div class="flex place-items-center justify-between gap-2">
<div class="flex items-center gap-2">