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

View File

@@ -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<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 {
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<boolean> {
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,
]);
}
}

View File

@@ -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),