feat(server,web): add websocket events for album updates

and person face changes
This commit is contained in:
Min Idzelis
2025-06-11 10:52:43 +00:00
parent c03e72c1da
commit b3d080f6e8
9 changed files with 272 additions and 70 deletions

View File

@@ -13,7 +13,8 @@ export function updateObject(target: any, source: any): boolean {
}
const isDate = target[key] instanceof Date;
if (typeof target[key] === 'object' && !isDate) {
updated = updated || updateObject(target[key], source[key]);
const updatedChild = updateObject(target[key], source[key]);
updated = updated || updatedChild;
} else {
if (target[key] !== source[key]) {
target[key] = source[key];

View File

@@ -1,85 +1,219 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { PendingChange, TimelineAsset } from '$lib/managers/timeline-manager/types';
import { websocketEvents } from '$lib/stores/websocket';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { throttle } from 'lodash-es';
import { getAllAlbums, getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import type { Unsubscriber } from 'svelte/store';
const PROCESS_DELAY_MS = 2500;
export class WebsocketSupport {
#pendingChanges: PendingChange[] = [];
readonly #timelineManager: TimelineManager;
#unsubscribers: Unsubscriber[] = [];
#timelineManager: TimelineManager;
#processPendingChanges = throttle(() => {
const { add, update, remove } = this.#getPendingChangeBatches();
if (add.length > 0) {
this.#timelineManager.addAssets(add);
}
if (update.length > 0) {
this.#timelineManager.updateAssets(update);
}
if (remove.length > 0) {
this.#timelineManager.removeAssets(remove);
}
this.#pendingChanges = [];
}, 2500);
#pendingUpdates: {
updated: AssetResponseDto[];
trashed: string[];
deleted: string[];
personed: { assetId: string; personId: string | undefined; status: 'created' | 'removed' | 'removed_soft' }[];
album: { albumId: string; assetId: string[]; status: 'added' | 'removed' }[];
} = {
updated: [],
trashed: [],
deleted: [],
personed: [],
album: [],
};
#pendingCount() {
return (
this.#pendingUpdates.updated.length +
this.#pendingUpdates.trashed.length +
this.#pendingUpdates.deleted.length +
this.#pendingUpdates.personed.length +
this.#pendingUpdates.album.length
);
}
#processTimeoutId: ReturnType<typeof setTimeout> | undefined;
#isProcessing = false;
constructor(timeineManager: TimelineManager) {
this.#timelineManager = timeineManager;
constructor(timelineManager: TimelineManager) {
this.#timelineManager = timelineManager;
}
connectWebsocketEvents() {
this.#unsubscribers.push(
websocketEvents.on('on_upload_success', (asset) =>
this.#addPendingChanges({ type: 'add', values: [toTimelineAsset(asset)] }),
),
websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })),
websocketEvents.on('on_asset_update', (asset) =>
this.#addPendingChanges({ type: 'update', values: [toTimelineAsset(asset)] }),
),
websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })),
websocketEvents.on('on_asset_trash', (ids) => {
this.#pendingUpdates.trashed.push(...ids);
this.#scheduleProcessing();
}),
websocketEvents.on('on_asset_person', (data) => {
this.#pendingUpdates.personed.push(data);
this.#scheduleProcessing();
}),
// uploads and tagging are handled by this event
websocketEvents.on('on_asset_update', (asset) => {
this.#pendingUpdates.updated.push(asset);
this.#scheduleProcessing();
}),
websocketEvents.on('on_album_update', (data) => {
this.#pendingUpdates.album.push(data);
this.#scheduleProcessing();
}),
websocketEvents.on('on_asset_trash', (ids) => {
this.#pendingUpdates.trashed.push(...ids);
this.#scheduleProcessing();
}),
websocketEvents.on('on_asset_delete', (ids) => {
this.#pendingUpdates.deleted.push(ids);
this.#scheduleProcessing();
}),
);
}
disconnectWebsocketEvents() {
this.#cleanup();
}
#cleanup() {
for (const unsubscribe of this.#unsubscribers) {
unsubscribe();
}
this.#unsubscribers = [];
this.#cancelScheduledProcessing();
}
#addPendingChanges(...changes: PendingChange[]) {
this.#pendingChanges.push(...changes);
this.#processPendingChanges();
#cancelScheduledProcessing() {
if (this.#processTimeoutId) {
clearTimeout(this.#processTimeoutId);
this.#processTimeoutId = undefined;
}
}
#getPendingChangeBatches() {
const batch: {
add: TimelineAsset[];
update: TimelineAsset[];
remove: string[];
} = {
add: [],
update: [],
remove: [],
#scheduleProcessing() {
if (this.#processTimeoutId) {
return;
}
this.#processTimeoutId = setTimeout(() => {
this.#processTimeoutId = undefined;
void this.#processPendingChanges();
}, PROCESS_DELAY_MS);
}
async #processPendingChanges() {
if (this.#isProcessing || this.#pendingCount() === 0) {
return;
}
this.#isProcessing = true;
try {
await this.#process();
} finally {
this.#isProcessing = false;
if (this.#pendingCount() > 0) {
this.#scheduleProcessing();
}
}
}
async #process() {
const pendingUpdates = this.#pendingUpdates;
this.#pendingUpdates = {
updated: [],
trashed: [],
deleted: [],
personed: [],
album: [],
};
for (const { type, values } of this.#pendingChanges) {
switch (type) {
case 'add': {
batch.add.push(...values);
break;
}
case 'update': {
batch.update.push(...values);
break;
}
case 'delete':
case 'trash': {
batch.remove.push(...values);
break;
await this.#handleUpdatedAssets(pendingUpdates.updated);
await this.#handleUpdatedAssetsPerson(pendingUpdates.personed);
await this.#handleUpdatedAssetsAlbum(pendingUpdates.album);
await this.#handleUpdatedAssetsTrashed(pendingUpdates.trashed);
this.#timelineManager.removeAssets(pendingUpdates.deleted);
}
async #handleUpdatedAssets(assets: AssetResponseDto[]) {
const prefilteredAssets = assets.filter((asset) => !this.#timelineManager.isExcluded(toTimelineAsset(asset)));
if (!this.#timelineManager.options.albumId) {
// also check tags
if (!this.#timelineManager.options.tagId) {
return this.#timelineManager.addAssets(prefilteredAssets.map((asset) => toTimelineAsset(asset)));
}
for (const asset of prefilteredAssets) {
if (asset.tags?.some((tag) => tag.id === this.#timelineManager.options.tagId)) {
this.#timelineManager.addAssets([toTimelineAsset(asset)]);
} else {
this.#timelineManager.removeAssets([asset.id]);
}
}
}
return batch;
const matchingAssets = [];
for (const asset of prefilteredAssets) {
const albums = await getAllAlbums({ assetId: asset.id });
if (albums.some((album) => album.id === this.#timelineManager.options.albumId)) {
if (this.#timelineManager.options.tagId) {
if (asset.tags?.some((tag) => tag.id === this.#timelineManager.options.tagId)) {
matchingAssets.push(asset);
} else {
this.#timelineManager.removeAssets([asset.id]);
}
} else {
matchingAssets.push(asset);
}
}
}
return this.#timelineManager.addAssets(matchingAssets.map((asset) => toTimelineAsset(asset)));
}
async #handleUpdatedAssetsPerson(
data: { assetId: string; personId: string | undefined; status: 'created' | 'removed' | 'removed_soft' }[],
) {
if (!this.#timelineManager.options.personId) {
for (const { assetId } of data) {
const asset = await getAssetInfo({ id: assetId, key: authManager.key });
this.#timelineManager.addAssets([toTimelineAsset(asset)]);
}
return;
}
for (const { assetId, personId, status } of data) {
if (status === 'created') {
if (personId !== this.#timelineManager.options.personId) {
continue;
}
const asset = await getAssetInfo({ id: assetId, key: authManager.key });
this.#timelineManager.addAssets([toTimelineAsset(asset)]);
} else if (personId === this.#timelineManager.options.personId) {
this.#timelineManager.removeAssets([assetId]);
}
}
}
async #handleUpdatedAssetsAlbum(data: { albumId: string; assetId: string[]; status: 'added' | 'removed' }[]) {
if (!this.#timelineManager.options.albumId) {
return;
}
for (const { albumId, assetId, status } of data) {
if (albumId !== this.#timelineManager.options.albumId) {
continue;
}
if (status === 'added') {
const assets = await Promise.all(assetId.map((id) => getAssetInfo({ id, key: authManager.key })));
this.#timelineManager.addAssets(assets.map((element) => toTimelineAsset(element)));
} else if (status === 'removed') {
this.#timelineManager.removeAssets(assetId);
}
}
}
async #handleUpdatedAssetsTrashed(trashed: string[]) {
if (this.#timelineManager.options.isTrashed === undefined) {
return;
}
if (this.#timelineManager.options.isTrashed) {
const assets = await Promise.all(trashed.map((id) => getAssetInfo({ id, key: authManager.key })));
this.#timelineManager.addAssets(assets.map((element) => toTimelineAsset(element)));
} else {
this.#timelineManager.removeAssets(trashed);
}
}
}

View File

@@ -59,9 +59,6 @@ export class TimelineManager {
initTask = new CancellableTask(
() => {
this.isInitialized = true;
if (this.#options.albumId || this.#options.personId) {
return;
}
this.connect();
},
() => {
@@ -189,6 +186,10 @@ export class TimelineManager {
return this.#viewportHeight;
}
get options() {
return { ...this.#options };
}
async *assetsIterator(options?: {
startMonthGroup?: MonthGroup;
startDayGroup?: DayGroup;

View File

@@ -16,6 +16,15 @@ export interface ReleaseEvent {
export interface Events {
on_upload_success: (asset: AssetResponseDto) => void;
on_user_delete: (id: string) => void;
on_album_update: (data: { albumId: string; assetId: string[]; status: 'added' | 'removed' }) => void;
on_asset_person: ({
assetId,
personId,
}: {
assetId: string;
personId: string | undefined;
status: 'created' | 'removed' | 'removed_soft';
}) => void;
on_asset_delete: (assetId: string) => void;
on_asset_trash: (assetIds: string[]) => void;
on_asset_update: (asset: AssetResponseDto) => void;