refactor: plugin manifest (#28673)

This commit is contained in:
Jason Rasmussen
2026-05-28 17:23:49 -04:00
committed by GitHub
parent 134c0d4dfb
commit 8783180cf3
24 changed files with 140 additions and 118 deletions
+1
View File
@@ -38,6 +38,7 @@ const PluginManifestTemplateSchema = z
description: z.string().min(1).describe('Template description'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
steps: z.array(PluginManifestTemplateStepSchema).describe('Workflow steps'),
uiHints: z.array(z.string()).optional().default([]).describe('Ui hints, for example "smart-album"'),
})
.meta({ id: 'PluginManifestTemplateDto' });
+3
View File
@@ -58,6 +58,7 @@ const PluginTemplateResponseSchema = z
description: z.string().describe('Template description'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
steps: z.array(PluginTemplateStepResponseSchema).describe('Workflow steps'),
uiHints: z.array(z.string()).describe('Ui hints, for example "smart-album"'),
})
.meta({ id: 'PluginTemplateResponseDto' });
@@ -91,6 +92,7 @@ export type PluginTemplate = {
config?: Record<string, unknown> | null;
enabled?: boolean;
}>;
uiHints: string[];
};
export const mapTemplate = (plugin: { name: string }, template: PluginTemplate): PluginTemplateResponseDto => {
@@ -104,6 +106,7 @@ export const mapTemplate = (plugin: { name: string }, template: PluginTemplate):
config: step.config ?? null,
enabled: step.enabled,
})),
uiHints: template.uiHints ?? [],
};
};
+1 -1
View File
@@ -866,7 +866,7 @@ export enum JobName {
Ocr = 'Ocr',
// Workflow
WorkflowAssetCreate = 'WorkflowAssetCreate',
WorkflowAssetTrigger = 'WorkflowAssetTrigger',
}
export const JobNameSchema = z.enum(JobName).describe('Job name').meta({ id: 'JobName' });
@@ -45,10 +45,10 @@ export class WorkflowRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
search(dto: WorkflowSearchDto & { ownerId?: string }) {
search(dto: WorkflowSearchDto & { userId?: string }) {
return this.queryBuilder()
.$if(!!dto.id, (qb) => qb.where('id', '=', dto.id!))
.$if(!!dto.ownerId, (qb) => qb.where('ownerId', '=', dto.ownerId!))
.$if(!!dto.userId, (qb) => qb.where('ownerId', '=', dto.userId!))
.$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!))
.$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!))
.orderBy('createdAt', 'desc')
@@ -1,9 +1,8 @@
import { CurrentPlugin } from '@extism/extism';
import { WorkflowChanges, WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk';
import { HttpException, UnauthorizedException } from '@nestjs/common';
import _ from 'lodash';
import { join } from 'node:path';
import { OnEvent, OnJob } from 'src/decorators';
import { DummyValue, OnEvent, OnJob } from 'src/decorators';
import { AlbumsAddAssetsDto } from 'src/dtos/album.dto';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -21,6 +20,7 @@ import {
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { AlbumService } from 'src/services/album.service';
import { AssetService } from 'src/services/asset.service';
import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types';
@@ -32,9 +32,11 @@ const dummy = () => {
type ExecuteOptions<T extends WorkflowType> = {
read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T> }>;
write: (changes: WorkflowChanges<T>) => Promise<void>;
write: (auth: AuthDto, changes: WorkflowChanges<T>) => Promise<void>;
};
type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger };
export class WorkflowExecutionService extends BaseService {
private jwtSecret!: string;
@@ -62,7 +64,6 @@ export class WorkflowExecutionService extends BaseService {
const albumAddAssets = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
albumService.addAssets(authDto, ...args),
);
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
albumService.addAssetsToAlbums(authDto, ...args),
);
@@ -247,20 +248,25 @@ export class WorkflowExecutionService extends BaseService {
}
@OnEvent({ name: 'AssetCreate' })
async onAssetCreate({ asset }: ArgOf<'AssetCreate'>) {
const dto = { ownerId: asset.ownerId, trigger: WorkflowTrigger.AssetCreate };
const items = await this.workflowRepository.search(dto);
onAssetCreate({ asset: { ownerId: userId, id: assetId } }: ArgOf<'AssetCreate'>) {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetCreate });
}
private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) {
const items = await this.workflowRepository.search({ userId, trigger });
await this.jobRepository.queueAll(
items.map((workflow) => ({
name: JobName.WorkflowAssetCreate,
data: { workflowId: workflow.id, assetId: asset.id },
name: JobName.WorkflowAssetTrigger,
data: { workflowId: workflow.id, assetId, trigger },
})),
);
}
@OnJob({ name: JobName.WorkflowAssetCreate, queue: QueueName.Workflow })
handleAssetCreate({ workflowId, assetId }: JobOf<JobName.WorkflowAssetCreate>) {
@OnJob({ name: JobName.WorkflowAssetTrigger, queue: QueueName.Workflow })
handleAssetTrigger({ workflowId, assetId }: JobOf<JobName.WorkflowAssetTrigger>) {
return this.execute(workflowId, (type) => {
const assetService = BaseService.create(AssetService, this);
switch (type) {
case WorkflowType.AssetV1: {
return {
@@ -271,19 +277,16 @@ export class WorkflowExecutionService extends BaseService {
authUserId: asset.ownerId,
};
},
write: async (changes) => {
if (changes.asset) {
await this.assetRepository.update({
id: assetId,
..._.omitBy(
{
isFavorite: changes.asset?.isFavorite,
visibility: changes.asset?.visibility,
},
_.isUndefined,
),
});
write: async (auth, changes) => {
const asset = changes.asset;
if (!asset) {
return;
}
await assetService.update(auth, assetId, {
isFavorite: asset.isFavorite,
visibility: asset.visibility,
});
},
} satisfies ExecuteOptions<typeof type>;
}
@@ -301,7 +304,19 @@ export class WorkflowExecutionService extends BaseService {
}
// TODO infer from steps
const type = 'AssetV1' as T;
let type: T | undefined;
for (const targetType of Object.values(WorkflowType)) {
const missing = workflow.steps.some((step) => !step.types.includes(targetType));
if (!missing) {
type = targetType as unknown as T;
break;
}
}
if (!type) {
throw new Error('Unable to infer workflow event type from steps');
}
const handler = getHandler(type);
if (!handler) {
this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`);
@@ -337,7 +352,19 @@ export class WorkflowExecutionService extends BaseService {
payload,
);
if (result?.changes) {
await write(result.changes);
await write(
{
user: {
id: readResult.authUserId,
},
session: {
id: DummyValue.UUID,
// TODO move this to auth.elevated or similar
hasElevatedPermission: true,
},
} as AuthDto,
result.changes,
);
({ data } = await read(type));
}
+1 -1
View File
@@ -23,7 +23,7 @@ export class WorkflowService extends BaseService {
}
async search(auth: AuthDto, dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> {
const workflows = await this.workflowRepository.search({ ...dto, ownerId: auth.user.id });
const workflows = await this.workflowRepository.search({ ...dto, userId: auth.user.id });
return workflows.map((workflow) => mapWorkflow(workflow));
}
+1 -1
View File
@@ -404,7 +404,7 @@ export type JobItem =
| { name: JobName.Ocr; data: IEntityJob }
// Workflow
| { name: JobName.WorkflowAssetCreate; data: { workflowId: string; assetId: string } }
| { name: JobName.WorkflowAssetTrigger; data: { workflowId: string; assetId: string } }
// Editor
| { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob };
@@ -137,7 +137,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetArchive' }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Archive,
@@ -154,7 +154,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetArchive', config: { inverse: true } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Timeline,
@@ -173,7 +173,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetLock' }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Locked,
@@ -190,7 +190,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetLock', config: { inverse: true } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Timeline,
@@ -209,7 +209,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetFavorite' }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
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 });
});
@@ -224,7 +224,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetFavorite', config: { inverse: true } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
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 });
});
@@ -242,7 +242,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
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);
});
@@ -261,7 +261,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album1.id, album2.id] } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AlbumRepository).getAssetIds(album1.id, [asset.id])).resolves.toContain(asset.id);
await expect(ctx.get(AlbumRepository).getAssetIds(album2.id, [asset.id])).resolves.toContain(asset.id);
@@ -279,7 +279,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }],
});
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeTruthy();
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeTruthy();
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id);
});