Skip to content

Commit

Permalink
feat: listen to deletes and auto-subscribe on all asset grid pages
Browse files Browse the repository at this point in the history
  • Loading branch information
jrasm91 committed Sep 1, 2023
1 parent 2d737ae commit a207fb2
Show file tree
Hide file tree
Showing 8 changed files with 121 additions and 81 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const ICommunicationRepository = 'ICommunicationRepository';

export enum CommunicationEvent {
UPLOAD_SUCCESS = 'on_upload_success',
ASSET_DELETE = 'on_asset_delete',
}

export interface ICommunicationRepository {
Expand Down
7 changes: 5 additions & 2 deletions server/src/immich/api-v1/asset/asset.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -7,6 +7,7 @@ import {
fileStub,
IAccessRepositoryMock,
newAccessRepositoryMock,
newCommunicationRepositoryMock,
newCryptoRepositoryMock,
newJobRepositoryMock,
newStorageRepositoryMock,
Expand Down Expand Up @@ -86,6 +87,7 @@ describe('AssetService', () => {
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
let accessMock: IAccessRepositoryMock;
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let communicationMock: jest.Mocked<ICommunicationRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions server/src/immich/api-v1/asset/asset.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import {
AccessCore,
AssetResponseDto,
AuthUserDto,
CommunicationEvent,
getLivePhotoMotionFilename,
IAccessRepository,
ICommunicationRepository,
ICryptoRepository,
IJobRepository,
IStorageRepository,
Expand Down Expand Up @@ -64,6 +66,7 @@ export class AssetService {
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
Expand Down Expand Up @@ -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 });

Expand Down
3 changes: 3 additions & 0 deletions web/src/lib/components/photos-page/asset-grid.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
onMount(async () => {
document.addEventListener('keydown', onKeyboardPress);
assetStore.connect();
await assetStore.init(viewport);
});
Expand All @@ -49,6 +50,8 @@
if ($showAssetViewer) {
$showAssetViewer = false;
}
assetStore.disconnect();
});
const handleKeyboardPress = (event: KeyboardEvent) => {
Expand Down
13 changes: 5 additions & 8 deletions web/src/lib/components/shared-components/upload-panel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
139 changes: 98 additions & 41 deletions web/src/lib/stores/assets.store.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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<string, AssetLookup> = {};
private newAssets: AssetResponseDto[] = [];
private pendingChanges: PendingChange[] = [];
private unsubscribers: Unsubscriber[] = [];

initialized = false;
timelineHeight = 0;
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 5 additions & 9 deletions web/src/lib/stores/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ let websocket: Socket;

function initWebsocketStore() {
const onUploadSuccess = writable<AssetResponseDto>();
const onAssetDelete = writable<string>();

return {
onUploadSuccess,
onAssetDelete,
};
}

Expand All @@ -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 = () => {
Expand Down
21 changes: 0 additions & 21 deletions web/src/routes/(user)/photos/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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());
</script>

<UserPageLayout user={data.user} hideNavbar={$isMultiSelectState} showUploadButton>
Expand Down

0 comments on commit a207fb2

Please sign in to comment.