feat: workflow asset tag trigger/filter/action (#29043)

* feat: workflow asset tag trigger and filter

* feat(web): show tag names in workflow editor

* fix(web): tag picker in schema config editor

* fix: invalid plugin manifest

* chore: update workflow method wrapper type

* chore: update tag filter method declaration

* feat: workflow action to add tags to assets
This commit is contained in:
Ben Beckford
2026-08-12 16:26:03 -07:00
committed by GitHub
parent b82d480552
commit a939561e70
17 changed files with 175 additions and 8 deletions
+2
View File
@@ -2139,6 +2139,8 @@
"trigger": "Trigger", "trigger": "Trigger",
"trigger_asset_metadata_extraction": "Asset Metadata Extraction", "trigger_asset_metadata_extraction": "Asset Metadata Extraction",
"trigger_asset_metadata_extraction_description": "Triggered when the EXIF metadata of an asset is extracted", "trigger_asset_metadata_extraction_description": "Triggered when the EXIF metadata of an asset is extracted",
"trigger_asset_tagged": "Asset Tagged",
"trigger_asset_tagged_description": "Triggered when a tag is added to an asset",
"trigger_asset_uploaded": "Asset Upload", "trigger_asset_uploaded": "Asset Upload",
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded", "trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
"trigger_person_recognized": "Person Recognized", "trigger_person_recognized": "Person Recognized",
+2 -1
View File
@@ -28066,7 +28066,8 @@
"description": "Plugin trigger type", "description": "Plugin trigger type",
"enum": [ "enum": [
"AssetCreate", "AssetCreate",
"AssetMetadataExtraction" "AssetMetadataExtraction",
"AssetTagged"
], ],
"type": "string" "type": "string"
}, },
+50
View File
@@ -308,6 +308,56 @@
}, },
"uiHints": ["Filter"] "uiHints": ["Filter"]
}, },
{
"name": "assetAddTags",
"title": "Add Tags",
"description": "Add tags to an asset",
"types": ["AssetV1"],
"hostFunctions": true,
"schema": {
"type": "object",
"properties": {
"tags": {
"title": "Tags",
"description": "Tags to add to the asset",
"type": "string",
"array": true,
"uiHint": {
"type": "TagId"
}
}
},
"required": ["tags"]
}
},
{
"name": "assetTagFilter",
"title": "Filter by tags",
"description": "Filter assets by which tags they have",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"tags": {
"title": "Tags",
"description": "Which tags the asset must have",
"type": "string",
"array": true,
"uiHint": {
"type": "TagId"
}
},
"matching": {
"title": "Matching",
"description": "Whether assets must have all, any, or none of the listed tags",
"type": "string",
"enum": ["all", "any", "none"]
}
},
"required": ["tags", "matching"]
},
"uiHints": ["Filter"]
},
{ {
"name": "assetExifFilter", "name": "assetExifFilter",
"title": "Filter by EXIF metadata", "title": "Filter by EXIF metadata",
+27
View File
@@ -39,6 +39,11 @@ const matchValueResult = (value: string, config: MatchValueConfig) => {
}; };
const methods = wrapper<Manifest>({ const methods = wrapper<Manifest>({
assetAddTags: ({ config, data, functions }) => {
functions.bulkTagAssets({ assetIds: [data.asset.id], tagIds: config.tags });
return {};
},
assetAddToAlbums: ({ config, data, functions }) => { assetAddToAlbums: ({ config, data, functions }) => {
const assetId = data.asset.id; const assetId = data.asset.id;
@@ -176,6 +181,24 @@ const methods = wrapper<Manifest>({
return { workflow: { continue: hasTimeZone === needsTimeZone } }; return { workflow: { continue: hasTimeZone === needsTimeZone } };
}, },
assetTagFilter: ({ config, data }) => {
const assetTags = data.asset.tags.map((tag) => tag.id);
for (const tag of config.tags) {
if (assetTags.includes(tag)) {
if (config.matching === 'any') {
break;
} else if (config.matching === 'none') {
return { workflow: { continue: false } };
}
} else if (config.matching === 'all') {
return { workflow: { continue: false } };
}
}
return { workflow: { continue: true } };
},
assetTypeFilter: ({ config, data }) => { assetTypeFilter: ({ config, data }) => {
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } }; return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
}, },
@@ -208,6 +231,7 @@ const methods = wrapper<Manifest>({
}); });
const { const {
assetAddTags,
assetAddToAlbums, assetAddToAlbums,
assetArchive, assetArchive,
assetFavorite, assetFavorite,
@@ -217,6 +241,7 @@ const {
assetDateFilter, assetDateFilter,
assetLock, assetLock,
assetMissingTimeZoneFilter, assetMissingTimeZoneFilter,
assetTagFilter,
assetTypeFilter, assetTypeFilter,
assetVisibility, assetVisibility,
webhook, webhook,
@@ -226,6 +251,7 @@ const {
} = methods; } = methods;
export { export {
assetAddTags,
assetAddToAlbums, assetAddToAlbums,
assetArchive, assetArchive,
assetFavorite, assetFavorite,
@@ -235,6 +261,7 @@ export {
assetDateFilter, assetDateFilter,
assetLock, assetLock,
assetMissingTimeZoneFilter, assetMissingTimeZoneFilter,
assetTagFilter,
assetTypeFilter, assetTypeFilter,
assetVisibility, assetVisibility,
webhook, webhook,
@@ -4,6 +4,8 @@ import {
type BulkIdResponseDto, type BulkIdResponseDto,
type BulkIdsDto, type BulkIdsDto,
type CreateAlbumDto, type CreateAlbumDto,
type TagBulkAssetsDto,
type TagBulkAssetsResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
declare module 'extism:host' { declare module 'extism:host' {
@@ -47,6 +49,7 @@ export const availableFunctions = [
'addAssetsToAlbum', 'addAssetsToAlbum',
'addAssetsToAlbums', 'addAssetsToAlbums',
'httpRequest', 'httpRequest',
'bulkTagAssets',
] as const; ] as const;
export const hostFunctions = (authToken: string) => { export const hostFunctions = (authToken: string) => {
@@ -96,5 +99,11 @@ export const hostFunctions = (authToken: string) => {
authToken, authToken,
[url, options], [url, options],
), ),
bulkTagAssets: (dto: TagBulkAssetsDto) =>
call<[TagBulkAssetsDto], TagBulkAssetsResponseDto>(
'bulkTagAssets',
authToken,
[dto],
),
} satisfies Record<(typeof availableFunctions)[number], unknown>; } satisfies Record<(typeof availableFunctions)[number], unknown>;
}; };
+8 -1
View File
@@ -1,4 +1,9 @@
import type { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk'; import type {
AssetTypeEnum,
AssetVisibility,
TagResponseDto,
WorkflowType,
} from '@immich/sdk';
type DeepPartial<T> = T extends Date type DeepPartial<T> = T extends Date
? T ? T
@@ -18,6 +23,7 @@ export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
export enum WorkflowTrigger { export enum WorkflowTrigger {
AssetCreate = 'AssetCreate', AssetCreate = 'AssetCreate',
AssetMetadataExtraction = 'AssetMetadataExtraction', AssetMetadataExtraction = 'AssetMetadataExtraction',
AssetTagged = 'AssetTagged',
// PersonRecognized = 'PersonRecognized', // PersonRecognized = 'PersonRecognized',
} }
@@ -88,6 +94,7 @@ export type AssetV1 = {
duplicateId: string | null; duplicateId: string | null;
visibility: AssetVisibility; visibility: AssetVisibility;
isEdited: boolean; isEdited: boolean;
tags: TagResponseDto[];
exifInfo: { exifInfo: {
make: string | null; make: string | null;
model: string | null; model: string | null;
+2 -1
View File
@@ -7423,7 +7423,8 @@ export enum WorkflowType {
} }
export enum WorkflowTrigger { export enum WorkflowTrigger {
AssetCreate = "AssetCreate", AssetCreate = "AssetCreate",
AssetMetadataExtraction = "AssetMetadataExtraction" AssetMetadataExtraction = "AssetMetadataExtraction",
AssetTagged = "AssetTagged"
} }
export enum QueueJobStatus { export enum QueueJobStatus {
Active = "active", Active = "active",
+1 -1
View File
@@ -43,7 +43,7 @@ type EventMap = {
// asset events // asset events
AssetCreate: [{ asset: Pick<Asset, 'id' | 'ownerId'>; file?: UploadFile }]; AssetCreate: [{ asset: Pick<Asset, 'id' | 'ownerId'>; file?: UploadFile }];
AssetTag: [{ assetId: string }]; AssetTag: [{ assetId: string; userId: string }];
AssetUntag: [{ assetId: string }]; AssetUntag: [{ assetId: string }];
AssetHide: [{ assetId: string; userId: string }]; AssetHide: [{ assetId: string; userId: string }];
AssetShow: [{ assetId: string; userId: string }]; AssetShow: [{ assetId: string; userId: string }];
@@ -8,6 +8,7 @@ import { WorkflowSearchDto } from 'src/dtos/workflow.dto';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table'; import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
import { WorkflowTable } from 'src/schema/tables/workflow.table'; import { WorkflowTable } from 'src/schema/tables/workflow.table';
import { withTags } from 'src/utils/database';
export type WorkflowStepUpsert = Omit<Insertable<WorkflowStepTable>, 'workflowId' | 'order'>; export type WorkflowStepUpsert = Omit<Insertable<WorkflowStepTable>, 'workflowId' | 'order'>;
@@ -143,6 +144,7 @@ export class WorkflowRepository {
.leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id')
.select((eb) => [ .select((eb) => [
...columns.workflowAssetV1, ...columns.workflowAssetV1,
withTags,
jsonObjectFrom( jsonObjectFrom(
eb eb
.selectFrom('asset_exif') .selectFrom('asset_exif')
+2 -2
View File
@@ -93,7 +93,7 @@ export class TagService extends BaseService {
const results = await this.tagRepository.upsertAssetIds(items); const results = await this.tagRepository.upsertAssetIds(items);
for (const assetId of new Set(results.map((item) => item.assetId))) { for (const assetId of new Set(results.map((item) => item.assetId))) {
await this.updateTags(assetId); await this.updateTags(assetId);
await this.eventRepository.emit('AssetTag', { assetId }); await this.eventRepository.emit('AssetTag', { assetId, userId: auth.user.id });
} }
return { count: results.length }; return { count: results.length };
@@ -114,7 +114,7 @@ export class TagService extends BaseService {
} }
await this.updateTags(assetId); await this.updateTags(assetId);
await this.eventRepository.emit('AssetTag', { assetId }); await this.eventRepository.emit('AssetTag', { assetId, userId: auth.user.id });
} }
return results; return results;
@@ -13,6 +13,7 @@ import { AlbumsAddAssetsDto, CreateAlbumDto, GetAlbumsDto } from 'src/dtos/album
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import { TagBulkAssetsDto } from 'src/dtos/tag.dto';
import { import {
BootstrapEventPriority, BootstrapEventPriority,
DatabaseLock, DatabaseLock,
@@ -27,6 +28,7 @@ import { ArgOf } from 'src/repositories/event.repository';
import { AlbumService } from 'src/services/album.service'; import { AlbumService } from 'src/services/album.service';
import { AssetService } from 'src/services/asset.service'; import { AssetService } from 'src/services/asset.service';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { TagService } from 'src/services/tag.service';
import { JobOf } from 'src/types'; import { JobOf } from 'src/types';
const dummy = () => { const dummy = () => {
@@ -69,6 +71,7 @@ export class WorkflowExecutionService extends BaseService {
this.jwtSecret = this.cryptoRepository.randomBytesAsText(32); this.jwtSecret = this.cryptoRepository.randomBytesAsText(32);
const albumService = BaseService.create(AlbumService, this); const albumService = BaseService.create(AlbumService, this);
const tagService = BaseService.create(TagService, this);
const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, ctx, args) => albumService.getAll(authDto, ...args)); const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, ctx, args) => albumService.getAll(authDto, ...args));
const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, ctx, args) => albumService.create(authDto, ...args)); const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, ctx, args) => albumService.create(authDto, ...args));
@@ -106,6 +109,9 @@ export class WorkflowExecutionService extends BaseService {
throw new Error('Hostname did not match any listed in methods[].allowedHosts in the plugin manifest'); throw new Error('Hostname did not match any listed in methods[].allowedHosts in the plugin manifest');
}); });
const bulkTagAssets = this.wrap<[dto: TagBulkAssetsDto]>((authDto, ctx, args) =>
tagService.bulkTagAssets(authDto, ...args),
);
const functions = { const functions = {
searchAlbums, searchAlbums,
@@ -113,6 +119,7 @@ export class WorkflowExecutionService extends BaseService {
addAssetsToAlbum, addAssetsToAlbum,
addAssetsToAlbums, addAssetsToAlbums,
httpRequest, httpRequest,
bulkTagAssets,
}; };
const stubs: typeof functions = { const stubs: typeof functions = {
@@ -121,6 +128,7 @@ export class WorkflowExecutionService extends BaseService {
addAssetsToAlbum: dummy, addAssetsToAlbum: dummy,
addAssetsToAlbums: dummy, addAssetsToAlbums: dummy,
httpRequest: dummy, httpRequest: dummy,
bulkTagAssets: dummy,
}; };
const plugins = await this.pluginRepository.getForLoad(); const plugins = await this.pluginRepository.getForLoad();
@@ -309,6 +317,11 @@ export class WorkflowExecutionService extends BaseService {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction }); return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction });
} }
@OnEvent({ name: 'AssetTag' })
onAssetTagged({ assetId, userId }: ArgOf<'AssetTag'>) {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetTagged });
}
private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) { private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) {
const items = await this.workflowRepository.search({ userId, trigger }); const items = await this.workflowRepository.search({ userId, trigger });
await this.jobRepository.queueAll( await this.jobRepository.queueAll(
+1
View File
@@ -6,6 +6,7 @@ export const triggerMap: Record<WorkflowTrigger, WorkflowType[]> = {
[WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1], [WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1],
// [WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1], // [WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1],
[WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1], [WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1],
[WorkflowTrigger.AssetTagged]: [WorkflowType.AssetV1],
}; };
export const getWorkflowTriggers = () => export const getWorkflowTriggers = () =>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import SchemaAlbumPicker from '$lib/components/SchemaAlbumPicker.svelte'; import SchemaAlbumPicker from '$lib/components/SchemaAlbumPicker.svelte';
import Self from '$lib/components/SchemaConfiguration.svelte'; import Self from '$lib/components/SchemaConfiguration.svelte';
import SchemaTagPicker from '$lib/components/SchemaTagPicker.svelte';
import type { JSONSchemaProperty, SchemaConfig } from '$lib/types'; import type { JSONSchemaProperty, SchemaConfig } from '$lib/types';
import { import {
CodeBlock, CodeBlock,
@@ -80,6 +81,8 @@
</div> </div>
{:else if schema.uiHint?.type === 'AlbumId'} {:else if schema.uiHint?.type === 'AlbumId'}
<SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} /> <SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} />
{:else if schema.uiHint?.type === 'TagId'}
<SchemaTagPicker bind:tagIds={getUiHintValue, setUiHintValue} />
{:else if schema.enum && schema.array} {:else if schema.enum && schema.array}
<Field {label} {description}> <Field {label} {description}>
<MultiSelect options={schema.enum} bind:values={getEnum, setValue} /> <MultiSelect options={schema.enum} bind:values={getEnum, setValue} />
@@ -0,0 +1,32 @@
<script lang="ts">
import Combobox from '$lib/components/shared-components/Combobox.svelte';
import TagPill from '$lib/components/shared-components/TagPill.svelte';
import { getAllTags, type TagResponseDto } from '@immich/sdk';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
let { tagIds = $bindable([]) }: { tagIds: string[] } = $props();
let tags: TagResponseDto[] = $state([]);
onMount(async () => {
tags = await getAllTags();
});
</script>
<Combobox
onSelect={(option) => {
if (option && !tagIds.includes(option.value)) {
tagIds.push(option.value);
}
}}
label={$t('tags')}
defaultFirstOption
options={tags.map((tag) => ({ label: tag.value, value: tag.id }))}
placeholder={$t('search_tags')}
/>
<section class="flex flex-wrap gap-1 pt-2">
{#each tagIds as id, index (id)}
<TagPill label={tags.find((t) => t.id === id)?.name ?? id} onRemove={() => tagIds.splice(index, 1)} />
{/each}
</section>
+1 -1
View File
@@ -100,7 +100,7 @@ export type JSONSchemaProperty = {
properties?: Record<string, JSONSchemaProperty>; properties?: Record<string, JSONSchemaProperty>;
required?: string[]; required?: string[];
uiHint?: { uiHint?: {
type?: 'AlbumId' | 'AssetId' | 'PersonId'; type?: 'AlbumId' | 'AssetId' | 'PersonId' | 'TagId';
order?: number; order?: number;
}; };
}; };
+9
View File
@@ -13,6 +13,9 @@ export const getTriggerName = ($t: MessageFormatter, type: WorkflowTrigger) => {
case WorkflowTrigger.AssetMetadataExtraction: { case WorkflowTrigger.AssetMetadataExtraction: {
return $t('trigger_asset_metadata_extraction'); return $t('trigger_asset_metadata_extraction');
} }
case WorkflowTrigger.AssetTagged: {
return $t('trigger_asset_tagged');
}
default: { default: {
return type; return type;
} }
@@ -30,6 +33,12 @@ export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigge
case WorkflowTrigger.AssetMetadataExtraction: { case WorkflowTrigger.AssetMetadataExtraction: {
return $t('trigger_asset_metadata_extraction_description'); return $t('trigger_asset_metadata_extraction_description');
} }
case WorkflowTrigger.AssetTagged: {
return $t('trigger_asset_tagged_description');
}
default: {
return type;
}
} }
}; };
@@ -1,6 +1,6 @@
<script module lang="ts"> <script module lang="ts">
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAlbumInfo } from '@immich/sdk'; import { getAlbumInfo, getTagById } from '@immich/sdk';
// eslint-disable-next-line svelte/prefer-svelte-reactivity // eslint-disable-next-line svelte/prefer-svelte-reactivity
const albumNameCache = new Map<string, Promise<string>>(); const albumNameCache = new Map<string, Promise<string>>();
@@ -239,6 +239,16 @@
{@render badge($t('album'), `"${truncate(albumName)}"`)} {@render badge($t('album'), `"${truncate(albumName)}"`)}
{/await} {/await}
{/each} {/each}
{:else if getUiHint(key) === 'TagId'}
{#each toIds(value) as tagId (tagId)}
{#await getTagById({ id: tagId })}
{@render badge($t('tag'), '…')}
{:then tag}
{@render badge($t('tag'), `"${truncate(tag.name)}"`)}
{:catch}
{@render badge($t('tag'), `"${truncate(tagId)}"`)}
{/await}
{/each}
{:else} {:else}
{@render badge(key, formatConfigValue(value))} {@render badge(key, formatConfigValue(value))}
{/if} {/if}