feat: more plugin triggers and methods (#28690)

This commit is contained in:
Jason Rasmussen 2026-05-29 14:02:07 -04:00 committed by GitHub
parent 58586483dc
commit da8505f61d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 322 additions and 133 deletions

View file

@ -1,5 +1,5 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { WorkflowController } from 'src/controllers/workflow.controller';
import { WorkflowTrigger } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { WorkflowService } from 'src/services/workflow.service';
import request from 'supertest';

View file

@ -1,6 +1,7 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { createZodDto } from 'nestjs-zod';
import { JsonSchemaDto } from 'src/dtos/json-schema.dto';
import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
import { WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
import { asPluginKey } from 'src/utils/workflow';
import z from 'zod';

View file

@ -1,6 +1,6 @@
import type { WorkflowStepConfig } from '@immich/plugin-sdk';
import type { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
import { createZodDto } from 'nestjs-zod';
import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
import { WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
import z from 'zod';
const WorkflowTriggerResponseSchema = z

View file

@ -1,3 +1,4 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import z from 'zod';
export enum AuthType {
@ -1164,11 +1165,6 @@ export enum PluginContext {
export const PluginContextSchema = z.enum(PluginContext).describe('Plugin context').meta({ id: 'PluginContextType' });
export enum WorkflowTrigger {
AssetCreate = 'AssetCreate',
PersonRecognized = 'PersonRecognized',
}
export const WorkflowTriggerSchema = z
.enum(WorkflowTrigger)
.describe('Plugin trigger type')

View file

@ -103,6 +103,10 @@ export class WorkflowRepository {
});
}
async updateStep(id: string, dto: Updateable<WorkflowStepTable>) {
await this.db.updateTable('workflow_step').where('workflow_step.id', '=', id).set(dto).execute();
}
private async replaceAndReturn(tx: Kysely<DB>, workflowId: string, steps?: WorkflowStepUpsert[]) {
if (steps) {
await tx.deleteFrom('workflow_step').where('workflowId', '=', workflowId).execute();

View file

@ -1,3 +1,4 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import {
Column,
CreateDateColumn,
@ -9,7 +10,6 @@ import {
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { WorkflowTrigger } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table';
@Table('workflow')

View file

@ -1,9 +1,15 @@
import { CurrentPlugin } from '@extism/extism';
import { WorkflowChanges, WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk';
import {
WorkflowChanges,
WorkflowEventData,
WorkflowEventPayload,
WorkflowResponse,
WorkflowTrigger,
} from '@immich/plugin-sdk';
import { HttpException, UnauthorizedException } from '@nestjs/common';
import { join } from 'node:path';
import { DummyValue, OnEvent, OnJob } from 'src/decorators';
import { AlbumsAddAssetsDto } from 'src/dtos/album.dto';
import { AlbumsAddAssetsDto, CreateAlbumDto, GetAlbumsDto } from 'src/dtos/album.dto';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
@ -15,7 +21,6 @@ import {
JobName,
JobStatus,
QueueName,
WorkflowTrigger,
WorkflowType,
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
@ -61,7 +66,9 @@ export class WorkflowExecutionService extends BaseService {
const albumService = BaseService.create(AlbumService, this);
const albumAddAssets = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, args) => albumService.getAll(authDto, ...args));
const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, args) => albumService.create(authDto, ...args));
const addAssetsToAlbum = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
albumService.addAssets(authDto, ...args),
);
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
@ -69,12 +76,16 @@ export class WorkflowExecutionService extends BaseService {
);
const functions = {
albumAddAssets,
searchAlbums,
createAlbum,
addAssetsToAlbum,
addAssetsToAlbums,
};
const stubs = {
albumAddAssets: dummy,
const stubs: typeof functions = {
searchAlbums: dummy,
createAlbum: dummy,
addAssetsToAlbum: dummy,
addAssetsToAlbums: dummy,
};
@ -252,6 +263,17 @@ export class WorkflowExecutionService extends BaseService {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetCreate });
}
@OnEvent({ name: 'AssetMetadataExtracted' })
onAssetMetadataExtracted({ userId, assetId, source }: ArgOf<'AssetMetadataExtracted'>) {
// prevent loops
// TODO loop detection in job service directly
if (source === 'sidecar-write') {
return;
}
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction });
}
private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) {
const items = await this.workflowRepository.search({ userId, trigger });
await this.jobRepository.queueAll(
@ -286,6 +308,25 @@ export class WorkflowExecutionService extends BaseService {
await assetService.update(auth, assetId, {
isFavorite: asset.isFavorite,
visibility: asset.visibility,
dateTimeOriginal: asset.exifInfo?.dateTimeOriginal ?? undefined,
// TODO allow setting to null
longitude: asset.exifInfo?.longitude ?? undefined,
// TODO allow setting to null
latitude: asset.exifInfo?.latitude ?? undefined,
// TODO allow setting to null
description: asset.exifInfo?.description ?? undefined,
rating: asset.exifInfo?.rating,
// TODO add to update dto
// make: asset.exifInfo?.make,
// model: asset.exifInfo?.model,
// city: asset.exifInfo?.city,
// state: asset.exifInfo?.state,
// country: asset.exifInfo?.country,
// lensModel: asset.exifInfo?.lensModel,
// fNumber: asset.exifInfo?.fNumber,
// fps: asset.exifInfo?.fps,
// iso: asset.exifInfo?.iso,
});
},
} satisfies ExecuteOptions<typeof type>;
@ -367,6 +408,10 @@ export class WorkflowExecutionService extends BaseService {
({ data } = await read(type));
}
if (result?.config) {
await this.workflowRepository.updateStep(step.id, { config: result.config });
}
const shouldContinue = result?.workflow?.continue ?? true;
if (!shouldContinue) {
break;

View file

@ -1,4 +1,4 @@
import { WorkflowStepConfig } from '@immich/plugin-sdk';
import { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
import { BadRequestException, Injectable } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@ -11,7 +11,7 @@ import {
WorkflowTriggerResponseDto,
WorkflowUpdateDto,
} from 'src/dtos/workflow.dto';
import { Permission, WorkflowTrigger } from 'src/enum';
import { Permission } from 'src/enum';
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
import { BaseService } from 'src/services/base.service';
import { getWorkflowTriggers, isMethodCompatible, resolveMethod } from 'src/utils/workflow';

View file

@ -1,3 +1,4 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { ShallowDehydrateObject } from 'kysely';
import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants';
@ -29,7 +30,6 @@ import {
TranscodeTarget,
UserMetadataKey,
VideoCodec,
WorkflowTrigger,
WorkflowType,
} from 'src/enum';

View file

@ -1,4 +1,5 @@
import { WorkflowTrigger, WorkflowType } from 'src/enum';
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { WorkflowType } from 'src/enum';
import { isMethodCompatible } from 'src/utils/workflow';
const tests: Array<{ trigger: WorkflowTrigger; types: WorkflowType[]; expected: boolean }> = [

View file

@ -1,9 +1,11 @@
import { WorkflowTrigger, WorkflowType } from 'src/enum';
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { WorkflowType } from 'src/enum';
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
export const triggerMap: Record<WorkflowTrigger, WorkflowType[]> = {
[WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1],
[WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1],
[WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1],
};
export const getWorkflowTriggers = () =>

View file

@ -1,5 +1,6 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { Kysely } from 'kysely';
import { WorkflowTrigger, WorkflowType } from 'src/enum';
import { WorkflowType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';

View file

@ -1,8 +1,8 @@
import { WorkflowStepConfig } from '@immich/plugin-sdk';
import { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
import { Kysely } from 'kysely';
import { readFileSync } from 'node:fs';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import { AssetVisibility, LogLevel, WorkflowTrigger } from 'src/enum';
import { AssetVisibility, LogLevel } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
@ -12,6 +12,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository';
import { DB } from 'src/schema';
import { WorkflowExecutionService } from 'src/services/workflow-execution.service';
@ -33,8 +34,9 @@ class WorkflowTestContext extends MediumTestContext<WorkflowExecutionService> {
CryptoRepository,
DatabaseRepository,
LoggingRepository,
StorageRepository,
PluginRepository,
StorageRepository,
UserRepository,
WorkflowRepository,
],
mock: [ConfigRepository],
@ -231,6 +233,52 @@ describe('core plugin', () => {
});
describe('assetAddToAlbums', () => {
it('should create an album by name', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [], albumName: 'Screenshots' } }],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
const albums = await ctx.get(AlbumRepository).getAll(user.id);
expect(albums).toHaveLength(1);
const album = albums[0]!;
expect(album.albumName).toEqual('Screenshots');
const updated = await ctx.get(WorkflowRepository).get(workflow.id);
expect(updated?.steps[0].config).toEqual({ albumIds: [album.id], albumName: 'Screenshots' });
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id);
});
it('should not use the name when there is an albumId', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [
{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id], albumName: 'Screenshots' } },
],
});
const albums = await ctx.get(AlbumRepository).getAll(user.id);
expect(albums).toHaveLength(1);
expect(albums[0].albumName).toEqual(album.albumName);
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id);
});
it('should add an asset to an album', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });