diff --git a/server/src/domain/communication/communication.repository.ts b/server/src/domain/communication/communication.repository.ts index 12952144686bc..68f81085c8e37 100644 --- a/server/src/domain/communication/communication.repository.ts +++ b/server/src/domain/communication/communication.repository.ts @@ -2,6 +2,7 @@ export const ICommunicationRepository = 'ICommunicationRepository'; export enum CommunicationEvent { UPLOAD_SUCCESS = 'on_upload_success', + ASSET_DELETE = 'on_asset_delete', } export interface ICommunicationRepository { diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts index 3b8f9a1b9dc3d..5edd8be57239a 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -1,4 +1,4 @@ -import { ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain'; +import { ICommunicationRepository, ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain'; import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { BadRequestException } from '@nestjs/common'; import { @@ -7,6 +7,7 @@ import { fileStub, IAccessRepositoryMock, newAccessRepositoryMock, + newCommunicationRepositoryMock, newCryptoRepositoryMock, newJobRepositoryMock, newStorageRepositoryMock, @@ -86,6 +87,7 @@ describe('AssetService', () => { let a: Repository; // TO BE DELETED AFTER FINISHED REFACTORING let accessMock: IAccessRepositoryMock; let assetRepositoryMock: jest.Mocked; + let communicationMock: jest.Mocked; let cryptoMock: jest.Mocked; let jobMock: jest.Mocked; let storageMock: jest.Mocked; @@ -109,11 +111,12 @@ describe('AssetService', () => { }; accessMock = newAccessRepositoryMock(); + communicationMock = newCommunicationRepositoryMock(); cryptoMock = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); storageMock = newStorageRepositoryMock(); - sut = new AssetService(accessMock, assetRepositoryMock, a, cryptoMock, jobMock, storageMock); + sut = new AssetService(accessMock, assetRepositoryMock, a, communicationMock, cryptoMock, jobMock, storageMock); when(assetRepositoryMock.get) .calledWith(assetStub.livePhotoStillAsset.id) diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index b397b2688b011..6481df82218c4 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -2,8 +2,10 @@ import { AccessCore, AssetResponseDto, AuthUserDto, + CommunicationEvent, getLivePhotoMotionFilename, IAccessRepository, + ICommunicationRepository, ICryptoRepository, IJobRepository, IStorageRepository, @@ -64,6 +66,7 @@ export class AssetService { @Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAssetRepository) private _assetRepository: IAssetRepository, @InjectRepository(AssetEntity) private assetRepository: Repository, + @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @@ -286,6 +289,7 @@ export class AssetService { await this._assetRepository.remove(asset); await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [id] } }); + this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, id); result.push({ id, status: DeleteAssetStatusEnum.SUCCESS }); diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 31c62185c68f3..6eba7b1896d13 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -38,6 +38,7 @@ onMount(async () => { document.addEventListener('keydown', onKeyboardPress); + assetStore.connect(); await assetStore.init(viewport); }); @@ -49,6 +50,8 @@ if ($showAssetViewer) { $showAssetViewer = false; } + + assetStore.disconnect(); }); const handleKeyboardPress = (event: KeyboardEvent) => { diff --git a/web/src/lib/components/shared-components/upload-panel.svelte b/web/src/lib/components/shared-components/upload-panel.svelte index 4ca4c918108ac..0a064740758c5 100644 --- a/web/src/lib/components/shared-components/upload-panel.svelte +++ b/web/src/lib/components/shared-components/upload-panel.svelte @@ -36,15 +36,12 @@ in:fade={{ duration: 250 }} out:fade={{ duration: 250 }} on:outroend={() => { - const errorInfo = - $errorCounter > 0 - ? `Upload completed with ${$errorCounter} error${$errorCounter > 1 ? 's' : ''}` - : 'Upload success'; - const type = $errorCounter > 0 ? NotificationType.Warning : NotificationType.Info; - notificationController.show({ - message: `${errorInfo}, refresh the page to see new upload assets`, - type, + message: + $errorCounter > 0 + ? `Upload completed with ${$errorCounter} error${$errorCounter > 1 ? 's' : ''}` + : 'Upload success', + type: $errorCounter > 0 ? NotificationType.Warning : NotificationType.Info, }); if ($duplicateCounter > 0) { diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index 4556f649a790a..ffeebbad0bf87 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -1,8 +1,9 @@ import { api, AssetApiGetTimeBucketsRequest, AssetResponseDto } from '@api'; -import { writable } from 'svelte/store'; -import { handleError } from '../utils/handle-error'; -import { DateTime } from 'luxon'; import { debounce } from 'lodash-es'; +import { DateTime } from 'luxon'; +import { Unsubscriber, writable } from 'svelte/store'; +import { handleError } from '../utils/handle-error'; +import { websocketStore } from './websocket'; export enum BucketPosition { Above = 'above', @@ -36,12 +37,28 @@ export class AssetBucket { position!: BucketPosition; } +const isMismatched = (option: boolean | undefined, value: boolean): boolean => + option === undefined ? false : option !== value; + const THUMBNAIL_HEIGHT = 235; +interface AddAsset { + type: 'add'; + value: AssetResponseDto; +} + +interface RemoveAsset { + type: 'remove'; + value: string; +} + +type PendingChange = AddAsset | RemoveAsset; + export class AssetStore { private store$ = writable(this); private assetToBucket: Record = {}; - private newAssets: AssetResponseDto[] = []; + private pendingChanges: PendingChange[] = []; + private unsubscribers: Unsubscriber[] = []; initialized = false; timelineHeight = 0; @@ -55,6 +72,49 @@ export class AssetStore { subscribe = this.store$.subscribe; + connect() { + this.unsubscribers.push( + websocketStore.onUploadSuccess.subscribe((value) => { + if (value) { + this.pendingChanges.push({ type: 'add', value }); + this.debouncer(); + } + }), + websocketStore.onAssetDelete.subscribe((value) => { + if (value) { + this.pendingChanges.push({ type: 'remove', value }); + this.debouncer(); + } + }), + ); + } + + disconnect() { + for (const unsubscribe of this.unsubscribers) { + unsubscribe(); + } + } + + debouncer = debounce(() => { + for (const { type, value } of this.pendingChanges) { + switch (type) { + case 'add': + this.addAsset(value); + break; + case 'remove': + this.removeAsset(value); + break; + } + } + + this.pendingChanges = []; + this.emit(true); + }, 2000); + + flush() { + this.debouncer.flush(); + } + async init(viewport: Viewport) { this.initialized = false; this.timelineHeight = 0; @@ -171,47 +231,44 @@ export class AssetStore { return scrollTimeline ? delta : 0; } - createBucket(bucketDate: string): AssetBucket { - const bucket = new AssetBucket(); - - bucket.bucketDate = bucketDate; - bucket.bucketHeight = THUMBNAIL_HEIGHT; - bucket.assets = []; - bucket.cancelToken = null; - bucket.position = BucketPosition.Unknown; - - return bucket; - } - private debounceAddToBucket = debounce(() => this._addToBucket(), 2000); - - addToBucket(asset: AssetResponseDto) { - this.newAssets.push(asset); - this.debounceAddToBucket(); - } - - private _addToBucket(): void { - try { - for (const asset of this.newAssets) { - const timeBucket = DateTime.fromISO(asset.fileCreatedAt).toUTC().startOf('month').toString(); - const bucket = this.getBucketByDate(timeBucket); + private addAsset(asset: AssetResponseDto): void { + if ( + this.assetToBucket[asset.id] || + this.options.userId || + this.options.personId || + this.options.albumId || + isMismatched(this.options.isArchived, asset.isArchived) || + isMismatched(this.options.isFavorite, asset.isFavorite) + ) { + return; + } - if (!bucket) { - continue; - } + const timeBucket = DateTime.fromISO(asset.fileCreatedAt).toUTC().startOf('month').toString(); + let bucket = this.getBucketByDate(timeBucket); - bucket.assets.push(asset); - bucket.assets.sort((a, b) => { - const aDate = DateTime.fromISO(a.fileCreatedAt).toUTC(); - const bDate = DateTime.fromISO(b.fileCreatedAt).toUTC(); - return bDate.diff(aDate).milliseconds; - }); - } + if (!bucket) { + bucket = { + bucketDate: timeBucket, + bucketHeight: THUMBNAIL_HEIGHT, + assets: [], + cancelToken: null, + position: BucketPosition.Unknown, + }; - this.newAssets = []; - this.emit(true); - } catch (e) { - console.error(e); + this.buckets.push(bucket); + this.buckets = this.buckets.sort((a, b) => { + const aDate = DateTime.fromISO(a.bucketDate).toUTC(); + const bDate = DateTime.fromISO(b.bucketDate).toUTC(); + return bDate.diff(aDate).milliseconds; + }); } + + bucket.assets.push(asset); + bucket.assets.sort((a, b) => { + const aDate = DateTime.fromISO(a.fileCreatedAt).toUTC(); + const bDate = DateTime.fromISO(b.fileCreatedAt).toUTC(); + return bDate.diff(aDate).milliseconds; + }); } getBucketByDate(bucketDate: string): AssetBucket | null { diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 33fed75a2638d..8837f9cd05b28 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -6,9 +6,11 @@ let websocket: Socket; function initWebsocketStore() { const onUploadSuccess = writable(); + const onAssetDelete = writable(); return { onUploadSuccess, + onAssetDelete, }; } @@ -31,15 +33,9 @@ export const openWebsocketConnection = () => { }; const listenToEvent = async (socket: Socket) => { - socket.on('on_upload_success', (payload) => { - const asset: AssetResponseDto = JSON.parse(payload); - - websocketStore.onUploadSuccess.set(asset); - }); - - socket.on('error', (e) => { - console.log('Websocket Error', e); - }); + socket.on('on_upload_success', (data) => websocketStore.onUploadSuccess.set(JSON.parse(data) as AssetResponseDto)); + socket.on('on_asset_delete', (data) => websocketStore.onAssetDelete.set(JSON.parse(data) as string)); + socket.on('error', (e) => console.log('Websocket Error', e)); }; export const closeWebsocketConnection = () => { diff --git a/web/src/routes/(user)/photos/+page.svelte b/web/src/routes/(user)/photos/+page.svelte index 7f903a0b491ca..99ce53e12dfc1 100644 --- a/web/src/routes/(user)/photos/+page.svelte +++ b/web/src/routes/(user)/photos/+page.svelte @@ -16,10 +16,8 @@ import { AssetAction } from '$lib/constants'; import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { AssetStore } from '$lib/stores/assets.store'; - import { websocketStore } from '$lib/stores/websocket'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { TimeBucketSize } from '@api'; - import { onDestroy } from 'svelte'; import DotsVertical from 'svelte-material-icons/DotsVertical.svelte'; import Plus from 'svelte-material-icons/Plus.svelte'; import type { PageData } from './$types'; @@ -31,25 +29,6 @@ const { isMultiSelectState, selectedAssets } = assetInteractionStore; $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite); - - let firstCall = false; - const wsPageUnsubscriber = websocketStore.onUploadSuccess.subscribe((asset) => { - /** - * By design, Writable stores will emit their current value to new subscribers. - * This means by navigating to a different page and back, we will receive the last emitted value, - * and this will cause duplicate asset in the bucket, the app will throw an error. - * - * firstCall is used to prevent this from happening. - */ - if (!asset || !firstCall) { - firstCall = true; - return; - } - - assetStore.addToBucket(asset); - }); - - onDestroy(() => wsPageUnsubscriber());