feat: geolocation workflow filter (#28961)

* feat: geolocation workflow filter

* refactor: geolocation workflow filter

* feat: location filter workflow example
This commit is contained in:
Ben Beckford
2026-06-09 22:05:01 -07:00
committed by GitHub
parent 4bc411b7c7
commit 1ce961fbb3
4 changed files with 193 additions and 0 deletions

View File

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

View File

@@ -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;

View File

@@ -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<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
const target = config.inverse ? false : true;

View File

@@ -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 });
});
});
});