From 2814de4420150f317c93321f019e9881c5750e07 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 5 Dec 2023 22:14:39 -0500 Subject: [PATCH 01/21] chore(deps): update dependency vite to v4.5.1 [security] (#5513) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- web/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 5f5a3982f6..ddd49f10c0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", From f53b70571b485cd49b67da3e544f0226264eb94a Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 6 Dec 2023 14:56:09 +0000 Subject: [PATCH 02/21] 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> --- .../album/ui/album_thumbnail_listtile.dart | 66 ++++++------- .../shared/providers/websocket.provider.dart | 99 ++++++++++++++++--- .../domain/metadata/metadata.service.spec.ts | 23 +++++ .../src/domain/metadata/metadata.service.ts | 6 ++ .../repositories/communication.repository.ts | 1 + 5 files changed, 148 insertions(+), 47 deletions(-) diff --git a/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart b/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart index 38208e88c0..adf8633605 100644 --- a/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart +++ b/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(), + ], + ), + ], + ), ), ), ], diff --git a/mobile/lib/shared/providers/websocket.provider.dart b/mobile/lib/shared/providers/websocket.provider.dart index 018f7ea7a5..ebe69b8144 100644 --- a/mobile/lib/shared/providers/websocket.provider.dart +++ b/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 { 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 { } 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 _handlePendingDeletes() async { final deleteChanges = state.pendingChanges .where((c) => c.action == PendingAction.assetDelete) .toList(); if (deleteChanges.isNotEmpty) { List 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 _handlePendingUploaded() async { + final uploadedChanges = state.pendingChanges + .where((c) => c.action == PendingAction.assetUploaded) + .toList(); + if (uploadedChanges.isNotEmpty) { + List 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 _handlingPendingHidden() async { + final hiddenChanges = state.pendingChanges + .where((c) => c.action == PendingAction.assetHidden) + .toList(); + if (hiddenChanges.isNotEmpty) { + List 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 { _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 diff --git a/server/src/domain/metadata/metadata.service.spec.ts b/server/src/domain/metadata/metadata.service.spec.ts index 1eefc5ebaa..b3e3ec9270 100644 --- a/server/src/domain/metadata/metadata.service.spec.ts +++ b/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; let personMock: jest.Mocked; let storageMock: jest.Mocked; + let communicationMock: jest.Mocked; 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', () => { diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts index 9c8f887dc8..8b6cff4ece 100644 --- a/server/src/domain/metadata/metadata.service.ts +++ b/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; } diff --git a/server/src/domain/repositories/communication.repository.ts b/server/src/domain/repositories/communication.repository.ts index 958adb8033..86397d5cd8 100644 --- a/server/src/domain/repositories/communication.repository.ts +++ b/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', From e2d0e944ebcf56023cf7e3c625a6fd987d6d3393 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 6 Dec 2023 20:21:57 +0000 Subject: [PATCH 03/21] chore(renovate): ignore openapi pubspec (#5521) Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- renovate.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/renovate.json b/renovate.json index c81104d6b7..928d6783a1 100644 --- a/renovate.json +++ b/renovate.json @@ -62,6 +62,9 @@ "versioning": "node" } ], + "ignorePaths": [ + "mobile/openapi/pubspec.yaml" + ], "ignoreDeps": [ "http", "latlong2", From 338a028185380411d3ed1700c7c4facb7a63b5fd Mon Sep 17 00:00:00 2001 From: James Keane Date: Wed, 6 Dec 2023 20:51:51 -0500 Subject: [PATCH 04/21] fix(server): await`sendFile` (#5515) Fixes the intermittent EPIPE errors that myself and others are seeing. By explicitly returning a promise we ensure the caller correctly waits until the `sendFile` is complete before potentially closing or cleaning the socket. This is the most likely bug that would cause EPIPE errors. Fix was confirmed on a live system -- would benefit from a unit test though. --- .../src/immich/api-v1/asset/asset.service.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 48b64672d8..cbe63e5577 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -336,14 +336,18 @@ export class AssetService { res.set('Cache-Control', 'private, max-age=86400, no-transform'); res.header('Content-Type', mimeTypes.lookup(filepath)); - res.sendFile(filepath, options, (error: Error) => { - if (!error) { - return; - } + return new Promise((resolve, reject) => { + res.sendFile(filepath, options, (error: Error) => { + if (!error) { + resolve(); + return; + } - if (error.message !== 'Request aborted') { - this.logger.error(`Unable to send file: ${error.name}`, error.stack); - } + if (error.message !== 'Request aborted') { + this.logger.error(`Unable to send file: ${error.name}`, error.stack); + } + reject(error); + }); }); } From 8736c77f7a919f24d49718abc3d9817905da5228 Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Thu, 7 Dec 2023 03:03:28 +0100 Subject: [PATCH 05/21] fix(web): align all edit buttons and not correctly rounded buttons on detail-panel (#5524) * fix: align all pencils * fix: format --- .../lib/components/asset-viewer/detail-panel.svelte | 12 +++++++----- .../elements/buttons/circle-icon-button.svelte | 3 +++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index aa36f9195e..8c789ec330 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -205,12 +205,13 @@

PEOPLE

-
+
{#if people.some((person) => person.isHidden)} (showingHiddenPeople = !showingHiddenPeople)} /> {/if} @@ -219,6 +220,7 @@ icon={mdiPencil} padding="1" size="20" + buttonSize="32" on:click={() => (showEditFaces = true)} />
@@ -337,7 +339,7 @@
{#if isOwner} - {/if} @@ -349,7 +351,7 @@
- @@ -507,7 +509,7 @@ {:else if !asset.exifInfo?.city && !asset.isReadOnly && $user && asset.ownerId === $user.id}
(isShowChangeLocation = true)} on:keydown={(event) => event.key === 'Enter' && (isShowChangeLocation = true)} tabindex="0" @@ -521,7 +523,7 @@

Add a location

-
+
diff --git a/web/src/lib/components/elements/buttons/circle-icon-button.svelte b/web/src/lib/components/elements/buttons/circle-icon-button.svelte index bf2805166f..a2e6dc0c42 100644 --- a/web/src/lib/components/elements/buttons/circle-icon-button.svelte +++ b/web/src/lib/components/elements/buttons/circle-icon-button.svelte @@ -11,10 +11,13 @@ export let forceDark = false; export let hideMobile = false; export let iconColor = 'currentColor'; + export let buttonSize: string | undefined = undefined;