From dc5553b239702f11d742ed2cd9be4954d3a286f8 Mon Sep 17 00:00:00 2001 From: austinmdillow <17092799+austinmdillow@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:12:25 -0400 Subject: [PATCH] feat: workflow filter by missing EXIF property (#32713) Co-authored-by: Daniel Dietzler (cherry picked from commit a1a80cb1c416d3a480963a78d7b9df329d867bf6) --- packages/plugin-core/manifest.json | 4 +- packages/plugin-core/src/index.ts | 29 +++-- .../workflow/workflow-core-plugin.spec.ts | 106 ++++++++++++++++++ 3 files changed, 129 insertions(+), 10 deletions(-) diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json index 2c5666b09..ac8ba474d 100644 --- a/packages/plugin-core/manifest.json +++ b/packages/plugin-core/manifest.json @@ -407,7 +407,7 @@ "matchType": { "type": "string", "title": "Match type", - "enum": ["contains", "startsWith", "exact", "regex"], + "enum": ["contains", "startsWith", "exact", "regex", "present", "missing"], "default": "contains", "description": "Type of pattern matching to perform", "uiHint": { @@ -424,7 +424,7 @@ } } }, - "required": ["property", "pattern"] + "required": ["property"] }, "uiHints": ["Filter"] }, diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index ec6a3bb69..915efbfb5 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -3,13 +3,18 @@ import { AssetVisibility } from '@immich/sdk'; import type { Manifest } from '../dist/index.d.ts'; type MatchValueConfig = { - pattern: string; - matchType?: 'contains' | 'exact' | 'regex' | 'startsWith'; + pattern?: string; + matchType?: 'contains' | 'exact' | 'regex' | 'startsWith' | 'missing' | 'present'; caseSensitive?: boolean; }; -const matchValueResult = (value: string, config: MatchValueConfig) => { - const { pattern, matchType = 'contains', caseSensitive = false } = config; +const matchValueResult = (value: string | null, config: MatchValueConfig) => { + const { pattern = '', matchType = 'contains', caseSensitive = false } = config; + + if (value === null) { + return { workflow: { continue: matchType === 'missing' } }; + } + const searchName = caseSensitive ? value : value.toLowerCase(); const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); @@ -32,6 +37,15 @@ const matchValueResult = (value: string, config: MatchValueConfig) => { return { workflow: { continue: regex.test(value) } }; } + case 'present': { + return { workflow: { continue: true } }; + } + + case 'missing': { + // value is not null at this point + return { workflow: { continue: false } }; + } + default: { return {}; } @@ -135,11 +149,10 @@ const methods = wrapper({ }, assetExifFilter: ({ config, data }) => { - if (!data.asset.exifInfo || data.asset.exifInfo[config.property] === null) { - return { workflow: { continue: false } }; - } + let value = data.asset.exifInfo?.[config.property]; - return matchValueResult(String(data.asset.exifInfo[config.property]), config); + // Empty EXIF value is treated as missing to mirror behavior of the web UI + return matchValueResult(value === null || value === undefined || value === '' ? null : String(value), config); }, assetDateFilter: ({ config, data }) => { 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 cac1cbae7..e4501f317 100644 --- a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts +++ b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts @@ -530,6 +530,112 @@ describe('core plugin', () => { }); }); + describe('assetExifFilter', () => { + describe('missing', () => { + it('should favorite asset if it has no orientation', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id, type: AssetType.Video }); + await ctx.newExif({ assetId: asset.id, orientation: null }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetMetadataExtraction, + steps: [ + { + method: 'immich-plugin-core#assetExifFilter', + config: { property: 'orientation', matchType: 'missing' }, + }, + { + 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 not favorite asset if it has orientation', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id, type: AssetType.Video }); + await ctx.newExif({ assetId: asset.id, orientation: '2' }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetMetadataExtraction, + steps: [ + { + method: 'immich-plugin-core#assetExifFilter', + config: { property: 'orientation', matchType: 'missing' }, + }, + { + 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 }); + }); + }); + + describe('present', () => { + it('should favorite asset if it has orientation', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id, type: AssetType.Video }); + await ctx.newExif({ assetId: asset.id, orientation: '2' }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetMetadataExtraction, + steps: [ + { + method: 'immich-plugin-core#assetExifFilter', + config: { property: 'orientation', matchType: 'present' }, + }, + { + 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 not favorite asset if it has no orientation', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id, type: AssetType.Video }); + await ctx.newExif({ assetId: asset.id, orientation: null }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetMetadataExtraction, + steps: [ + { + method: 'immich-plugin-core#assetExifFilter', + config: { property: 'orientation', matchType: 'present' }, + }, + { + 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 }); + }); + }); + }); + describe('assetDateFilter', () => { it('should favorite assets created during the first 7 days of a specific year and month', async () => { const { user } = await ctx.newUser();