feat(web): enable websocket (#3765)
* send store event to page * fix format * add new asset to existing bucket * format * debouncing * format * load bucket * feedback * feat: listen to deletes and auto-subscribe on all asset grid pages * feat: auto refresh on person thumbnail * chore: skip upload event for now * fix: person thumbnail event * fix merge * update handleAssetDeletion with websocket communication * update info box on mount * fix test * fix test * feat: event for trash asset --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
parent
4dffae3f39
commit
36b21948bf
16 changed files with 279 additions and 136 deletions
|
@ -7,6 +7,7 @@ import {
|
|||
faceStub,
|
||||
newAccessRepositoryMock,
|
||||
newAssetRepositoryMock,
|
||||
newCommunicationRepositoryMock,
|
||||
newCryptoRepositoryMock,
|
||||
newJobRepositoryMock,
|
||||
newStorageRepositoryMock,
|
||||
|
@ -14,6 +15,7 @@ import {
|
|||
} from '@test';
|
||||
import { when } from 'jest-when';
|
||||
import { Readable } from 'stream';
|
||||
import { ICommunicationRepository } from '../communication';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { IJobRepository, JobItem, JobName } from '../job';
|
||||
import { IStorageRepository } from '../storage';
|
||||
|
@ -153,6 +155,7 @@ describe(AssetService.name, () => {
|
|||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
|
||||
it('should work', () => {
|
||||
|
@ -162,11 +165,12 @@ describe(AssetService.name, () => {
|
|||
beforeEach(async () => {
|
||||
accessMock = newAccessRepositoryMock();
|
||||
assetMock = newAssetRepositoryMock();
|
||||
communicationMock = newCommunicationRepositoryMock();
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
sut = new AssetService(accessMock, assetMock, cryptoMock, jobMock, configMock, storageMock);
|
||||
sut = new AssetService(accessMock, assetMock, cryptoMock, jobMock, configMock, storageMock, communicationMock);
|
||||
|
||||
when(assetMock.getById)
|
||||
.calledWith(assetStub.livePhotoStillAsset.id)
|
||||
|
|
|
@ -6,6 +6,7 @@ import { extname } from 'path';
|
|||
import sanitize from 'sanitize-filename';
|
||||
import { AccessCore, IAccessRepository, Permission } from '../access';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { CommunicationEvent, ICommunicationRepository } from '../communication';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { mimeTypes } from '../domain.constant';
|
||||
import { HumanReadableSize, usePagination } from '../domain.util';
|
||||
|
@ -72,6 +73,7 @@ export class AssetService {
|
|||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
||||
) {
|
||||
this.access = new AccessCore(accessRepository);
|
||||
this.storageCore = new StorageCore(storageRepository);
|
||||
|
@ -362,6 +364,7 @@ export class AssetService {
|
|||
|
||||
await this.assetRepository.remove(asset);
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } });
|
||||
this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, id);
|
||||
|
||||
// TODO refactor this to use cascades
|
||||
if (asset.livePhotoVideoId) {
|
||||
|
@ -392,6 +395,7 @@ export class AssetService {
|
|||
} else {
|
||||
await this.assetRepository.softDeleteAll(ids);
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids } });
|
||||
this.communicationRepository.send(CommunicationEvent.ASSET_TRASH, authUser.id, ids);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,10 @@ export const ICommunicationRepository = 'ICommunicationRepository';
|
|||
|
||||
export enum CommunicationEvent {
|
||||
UPLOAD_SUCCESS = 'on_upload_success',
|
||||
ASSET_DELETE = 'on_asset_delete',
|
||||
ASSET_TRASH = 'on_asset_trash',
|
||||
PERSON_THUMBNAIL = 'on_person_thumbnail',
|
||||
SERVER_VERSION = 'on_server_version',
|
||||
CONFIG_UPDATE = 'on_config_update',
|
||||
}
|
||||
|
||||
|
|
|
@ -6,10 +6,12 @@ import {
|
|||
newAssetRepositoryMock,
|
||||
newCommunicationRepositoryMock,
|
||||
newJobRepositoryMock,
|
||||
newPersonRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
} from '@test';
|
||||
import { IAssetRepository } from '../asset';
|
||||
import { ICommunicationRepository } from '../communication';
|
||||
import { IPersonRepository } from '../person';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { JobCommand, JobName, QueueName } from './job.constants';
|
||||
|
@ -30,13 +32,15 @@ describe(JobService.name, () => {
|
|||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let personMock: jest.Mocked<IPersonRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
communicationMock = newCommunicationRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
sut = new JobService(assetMock, communicationMock, jobMock, configMock);
|
||||
personMock = newPersonRepositoryMock();
|
||||
sut = new JobService(assetMock, communicationMock, jobMock, configMock, personMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
|
|
|
@ -2,6 +2,7 @@ import { AssetType } from '@app/infra/entities';
|
|||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { IAssetRepository, mapAsset } from '../asset';
|
||||
import { CommunicationEvent, ICommunicationRepository } from '../communication';
|
||||
import { IPersonRepository } from '../person';
|
||||
import { FeatureFlag, ISystemConfigRepository } from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { JobCommand, JobName, QueueName } from './job.constants';
|
||||
|
@ -18,6 +19,7 @@ export class JobService {
|
|||
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IPersonRepository) private personRepository: IPersonRepository,
|
||||
) {
|
||||
this.configCore = new SystemConfigCore(configRepository);
|
||||
}
|
||||
|
@ -172,15 +174,20 @@ export class JobService {
|
|||
}
|
||||
break;
|
||||
|
||||
case JobName.GENERATE_PERSON_THUMBNAIL:
|
||||
const { id } = item.data;
|
||||
const person = await this.personRepository.getById(id);
|
||||
if (person) {
|
||||
this.communicationRepository.send(CommunicationEvent.PERSON_THUMBNAIL, person.ownerId, id);
|
||||
}
|
||||
break;
|
||||
|
||||
case JobName.GENERATE_JPEG_THUMBNAIL: {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: item.data });
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBHASH_THUMBNAIL, data: item.data });
|
||||
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: item.data });
|
||||
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: item.data });
|
||||
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: item.data });
|
||||
if (item.data.source !== 'upload') {
|
||||
break;
|
||||
}
|
||||
|
||||
const [asset] = await this.assetRepository.getByIds([item.data.id]);
|
||||
if (asset) {
|
||||
|
@ -189,10 +196,20 @@ export class JobService {
|
|||
} else if (asset.livePhotoVideoId) {
|
||||
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } });
|
||||
}
|
||||
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case JobName.GENERATE_WEBP_THUMBNAIL: {
|
||||
if (item.data.source !== 'upload') {
|
||||
break;
|
||||
}
|
||||
|
||||
const [asset] = await this.assetRepository.getByIds([item.data.id]);
|
||||
if (asset) {
|
||||
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In addition to the above jobs, all of these should queue `SEARCH_INDEX_ASSET`
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
import { AuthService } from '@app/domain';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
|
||||
@WebSocketGateway({ cors: true })
|
||||
export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
private logger = new Logger(CommunicationGateway.name);
|
||||
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@WebSocketServer() server!: Server;
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
client.leave(client.nsp.name);
|
||||
this.logger.log(`Client ${client.id} disconnected from Websocket`);
|
||||
}
|
||||
|
||||
async handleConnection(client: Socket) {
|
||||
try {
|
||||
this.logger.log(`New websocket connection: ${client.id}`);
|
||||
const user = await this.authService.validate(client.request.headers, {});
|
||||
if (user) {
|
||||
client.join(user.id);
|
||||
} else {
|
||||
client.emit('error', 'unauthorized');
|
||||
client.disconnect();
|
||||
}
|
||||
} catch (e) {
|
||||
client.emit('error', 'unauthorized');
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -27,7 +27,6 @@ import { BullModule } from '@nestjs/bullmq';
|
|||
import { Global, Module, Provider } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CommunicationGateway } from './communication.gateway';
|
||||
import { databaseConfig } from './database.config';
|
||||
import { databaseEntities } from './entities';
|
||||
import { bullConfig, bullQueues } from './infra.config';
|
||||
|
@ -90,7 +89,7 @@ const providers: Provider[] = [
|
|||
BullModule.forRoot(bullConfig),
|
||||
BullModule.registerQueue(...bullQueues),
|
||||
],
|
||||
providers: [...providers, CommunicationGateway],
|
||||
providers: [...providers],
|
||||
exports: [...providers, BullModule],
|
||||
})
|
||||
export class InfraModule {}
|
||||
|
|
|
@ -1,16 +1,43 @@
|
|||
import { CommunicationEvent } from '@app/domain';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommunicationGateway } from '../communication.gateway';
|
||||
import { AuthService, CommunicationEvent, ICommunicationRepository, serverVersion } from '@app/domain';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
|
||||
@Injectable()
|
||||
export class CommunicationRepository {
|
||||
constructor(private ws: CommunicationGateway) {}
|
||||
@WebSocketGateway({ cors: true })
|
||||
export class CommunicationRepository implements OnGatewayConnection, OnGatewayDisconnect, ICommunicationRepository {
|
||||
private logger = new Logger(CommunicationRepository.name);
|
||||
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@WebSocketServer() server!: Server;
|
||||
|
||||
async handleConnection(client: Socket) {
|
||||
try {
|
||||
this.logger.log(`New websocket connection: ${client.id}`);
|
||||
const user = await this.authService.validate(client.request.headers, {});
|
||||
if (user) {
|
||||
client.join(user.id);
|
||||
this.send(CommunicationEvent.SERVER_VERSION, user.id, serverVersion);
|
||||
} else {
|
||||
client.emit('error', 'unauthorized');
|
||||
client.disconnect();
|
||||
}
|
||||
} catch (e) {
|
||||
client.emit('error', 'unauthorized');
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
client.leave(client.nsp.name);
|
||||
this.logger.log(`Client ${client.id} disconnected from Websocket`);
|
||||
}
|
||||
|
||||
send(event: CommunicationEvent, userId: string, data: any) {
|
||||
this.ws.server.to(userId).emit(event, JSON.stringify(data));
|
||||
this.server.to(userId).emit(event, JSON.stringify(data));
|
||||
}
|
||||
|
||||
broadcast(event: CommunicationEvent, data: any) {
|
||||
this.ws.server.emit(event, data);
|
||||
this.server.emit(event, data);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
import type { AssetStore } from '$lib/stores/assets.store';
|
||||
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
||||
import type { Viewport } from '$lib/stores/assets.store';
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
export let assets: AssetResponseDto[];
|
||||
export let bucketDate: string;
|
||||
|
@ -176,6 +177,7 @@
|
|||
<div
|
||||
class="absolute"
|
||||
style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px"
|
||||
animate:flip={{ duration: 350 }}
|
||||
>
|
||||
<Thumbnail
|
||||
{asset}
|
||||
|
|
|
@ -44,6 +44,7 @@
|
|||
onMount(async () => {
|
||||
showSkeleton = false;
|
||||
document.addEventListener('keydown', onKeyboardPress);
|
||||
assetStore.connect();
|
||||
await assetStore.init(viewport);
|
||||
});
|
||||
|
||||
|
@ -55,6 +56,8 @@
|
|||
if ($showAssetViewer) {
|
||||
$showAssetViewer = false;
|
||||
}
|
||||
|
||||
assetStore.disconnect();
|
||||
});
|
||||
|
||||
const handleKeyboardPress = (event: KeyboardEvent) => {
|
||||
|
|
|
@ -1,52 +1,40 @@
|
|||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { websocketStore } from '$lib/stores/websocket';
|
||||
import { ServerInfoResponseDto, api } from '@api';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import Cloud from 'svelte-material-icons/Cloud.svelte';
|
||||
import Dns from 'svelte-material-icons/Dns.svelte';
|
||||
import LoadingSpinner from './loading-spinner.svelte';
|
||||
import { api, ServerInfoResponseDto } from '@api';
|
||||
import { asByteUnitString } from '../../utils/byte-units';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import LoadingSpinner from './loading-spinner.svelte';
|
||||
|
||||
const { serverVersion, connected } = websocketStore;
|
||||
|
||||
let isServerOk = true;
|
||||
let serverVersion = '';
|
||||
let serverInfo: ServerInfoResponseDto;
|
||||
let pingServerInterval: NodeJS.Timer;
|
||||
|
||||
$: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null;
|
||||
$: usedPercentage = Math.round((serverInfo?.diskUseRaw / serverInfo?.diskSizeRaw) * 100);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const { data: version } = await api.serverInfoApi.getServerVersion();
|
||||
|
||||
serverVersion = `v${version.major}.${version.minor}.${version.patch}`;
|
||||
|
||||
const { data: serverInfoRes } = await api.serverInfoApi.getServerInfo();
|
||||
serverInfo = serverInfoRes;
|
||||
getStorageUsagePercentage();
|
||||
} catch (e) {
|
||||
console.log('Error [StatusBox] [onMount]');
|
||||
isServerOk = false;
|
||||
}
|
||||
|
||||
pingServerInterval = setInterval(async () => {
|
||||
try {
|
||||
const { data: pingReponse } = await api.serverInfoApi.pingServer();
|
||||
|
||||
if (pingReponse.res === 'pong') isServerOk = true;
|
||||
else isServerOk = false;
|
||||
|
||||
const { data: serverInfoRes } = await api.serverInfoApi.getServerInfo();
|
||||
serverInfo = serverInfoRes;
|
||||
} catch (e) {
|
||||
console.log('Error [StatusBox] [pingServerInterval]', e);
|
||||
isServerOk = false;
|
||||
}
|
||||
}, 10000);
|
||||
await refresh();
|
||||
});
|
||||
|
||||
onDestroy(() => clearInterval(pingServerInterval));
|
||||
|
||||
const getStorageUsagePercentage = () => {
|
||||
return Math.round((serverInfo?.diskUseRaw / serverInfo?.diskSizeRaw) * 100);
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const { data } = await api.serverInfoApi.getServerInfo();
|
||||
serverInfo = data;
|
||||
} catch (e) {
|
||||
console.log('Error [StatusBox] [onMount]');
|
||||
}
|
||||
};
|
||||
|
||||
let interval: number;
|
||||
if (browser) {
|
||||
interval = window.setInterval(() => refresh(), 10_000);
|
||||
}
|
||||
|
||||
onDestroy(() => clearInterval(interval));
|
||||
</script>
|
||||
|
||||
<div class="dark:text-immich-dark-fg">
|
||||
|
@ -61,7 +49,7 @@
|
|||
<!-- style={`width: ${$downloadAssets[fileName]}%`} -->
|
||||
<div
|
||||
class="h-[7px] rounded-full bg-immich-primary dark:bg-immich-dark-primary"
|
||||
style="width: {getStorageUsagePercentage()}%"
|
||||
style="width: {usedPercentage}%"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs">
|
||||
|
@ -88,7 +76,7 @@
|
|||
<div class="mt-2 flex justify-between justify-items-center">
|
||||
<p>Status</p>
|
||||
|
||||
{#if isServerOk}
|
||||
{#if $connected}
|
||||
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">Online</p>
|
||||
{:else}
|
||||
<p class="font-medium text-red-500">Offline</p>
|
||||
|
@ -97,20 +85,18 @@
|
|||
|
||||
<div class="mt-2 flex justify-between justify-items-center">
|
||||
<p>Version</p>
|
||||
{#if $connected && version}
|
||||
<a
|
||||
href="https://github.com/immich-app/immich/releases"
|
||||
class="font-medium text-immich-primary dark:text-immich-dark-primary"
|
||||
target="_blank"
|
||||
>
|
||||
{serverVersion}
|
||||
{version}
|
||||
</a>
|
||||
{:else}
|
||||
<p class="font-medium text-red-500">Unknown</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div>
|
||||
<hr class="ml-5 my-4" />
|
||||
</div>
|
||||
<button class="text-xs ml-5 underline hover:cursor-pointer text-immich-primary" on:click={() => goto('/changelog')}
|
||||
>Changelog</button
|
||||
> -->
|
||||
</div>
|
||||
|
|
|
@ -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') + ', refresh the page to see new upload assets.',
|
||||
type: $errorCounter > 0 ? NotificationType.Warning : NotificationType.Info,
|
||||
});
|
||||
|
||||
if ($duplicateCounter > 0) {
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import { api, AssetApiGetTimeBucketsRequest, AssetResponseDto } from '@api';
|
||||
import { writable } from 'svelte/store';
|
||||
import { throttle } 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',
|
||||
|
@ -34,11 +37,33 @@ 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 DeleteAsset {
|
||||
type: 'delete';
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface TrashAsset {
|
||||
type: 'trash';
|
||||
value: string;
|
||||
}
|
||||
|
||||
type PendingChange = AddAsset | DeleteAsset | TrashAsset;
|
||||
|
||||
export class AssetStore {
|
||||
private store$ = writable(this);
|
||||
private assetToBucket: Record<string, AssetLookup> = {};
|
||||
private pendingChanges: PendingChange[] = [];
|
||||
private unsubscribers: Unsubscriber[] = [];
|
||||
|
||||
initialized = false;
|
||||
timelineHeight = 0;
|
||||
|
@ -52,6 +77,63 @@ export class AssetStore {
|
|||
|
||||
subscribe = this.store$.subscribe;
|
||||
|
||||
connect() {
|
||||
this.unsubscribers.push(
|
||||
websocketStore.onUploadSuccess.subscribe((value) => {
|
||||
if (value) {
|
||||
this.pendingChanges.push({ type: 'add', value });
|
||||
this.processPendingChanges();
|
||||
}
|
||||
}),
|
||||
|
||||
websocketStore.onAssetTrash.subscribe((ids) => {
|
||||
console.log('onAssetTrash', ids);
|
||||
if (ids) {
|
||||
for (const id of ids) {
|
||||
this.pendingChanges.push({ type: 'trash', value: id });
|
||||
}
|
||||
this.processPendingChanges();
|
||||
}
|
||||
}),
|
||||
|
||||
websocketStore.onAssetDelete.subscribe((value) => {
|
||||
if (value) {
|
||||
this.pendingChanges.push({ type: 'delete', value });
|
||||
this.processPendingChanges();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
for (const unsubscribe of this.unsubscribers) {
|
||||
unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
processPendingChanges = throttle(() => {
|
||||
for (const { type, value } of this.pendingChanges) {
|
||||
switch (type) {
|
||||
case 'add':
|
||||
this.addAsset(value);
|
||||
break;
|
||||
|
||||
case 'trash':
|
||||
if (!this.options.isTrashed) {
|
||||
this.removeAsset(value);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
this.removeAsset(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.pendingChanges = [];
|
||||
this.emit(true);
|
||||
}, 10_000);
|
||||
|
||||
async init(viewport: Viewport) {
|
||||
this.initialized = false;
|
||||
this.timelineHeight = 0;
|
||||
|
@ -168,6 +250,46 @@ export class AssetStore {
|
|||
return scrollTimeline ? delta : 0;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const timeBucket = DateTime.fromISO(asset.fileCreatedAt).toUTC().startOf('month').toString();
|
||||
let bucket = this.getBucketByDate(timeBucket);
|
||||
|
||||
if (!bucket) {
|
||||
bucket = {
|
||||
bucketDate: timeBucket,
|
||||
bucketHeight: THUMBNAIL_HEIGHT,
|
||||
assets: [],
|
||||
cancelToken: null,
|
||||
position: BucketPosition.Unknown,
|
||||
};
|
||||
|
||||
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 {
|
||||
return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,19 @@
|
|||
import { io, Socket } from 'socket.io-client';
|
||||
import type { AssetResponseDto, ServerVersionResponseDto } from '@api';
|
||||
import { io } from 'socket.io-client';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
let websocket: Socket;
|
||||
export const websocketStore = {
|
||||
onUploadSuccess: writable<AssetResponseDto>(),
|
||||
onAssetDelete: writable<string>(),
|
||||
onAssetTrash: writable<string[]>(),
|
||||
onPersonThumbnail: writable<string>(),
|
||||
serverVersion: writable<ServerVersionResponseDto>(),
|
||||
connected: writable<boolean>(false),
|
||||
};
|
||||
|
||||
export const openWebsocketConnection = () => {
|
||||
try {
|
||||
websocket = io('', {
|
||||
const websocket = io('', {
|
||||
path: '/api/socket.io',
|
||||
transports: ['polling'],
|
||||
reconnection: true,
|
||||
|
@ -12,21 +21,18 @@ export const openWebsocketConnection = () => {
|
|||
autoConnect: true,
|
||||
});
|
||||
|
||||
listenToEvent(websocket);
|
||||
websocket
|
||||
.on('connect', () => websocketStore.connected.set(true))
|
||||
.on('disconnect', () => websocketStore.connected.set(false))
|
||||
// .on('on_upload_success', (data) => websocketStore.onUploadSuccess.set(JSON.parse(data) as AssetResponseDto))
|
||||
.on('on_asset_delete', (data) => websocketStore.onAssetDelete.set(JSON.parse(data) as string))
|
||||
.on('on_asset_trash', (data) => websocketStore.onAssetTrash.set(JSON.parse(data) as string[]))
|
||||
.on('on_person_thumbnail', (data) => websocketStore.onPersonThumbnail.set(JSON.parse(data) as string))
|
||||
.on('on_server_version', (data) => websocketStore.serverVersion.set(JSON.parse(data) as ServerVersionResponseDto))
|
||||
.on('error', (e) => console.log('Websocket Error', e));
|
||||
|
||||
return () => websocket?.close();
|
||||
} catch (e) {
|
||||
console.log('Cannot connect to websocket ', e);
|
||||
}
|
||||
};
|
||||
|
||||
const listenToEvent = (socket: Socket) => {
|
||||
//TODO: if we are not using this, we should probably remove it?
|
||||
socket.on('on_upload_success', () => undefined);
|
||||
|
||||
socket.on('error', (e) => {
|
||||
console.log('Websocket Error', e);
|
||||
});
|
||||
};
|
||||
|
||||
export const closeWebsocketConnection = () => {
|
||||
websocket?.close();
|
||||
};
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
import { AppRoute } 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 { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetResponseDto, PersonResponseDto, TimeBucketSize, api } from '@api';
|
||||
import { onMount } from 'svelte';
|
||||
|
@ -54,6 +55,7 @@
|
|||
});
|
||||
const assetInteractionStore = createAssetInteractionStore();
|
||||
const { selectedAssets, isMultiSelectState } = assetInteractionStore;
|
||||
const { onPersonThumbnail } = websocketStore;
|
||||
|
||||
let viewMode: ViewMode = ViewMode.VIEW_ASSETS;
|
||||
let isEditingName = false;
|
||||
|
@ -65,12 +67,15 @@
|
|||
let potentialMergePeople: PersonResponseDto[] = [];
|
||||
|
||||
let personName = '';
|
||||
let thumbnailData = api.getPeopleThumbnailUrl(data.person.id);
|
||||
|
||||
let name: string = data.person.name;
|
||||
let suggestedPeople: PersonResponseDto[] = [];
|
||||
|
||||
$: isAllArchive = Array.from($selectedAssets).every((asset) => asset.isArchived);
|
||||
$: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite);
|
||||
$: $onPersonThumbnail === data.person.id &&
|
||||
(thumbnailData = api.getPeopleThumbnailUrl(data.person.id) + `?now=${Date.now()}`);
|
||||
|
||||
$: {
|
||||
suggestedPeople = !name
|
||||
|
@ -141,14 +146,8 @@
|
|||
|
||||
await api.personApi.updatePerson({ id: data.person.id, personUpdateDto: { featureFaceAssetId: asset.id } });
|
||||
|
||||
// TODO: Replace by Websocket in the future
|
||||
notificationController.show({
|
||||
message: 'Feature photo updated, refresh page to see changes',
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
|
||||
notificationController.show({ message: 'Feature photo updated', type: NotificationType.Info });
|
||||
assetInteractionStore.clearMultiselect();
|
||||
// scroll to top
|
||||
|
||||
viewMode = ViewMode.VIEW_ASSETS;
|
||||
};
|
||||
|
@ -376,7 +375,7 @@
|
|||
<ImageThumbnail
|
||||
circle
|
||||
shadow
|
||||
url={api.getPeopleThumbnailUrl(data.person.id)}
|
||||
url={thumbnailData}
|
||||
altText={data.person.name}
|
||||
widthStyle="3.375rem"
|
||||
heightStyle="3.375rem"
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
import { api } from '@api';
|
||||
import { openWebsocketConnection } from '$lib/stores/websocket';
|
||||
|
||||
let showNavigationLoadingBar = false;
|
||||
export let data: LayoutData;
|
||||
|
@ -36,6 +37,8 @@
|
|||
});
|
||||
|
||||
onMount(async () => {
|
||||
openWebsocketConnection();
|
||||
|
||||
try {
|
||||
await loadConfig();
|
||||
} catch (error) {
|
||||
|
|
Loading…
Reference in a new issue