mirror of
https://github.com/immich-app/immich.git
synced 2026-06-12 19:11:52 -07:00
refactor: plugin manifest (#28673)
This commit is contained in:
@@ -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' });
|
||||
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user