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
This commit is contained in:
Min Idzelis
2025-06-15 02:25:18 +00:00
parent 7b75da1f10
commit 6b87efe7a3
2 changed files with 178 additions and 84 deletions
@@ -7,27 +7,84 @@ import type { Unsubscriber } from 'svelte/store';
const PROCESS_DELAY_MS = 2500; 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<ReturnType<typeof getAssetInfo>>,
timelineManager: TimelineManager,
) => Promise<boolean> | 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 { export class WebsocketSupport {
readonly #timelineManager: TimelineManager; readonly #timelineManager: TimelineManager;
#unsubscribers: Unsubscriber[] = []; #unsubscribers: Unsubscriber[] = [];
#pendingUpdates: { #pendingUpdates: {
updated: AssetResponseDto[]; updated: string[];
trashed: string[]; trashed: string[];
restored: string[];
deleted: string[]; deleted: string[];
personed: { assetId: string; personId: string | undefined; status: 'created' | 'removed' | 'removed_soft' }[]; personed: { assetId: string; personId: string | undefined; status: 'created' | 'removed' | 'removed_soft' }[];
album: { albumId: string; assetId: string[]; status: 'added' | 'removed' }[]; 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() { #pendingCount() {
return ( return (
this.#pendingUpdates.updated.length + this.#pendingUpdates.updated.length +
this.#pendingUpdates.trashed.length + this.#pendingUpdates.trashed.length +
this.#pendingUpdates.restored.length +
this.#pendingUpdates.deleted.length + this.#pendingUpdates.deleted.length +
this.#pendingUpdates.personed.length + this.#pendingUpdates.personed.length +
this.#pendingUpdates.album.length this.#pendingUpdates.album.length
@@ -37,24 +94,38 @@ export class WebsocketSupport {
#isProcessing = false; #isProcessing = false;
constructor(timelineManager: TimelineManager) { constructor(timelineManager: TimelineManager) {
this.#pendingUpdates = this.#init();
this.#timelineManager = timelineManager; this.#timelineManager = timelineManager;
} }
#init() {
return {
updated: [],
trashed: [],
restored: [],
deleted: [],
personed: [],
album: [],
};
}
connectWebsocketEvents() { connectWebsocketEvents() {
this.#unsubscribers.push( this.#unsubscribers.push(
websocketEvents.on('on_asset_trash', (ids) => { websocketEvents.on('on_asset_trash', (ids) => {
this.#pendingUpdates.trashed.push(...ids); this.#pendingUpdates.trashed.push(...ids);
this.#scheduleProcessing(); this.#scheduleProcessing();
}), }),
// this event is called when an person is added or removed from an asset
websocketEvents.on('on_asset_person', (data) => { websocketEvents.on('on_asset_person', (data) => {
this.#pendingUpdates.personed.push(data); this.#pendingUpdates.personed.push(data);
this.#scheduleProcessing(); this.#scheduleProcessing();
}), }),
// uploads and tagging are handled by this event // uploads and tagging are handled by this event
websocketEvents.on('on_asset_update', (asset) => { websocketEvents.on('on_asset_update', (ids) => {
this.#pendingUpdates.updated.push(asset); this.#pendingUpdates.updated.push(...ids);
this.#scheduleProcessing(); this.#scheduleProcessing();
}), }),
// this event is called when an asseted is added or removed from an album
websocketEvents.on('on_album_update', (data) => { websocketEvents.on('on_album_update', (data) => {
this.#pendingUpdates.album.push(data); this.#pendingUpdates.album.push(data);
this.#scheduleProcessing(); this.#scheduleProcessing();
@@ -67,6 +138,10 @@ export class WebsocketSupport {
this.#pendingUpdates.deleted.push(ids); this.#pendingUpdates.deleted.push(ids);
this.#scheduleProcessing(); this.#scheduleProcessing();
}), }),
websocketEvents.on('on_asset_restore', (ids) => {
this.#pendingUpdates.restored.push(...ids);
this.#scheduleProcessing();
}),
); );
} }
@@ -120,100 +195,113 @@ export class WebsocketSupport {
async #process() { async #process() {
const pendingUpdates = this.#pendingUpdates; const pendingUpdates = this.#pendingUpdates;
this.#pendingUpdates = { this.#pendingUpdates = this.#init();
updated: [],
trashed: [], await this.#handleGeneric(
deleted: [], [...pendingUpdates.updated, ...pendingUpdates.trashed, ...pendingUpdates.restored],
personed: [], [checkVisibilityProperty, checkFavoriteProperty, checkTrashedProperty, checkTagProperty, checkAlbumProperty],
album: [], );
};
await this.#handleUpdatedAssets(pendingUpdates.updated);
await this.#handleUpdatedAssetsPerson(pendingUpdates.personed); await this.#handleUpdatedAssetsPerson(pendingUpdates.personed);
await this.#handleUpdatedAssetsAlbum(pendingUpdates.album); await this.#handleUpdatedAssetsAlbum(pendingUpdates.album);
await this.#handleUpdatedAssetsTrashed(pendingUpdates.trashed);
this.#timelineManager.removeAssets(pendingUpdates.deleted); this.#timelineManager.removeAssets(pendingUpdates.deleted);
} }
async #handleUpdatedAssets(assets: AssetResponseDto[]) { async #handleGeneric(assetIds: string[], filters: AssetFilter[]) {
const prefilteredAssets = assets.filter((asset) => !this.#timelineManager.isExcluded(toTimelineAsset(asset))); if (assetIds.length === 0) {
if (!this.#timelineManager.options.albumId) { return;
// also check tags }
if (!this.#timelineManager.options.tagId) {
return this.#timelineManager.addAssets(prefilteredAssets.map((asset) => toTimelineAsset(asset))); const assets = await fetchAssetInfos(assetIds);
} const assetsToAdd = [];
for (const asset of prefilteredAssets) { const assetsToRemove = [];
if (asset.tags?.some((tag) => tag.id === this.#timelineManager.options.tagId)) {
this.#timelineManager.addAssets([toTimelineAsset(asset)]); for (const asset of assets) {
} else { if (await this.#shouldAssetBeIncluded(asset, filters)) {
this.#timelineManager.removeAssets([asset.id]); assetsToAdd.push(asset);
} } else {
assetsToRemove.push(asset.id);
} }
} }
const matchingAssets = [];
for (const asset of prefilteredAssets) { this.#timelineManager.addAssets(assetsToAdd.map((asset) => toTimelineAsset(asset)));
const albums = await getAllAlbums({ assetId: asset.id }); this.#timelineManager.removeAssets(assetsToRemove);
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)) { async #shouldAssetBeIncluded(asset: AssetResponseDto, filters: AssetFilter[]): Promise<boolean> {
matchingAssets.push(asset); for (const filter of filters) {
} else { const result = await filter(asset, this.#timelineManager);
this.#timelineManager.removeAssets([asset.id]); if (!result) {
} return false;
} else {
matchingAssets.push(asset);
}
} }
} }
return this.#timelineManager.addAssets(matchingAssets.map((asset) => toTimelineAsset(asset))); return true;
} }
async #handleUpdatedAssetsPerson( async #handleUpdatedAssetsPerson(
data: { assetId: string; personId: string | undefined; status: 'created' | 'removed' | 'removed_soft' }[], data: { assetId: string; personId: string | undefined; status: 'created' | 'removed' | 'removed_soft' }[],
) { ) {
if (!this.#timelineManager.options.personId) { const assetsToRemove: string[] = [];
for (const { assetId } of data) { const personAssetsToAdd: string[] = [];
const asset = await getAssetInfo({ id: assetId, key: authManager.key });
this.#timelineManager.addAssets([toTimelineAsset(asset)]); 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') { this.#timelineManager.removeAssets(assetsToRemove);
if (personId !== this.#timelineManager.options.personId) { // 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; continue;
} }
const asset = await getAssetInfo({ id: assetId, key: authManager.key }); if (status === 'added') {
this.#timelineManager.addAssets([toTimelineAsset(asset)]); assetsToAdd.push(...assetId);
} else if (personId === this.#timelineManager.options.personId) { } else if (status === 'removed') {
this.#timelineManager.removeAssets([assetId]); assetsToRemove.push(...assetId);
}
} }
} }
}
async #handleUpdatedAssetsAlbum(data: { albumId: string; assetId: string[]; status: 'added' | 'removed' }[]) { this.#timelineManager.removeAssets(assetsToRemove);
if (!this.#timelineManager.options.albumId) { // At this point, assetsToAdd contains assets that now have the target person,
return; // but we need to check if they still match other filters.
} await this.#handleGeneric(assetsToAdd, [
for (const { albumId, assetId, status } of data) { checkVisibilityProperty,
if (albumId !== this.#timelineManager.options.albumId) { checkFavoriteProperty,
continue; checkTrashedProperty,
} checkTagProperty,
if (status === 'added') { checkPersonProperty,
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);
}
} }
} }
@@ -411,6 +411,9 @@ export class TimelineManager {
} }
addAssets(assets: TimelineAsset[]) { addAssets(assets: TimelineAsset[]) {
if (assets.length === 0) {
return;
}
const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset)); const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset));
const notUpdated = this.updateAssets(assetsToUpdate); const notUpdated = this.updateAssets(assetsToUpdate);
addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc }); addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
@@ -475,6 +478,9 @@ export class TimelineManager {
} }
removeAssets(ids: string[]) { removeAssets(ids: string[]) {
if (ids.length === 0) {
return [];
}
const { unprocessedIds } = runAssetOperation( const { unprocessedIds } = runAssetOperation(
this, this,
new Set(ids), new Set(ids),