From 6b87efe7a35f364d61b949a136a2ddecf2a1dc13 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Sun, 15 Jun 2025 02:25:18 +0000 Subject: [PATCH] feat(web): improve websocket filtering and add restored assets support - Refactor websocket support to use modular filter functions - Add support for on_asset_restore events - Improve handling of asset updates with proper filtering for visibility, favorites, trash, tags, albums, and persons - Add null checks in timeline manager for empty arrays --- .../internal/websocket-support.svelte.ts | 256 ++++++++++++------ .../timeline-manager.svelte.ts | 6 + 2 files changed, 178 insertions(+), 84 deletions(-) diff --git a/web/src/lib/managers/timeline-manager/internal/websocket-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/websocket-support.svelte.ts index 05a6b40d7f..9b7023f408 100644 --- a/web/src/lib/managers/timeline-manager/internal/websocket-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/websocket-support.svelte.ts @@ -7,27 +7,84 @@ import type { Unsubscriber } from 'svelte/store'; const PROCESS_DELAY_MS = 2500; +const fetchAssetInfos = async (assetIds: string[]) => { + return await Promise.all(assetIds.map((id) => getAssetInfo({ id, key: authManager.key }))); +}; + +export type AssetFilter = ( + asset: Awaited>, + timelineManager: TimelineManager, +) => Promise | boolean; + +// Filter functions +const checkVisibilityProperty: AssetFilter = (asset, timelineManager) => { + const timelineAsset = toTimelineAsset(asset); + return ( + timelineManager.options.visibility === undefined || timelineManager.options.visibility === timelineAsset.visibility + ); +}; + +const checkFavoriteProperty: AssetFilter = (asset, timelineManager) => { + const timelineAsset = toTimelineAsset(asset); + return ( + timelineManager.options.isFavorite === undefined || timelineManager.options.isFavorite === timelineAsset.isFavorite + ); +}; + +const checkTrashedProperty: AssetFilter = (asset, timelineManager) => { + const timelineAsset = toTimelineAsset(asset); + return ( + timelineManager.options.isTrashed === undefined || timelineManager.options.isTrashed === timelineAsset.isTrashed + ); +}; + +const checkTagProperty: AssetFilter = (asset, timelineManager) => { + if (!timelineManager.options.tagId) { + return true; + } + const hasMatchingTag = asset.tags?.some((tag: { id: string }) => tag.id === timelineManager.options.tagId); + return !!hasMatchingTag; +}; + +const checkAlbumProperty: AssetFilter = async (asset, timelineManager) => { + if (!timelineManager.options.albumId) { + return true; + } + const albums = await getAllAlbums({ assetId: asset.id }); + return albums.some((album) => album.id === timelineManager.options.albumId); +}; + +const checkPersonProperty: AssetFilter = (asset, timelineManager) => { + if (!timelineManager.options.personId) { + return true; + } + const hasMatchingPerson = asset.people?.some( + (person: { id: string }) => person.id === timelineManager.options.personId, + ); + return !!hasMatchingPerson; +}; + export class WebsocketSupport { readonly #timelineManager: TimelineManager; #unsubscribers: Unsubscriber[] = []; #pendingUpdates: { - updated: AssetResponseDto[]; + updated: string[]; trashed: string[]; + restored: 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: [], }; + /** + * Count of pending updates across all categories. + * This is used to determine if there are any updates to process. + */ #pendingCount() { return ( this.#pendingUpdates.updated.length + this.#pendingUpdates.trashed.length + + this.#pendingUpdates.restored.length + this.#pendingUpdates.deleted.length + this.#pendingUpdates.personed.length + this.#pendingUpdates.album.length @@ -37,24 +94,38 @@ export class WebsocketSupport { #isProcessing = false; constructor(timelineManager: TimelineManager) { + this.#pendingUpdates = this.#init(); this.#timelineManager = timelineManager; } + #init() { + return { + updated: [], + trashed: [], + restored: [], + deleted: [], + personed: [], + album: [], + }; + } + connectWebsocketEvents() { this.#unsubscribers.push( websocketEvents.on('on_asset_trash', (ids) => { this.#pendingUpdates.trashed.push(...ids); this.#scheduleProcessing(); }), + // this event is called when an person is added or removed from an asset 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); + websocketEvents.on('on_asset_update', (ids) => { + this.#pendingUpdates.updated.push(...ids); this.#scheduleProcessing(); }), + // this event is called when an asseted is added or removed from an album websocketEvents.on('on_album_update', (data) => { this.#pendingUpdates.album.push(data); this.#scheduleProcessing(); @@ -67,6 +138,10 @@ export class WebsocketSupport { this.#pendingUpdates.deleted.push(ids); this.#scheduleProcessing(); }), + websocketEvents.on('on_asset_restore', (ids) => { + this.#pendingUpdates.restored.push(...ids); + this.#scheduleProcessing(); + }), ); } @@ -120,100 +195,113 @@ export class WebsocketSupport { async #process() { const pendingUpdates = this.#pendingUpdates; - this.#pendingUpdates = { - updated: [], - trashed: [], - deleted: [], - personed: [], - album: [], - }; - await this.#handleUpdatedAssets(pendingUpdates.updated); + this.#pendingUpdates = this.#init(); + + await this.#handleGeneric( + [...pendingUpdates.updated, ...pendingUpdates.trashed, ...pendingUpdates.restored], + [checkVisibilityProperty, checkFavoriteProperty, checkTrashedProperty, checkTagProperty, checkAlbumProperty], + ); + 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]); - } + async #handleGeneric(assetIds: string[], filters: AssetFilter[]) { + if (assetIds.length === 0) { + return; + } + + const assets = await fetchAssetInfos(assetIds); + const assetsToAdd = []; + const assetsToRemove = []; + + for (const asset of assets) { + if (await this.#shouldAssetBeIncluded(asset, filters)) { + assetsToAdd.push(asset); + } else { + assetsToRemove.push(asset.id); } } - 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); - } + + this.#timelineManager.addAssets(assetsToAdd.map((asset) => toTimelineAsset(asset))); + this.#timelineManager.removeAssets(assetsToRemove); + } + + async #shouldAssetBeIncluded(asset: AssetResponseDto, filters: AssetFilter[]): Promise { + for (const filter of filters) { + const result = await filter(asset, this.#timelineManager); + if (!result) { + return false; } } - return this.#timelineManager.addAssets(matchingAssets.map((asset) => toTimelineAsset(asset))); + return true; } 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)]); + const assetsToRemove: string[] = []; + const personAssetsToAdd: string[] = []; + + if (this.#timelineManager.options.personId === undefined) { + // If no person filter, we just add all assets with a person change + personAssetsToAdd.push(...data.map((d) => d.assetId)); + } else { + for (const { assetId, personId, status } of data) { + if (status === 'created' && personId === this.#timelineManager.options.personId) { + personAssetsToAdd.push(assetId); + } else if ( + (status === 'removed' || status === 'removed_soft') && + personId === this.#timelineManager.options.personId + ) { + assetsToRemove.push(assetId); + } } - return; } - for (const { assetId, personId, status } of data) { - if (status === 'created') { - if (personId !== this.#timelineManager.options.personId) { + + this.#timelineManager.removeAssets(assetsToRemove); + // At this point, personAssetsToAdd contains assets that now have the target person, + // but we need to check if they still match other filters. + await this.#handleGeneric(personAssetsToAdd, [ + checkVisibilityProperty, + checkFavoriteProperty, + checkTrashedProperty, + checkTagProperty, + checkAlbumProperty, + ]); + } + + async #handleUpdatedAssetsAlbum(data: { albumId: string; assetId: string[]; status: 'added' | 'removed' }[]) { + const assetsToAdd: string[] = []; + const assetsToRemove: string[] = []; + + if (this.#timelineManager.options.albumId === undefined) { + // If no album filter, we just add all assets with an album change + assetsToAdd.push(...data.flatMap((d) => d.assetId)); + } else { + for (const { albumId, assetId, status } of data) { + if (albumId !== this.#timelineManager.options.albumId) { 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]); + if (status === 'added') { + assetsToAdd.push(...assetId); + } else if (status === 'removed') { + assetsToRemove.push(...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); - } + + this.#timelineManager.removeAssets(assetsToRemove); + // At this point, assetsToAdd contains assets that now have the target person, + // but we need to check if they still match other filters. + await this.#handleGeneric(assetsToAdd, [ + checkVisibilityProperty, + checkFavoriteProperty, + checkTrashedProperty, + checkTagProperty, + checkPersonProperty, + ]); } } diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index cdad961d3f..124b600270 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -411,6 +411,9 @@ export class TimelineManager { } addAssets(assets: TimelineAsset[]) { + if (assets.length === 0) { + return; + } const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset)); const notUpdated = this.updateAssets(assetsToUpdate); addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc }); @@ -475,6 +478,9 @@ export class TimelineManager { } removeAssets(ids: string[]) { + if (ids.length === 0) { + return []; + } const { unprocessedIds } = runAssetOperation( this, new Set(ids),