From b75d9b74b90c985df5c615d5a779b5d5c9484f79 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Thu, 4 Jun 2026 03:56:41 +0000 Subject: [PATCH] feat(plugin-core): add filterByAlbum workflow step Adds a filterByAlbum core plugin method so workflows (e.g. the AlbumAssetAdded trigger) can be scoped to specific albums. The step checks the asset's album membership via the searchAlbums host function and halts the workflow when the asset is not in any of the configured albums (or, with inverse, when it is). --- packages/plugin-core/manifest.json | 27 ++++++++ packages/plugin-core/src/index.d.ts | 1 + packages/plugin-core/src/index.ts | 14 +++++ .../workflow/workflow-core-plugin.spec.ts | 61 +++++++++++++++++++ 4 files changed, 103 insertions(+) diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json index 48b4bee2c8..6c1d1e97c1 100644 --- a/packages/plugin-core/manifest.json +++ b/packages/plugin-core/manifest.json @@ -152,6 +152,33 @@ }, "uiHints": ["Filter"] }, + { + "name": "filterByAlbum", + "title": "Filter by album", + "description": "Continue only when the asset belongs to one of the selected albums", + "types": ["AssetV1"], + "hostFunctions": true, + "schema": { + "type": "object", + "properties": { + "albumIds": { + "type": "string", + "array": true, + "title": "Album IDs", + "description": "Albums to match against", + "uiHint": "AlbumId" + }, + "inverse": { + "type": "boolean", + "title": "Inverse", + "description": "Continue only when the asset is NOT in the selected albums", + "default": false + } + }, + "required": ["albumIds"] + }, + "uiHints": ["Filter"] + }, { "name": "assetArchive", "title": "Archive asset", diff --git a/packages/plugin-core/src/index.d.ts b/packages/plugin-core/src/index.d.ts index 170fa13102..918af49b7f 100644 --- a/packages/plugin-core/src/index.d.ts +++ b/packages/plugin-core/src/index.d.ts @@ -13,6 +13,7 @@ declare module 'main' { // filters export function assetFileFilter(): I32; export function assetMissingTimeZoneFilter(): I32; + export function filterByAlbum(): I32; // updates export function assetFavorite(): I32; diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index bcb05cfa19..2dc6d5ba03 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -50,6 +50,20 @@ export const assetMissingTimeZoneFilter = () => { }); }; +export const filterByAlbum = () => { + return wrapper(({ config, data, functions }) => { + const { albumIds = [], inverse = false } = config; + if (albumIds.length === 0) { + return {}; + } + + const albums = functions.searchAlbums({ assetId: data.asset.id }); + const isMember = albums.some((album) => albumIds.includes(album.id)); + + return { workflow: { continue: isMember !== inverse } }; + }); +}; + export const assetFavorite = () => { return wrapper(({ config, data }) => { const target = config.inverse ? false : true; diff --git a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts index 2bb9de6af1..8740bc85c5 100644 --- a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts +++ b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts @@ -332,4 +332,65 @@ describe('core plugin', () => { await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id); }); }); + + describe('filterByAlbum', () => { + it('should continue when the asset is in a selected album', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const { album } = await ctx.newAlbum({ ownerId: user.id }, [asset.id]); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AlbumAssetAdded, + steps: [ + { method: 'immich-plugin-core#filterByAlbum', config: { albumIds: [album.id] } }, + { method: 'immich-plugin-core#assetFavorite' }, + ], + }); + + await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true }); + }); + + it('should stop when the asset is not in a selected album', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const [{ album }, { album: other }] = await Promise.all([ + ctx.newAlbum({ ownerId: user.id }, [asset.id]), + ctx.newAlbum({ ownerId: user.id }), + ]); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AlbumAssetAdded, + steps: [ + { method: 'immich-plugin-core#filterByAlbum', config: { albumIds: [other.id] } }, + { method: 'immich-plugin-core#assetFavorite' }, + ], + }); + + await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false }); + }); + + it('should continue when no albums are configured', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AlbumAssetAdded, + steps: [ + { method: 'immich-plugin-core#filterByAlbum', config: { albumIds: [] } }, + { method: 'immich-plugin-core#assetFavorite' }, + ], + }); + + await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true }); + }); + }); });