mirror of
https://github.com/immich-app/immich.git
synced 2026-02-04 17:01:13 +03:00
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:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user