From 1ce961fbb3a65c260010359c0aaf5136a1979f69 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Tue, 9 Jun 2026 22:05:01 -0700 Subject: [PATCH] feat: geolocation workflow filter (#28961) * feat: geolocation workflow filter * refactor: geolocation workflow filter * feat: location filter workflow example --- packages/plugin-core/manifest.json | 76 +++++++++++++++++++ packages/plugin-core/src/index.d.ts | 1 + packages/plugin-core/src/index.ts | 45 +++++++++++ .../workflow/workflow-core-plugin.spec.ts | 71 +++++++++++++++++ 4 files changed, 193 insertions(+) diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json index 48b4bee2c8..f8093496a8 100644 --- a/packages/plugin-core/manifest.json +++ b/packages/plugin-core/manifest.json @@ -55,6 +55,26 @@ } ], "uiHints": ["SmartAlbum"] + }, + { + "name": "location-smart-album", + "title": "Location-based album", + "description": "Automatically add assets taken in a specific location to an album", + "trigger": "AssetMetadataExtraction", + "steps": [ + { + "method": "immich-plugin-core#assetLocationFilter", + "config": { "region": { "city": "Vancouver", "state": "British Columbia", "country": "Canada" } } + }, + { + "method": "immich-plugin-core#assetAddToAlbums", + "config": { + "albumName": "Vancouver photos & videos", + "albumIds": [] + } + } + ], + "uiHints": ["SmartAlbum"] } ], "methods": [ @@ -107,6 +127,62 @@ }, "uiHints": ["Filter"] }, + { + "name": "assetLocationFilter", + "title": "Filter assets by geolocation", + "description": "Filter assets by where they were taken", + "types": ["AssetV1"], + "schema": { + "type": "object", + "properties": { + "region": { + "type": "object", + "title": "Region", + "description": "Filter by region name", + "properties": { + "country": { + "type": "string", + "title": "Country", + "description": "Exact name of the country the asset must be taken in" + }, + "state": { + "type": "string", + "title": "State/province", + "description": "Exact name of the state/province the asset must be taken in" + }, + "city": { + "type": "string", + "title": "City", + "description": "Exact name of the city the asset must be taken in" + } + } + }, + "coordinate": { + "type": "object", + "title": "Coordinate", + "description": "Filter by distance to a coordinate", + "properties": { + "latitude": { + "type": "string", + "title": "Latitude", + "description": "GPS latitude of a coordinate which the asset must be close to" + }, + "longitude": { + "type": "string", + "title": "Longitude", + "description": "GPS longitude of a coordinate which the asset must be close to" + }, + "radius": { + "type": "number", + "title": "Maximum distance", + "description": "How close in kilometres the asset must be to the given point" + } + } + } + } + }, + "uiHints": ["Filter"] + }, { "name": "filterFileType", "title": "Filter by file type", diff --git a/packages/plugin-core/src/index.d.ts b/packages/plugin-core/src/index.d.ts index 170fa13102..2ce414e84f 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 assetLocationFilter(): I32; // updates export function assetFavorite(): I32; diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index bcb05cfa19..e67434bb54 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -50,6 +50,51 @@ export const assetMissingTimeZoneFilter = () => { }); }; +export const assetLocationFilter = () => { + return wrapper< + WorkflowType.AssetV1, + { + region?: { country?: string; state?: string; city?: string }; + coordinate?: { latitude?: string; longitude?: string; radius?: number }; + } + >(({ config, data }) => { + if ( + (config.region?.country && config.region.country !== data.asset.exifInfo?.country) || + (config.region?.state && config.region.state !== data.asset.exifInfo?.state) || + (config.region?.city && config.region.city !== data.asset.exifInfo?.city) + ) { + return { workflow: { continue: false } }; + } + + const configLat = Number.parseFloat(config.coordinate?.latitude ?? ''); + const configLon = Number.parseFloat(config.coordinate?.longitude ?? ''); + + if (Number.isNaN(configLat) || Number.isNaN(configLat)) { + return { workflow: { continue: true } }; + } + + const assetLat = data.asset.exifInfo?.latitude; + const assetLon = data.asset.exifInfo?.longitude; + + if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) { + return { workflow: { continue: false } }; + } + + const earthDiameter = 12742; + const deg = Math.PI / 180; + const delta = Math.asin( + Math.sqrt( + Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) + + Math.cos(assetLat * deg) * + Math.cos(configLat * deg) * + Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2), + ), + ); + + return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } }; + }); +}; + 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..7b1859e4c3 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,75 @@ describe('core plugin', () => { await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id); }); }); + + describe('assetLocationFilter', () => { + it('should favorite an asset within a given radius', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, latitude: 49.273_353_221_145_36, longitude: -123.103_871_440_787_64 }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetMetadataExtraction, + steps: [ + { + method: 'immich-plugin-core#assetLocationFilter', + config: { coordinate: { latitude: 49.288_821_679_949_29, longitude: -123.111_153_098_813_7, radius: 2 } }, + }, + { + 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 outside a given radius', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, latitude: 49.261_266_052_570_35, longitude: -123.248_959_390_781_96 }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetMetadataExtraction, + steps: [ + { + method: 'immich-plugin-core#assetLocationFilter', + config: { coordinate: { latitude: 49.288_821_679_949_29, longitude: -123.111_153_098_813_7, radius: 10 } }, + }, + { + 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 favorite asset by location name', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, city: 'Vancouver' }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetMetadataExtraction, + steps: [ + { + method: 'immich-plugin-core#assetLocationFilter', + config: { region: { city: 'Vancouver' } }, + }, + { + 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 }); + }); + }); });