feat: more plugin triggers and methods (#28690)

pull/28691/head
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

@ -24,11 +24,13 @@ class WorkflowTrigger {
String toJson() => value; String toJson() => value;
static const assetCreate = WorkflowTrigger._(r'AssetCreate'); static const assetCreate = WorkflowTrigger._(r'AssetCreate');
static const assetMetadataExtraction = WorkflowTrigger._(r'AssetMetadataExtraction');
static const personRecognized = WorkflowTrigger._(r'PersonRecognized'); static const personRecognized = WorkflowTrigger._(r'PersonRecognized');
/// List of all possible values in this [enum][WorkflowTrigger]. /// List of all possible values in this [enum][WorkflowTrigger].
static const values = <WorkflowTrigger>[ static const values = <WorkflowTrigger>[
assetCreate, assetCreate,
assetMetadataExtraction,
personRecognized, personRecognized,
]; ];
@ -69,6 +71,7 @@ class WorkflowTriggerTypeTransformer {
if (data != null) { if (data != null) {
switch (data) { switch (data) {
case r'AssetCreate': return WorkflowTrigger.assetCreate; case r'AssetCreate': return WorkflowTrigger.assetCreate;
case r'AssetMetadataExtraction': return WorkflowTrigger.assetMetadataExtraction;
case r'PersonRecognized': return WorkflowTrigger.personRecognized; case r'PersonRecognized': return WorkflowTrigger.personRecognized;
default: default:
if (!allowNull) { if (!allowNull) {

View File

@ -26355,6 +26355,7 @@
"description": "Plugin trigger type", "description": "Plugin trigger type",
"enum": [ "enum": [
"AssetCreate", "AssetCreate",
"AssetMetadataExtraction",
"PersonRecognized" "PersonRecognized"
], ],
"type": "string" "type": "string"

View File

@ -7,8 +7,8 @@
"wasmPath": "dist/plugin.wasm", "wasmPath": "dist/plugin.wasm",
"templates": [ "templates": [
{ {
"name": "auto-archive-screenshots", "name": "screenshots-smart-album",
"title": "Auto-archive screenshots", "title": "Archive screenshots",
"description": "Archive uploads with \"screenshot\" in the filename and optionally add them to an album", "description": "Archive uploads with \"screenshot\" in the filename and optionally add them to an album",
"trigger": "AssetCreate", "trigger": "AssetCreate",
"steps": [ "steps": [
@ -29,6 +29,27 @@
{ {
"method": "immich-plugin-core#assetAddToAlbums", "method": "immich-plugin-core#assetAddToAlbums",
"config": { "config": {
"albumName": "Screenshots",
"albumIds": []
}
}
],
"uiHints": ["SmartAlbum"]
},
{
"name": "missing-timezone-smart-album",
"title": "Missing timezone",
"description": "Automatically create an album for assets without a time zone",
"trigger": "AssetMetadataExtraction",
"steps": [
{
"method": "immich-plugin-core#assetMissingTimeZoneFilter",
"config": {}
},
{
"method": "immich-plugin-core#assetAddToAlbums",
"config": {
"albumName": "Missing time zone",
"albumIds": [] "albumIds": []
} }
} }
@ -68,6 +89,24 @@
}, },
"uiHints": ["Filter"] "uiHints": ["Filter"]
}, },
{
"name": "assetMissingTimeZoneFilter",
"title": "Filter by missing time zone",
"description": "Filter assets that have no time zone information",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"inverse": {
"type": "boolean",
"title": "Inverse",
"description": "Missing by default, set to true to filter assets with a time zone",
"default": false
}
}
},
"uiHints": ["Filter"]
},
{ {
"name": "filterFileType", "name": "filterFileType",
"title": "Filter by file type", "title": "Filter by file type",
@ -189,6 +228,12 @@
"array": true, "array": true,
"description": "Target album IDs", "description": "Target album IDs",
"uiHint": "AlbumId" "uiHint": "AlbumId"
},
"albumName": {
"type": "string",
"title": "Album name",
"array": true,
"description": "Use an album with this name if one exists, otherwise create a new one"
} }
}, },
"required": ["albumIds"] "required": ["albumIds"]

View File

@ -13,6 +13,7 @@
"license": "AGPL-3.0", "license": "AGPL-3.0",
"devDependencies": { "devDependencies": {
"@extism/js-pdk": "^1.0.1", "@extism/js-pdk": "^1.0.1",
"@immich/sdk": "workspace:*",
"@immich/plugin-sdk": "workspace:*", "@immich/plugin-sdk": "workspace:*",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"typescript": "^6.0.0" "typescript": "^6.0.0"

View File

@ -1,14 +1,20 @@
// copy from // keep in sync with plugin-sdk/host-functions.ts';
// import '@immich/plugin-sdk/host-functions';
declare module 'extism:host' { declare module 'extism:host' {
interface user { interface user {
albumAddAssets(ptr: PTR): I64; searchAlbums(ptr: PTR): I64;
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64; addAssetsToAlbums(ptr: PTR): I64;
} }
} }
// keep in sync with manifest.json
declare module 'main' { declare module 'main' {
// filters
export function assetFileFilter(): I32; export function assetFileFilter(): I32;
export function assetMissingTimeZoneFilter(): I32;
// updates
export function assetFavorite(): I32; export function assetFavorite(): I32;
export function assetVisibility(): I32; export function assetVisibility(): I32;
export function assetArchive(): I32; export function assetArchive(): I32;

View File

@ -1,4 +1,5 @@
import { AssetStatus, AssetVisibility, WorkflowType, wrapper } from '@immich/plugin-sdk'; import { wrapper } from '@immich/plugin-sdk';
import { AssetVisibility, WorkflowType } from '@immich/sdk';
type AssetFileFilterConfig = { type AssetFileFilterConfig = {
pattern: string; pattern: string;
@ -41,6 +42,14 @@ export const assetFileFilter = () => {
}); });
}; };
export const assetMissingTimeZoneFilter = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
const needsTimeZone = config.inverse ? true : false;
return { workflow: { continue: hasTimeZone === needsTimeZone } };
});
};
export const assetFavorite = () => { export const assetFavorite = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => { return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
const target = config.inverse ? false : true; const target = config.inverse ? false : true;
@ -89,28 +98,35 @@ export const assetLock = () => {
}; };
export const assetTrash = () => { export const assetTrash = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => ({ // TODO use trash/untrash host functions
changes: { return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
asset: config.inverse
? { deletedAt: null, status: AssetStatus.Active }
: { deletedAt: new Date().toISOString(), status: AssetStatus.Trashed },
},
}));
}; };
export const assetAddToAlbums = () => { export const assetAddToAlbums = () => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[] }>(({ config, data, functions }) => { return wrapper<WorkflowType.AssetV1, { albumIds: string[]; albumName?: string }>(({ config, data, functions }) => {
const assetId = data.asset.id;
if (config.albumIds.length === 0) { if (config.albumIds.length === 0) {
// noop if (!config.albumName) {
return {}; return {};
}
const [existing] = functions.searchAlbums({ name: config.albumName });
if (!existing) {
const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] });
config.albumIds.push(created.id);
return {};
}
config.albumIds.push(existing.id);
} }
if (config.albumIds.length === 1) { if (config.albumIds.length === 1) {
functions.albumAddAssets(config.albumIds[0], [data.asset.id]); functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
return {}; return {};
} }
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [data.asset.id] }); functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
return {}; return {};
}); });
}; };

View File

@ -2,7 +2,6 @@
"name": "@immich/plugin-sdk", "name": "@immich/plugin-sdk",
"version": "0.0.0", "version": "0.0.0",
"description": "", "description": "",
"main": "index.js",
"type": "module", "type": "module",
"exports": { "exports": {
"./host-functions": { "./host-functions": {
@ -11,7 +10,8 @@
}, },
".": { ".": {
"import": "./dist/index.js", "import": "./dist/index.js",
"types": "./dist/index.d.ts" "types": "./dist/index.d.ts",
"default": "./dist/index.js"
} }
}, },
"scripts": { "scripts": {

View File

@ -1,33 +0,0 @@
export enum WorkflowTrigger {
AssetCreate = 'AssetCreate',
PersonRecognized = 'PersonRecognized',
}
export enum WorkflowType {
AssetV1 = 'AssetV1',
AssetPersonV1 = 'AssetPersonV1',
}
export enum AssetType {
Image = 'IMAGE',
Video = 'VIDEO',
Audio = 'AUDIO',
Other = 'OTHER',
}
export enum AssetStatus {
Active = 'active',
Trashed = 'trashed',
Deleted = 'deleted',
}
export enum AssetVisibility {
Archive = 'archive',
Timeline = 'timeline',
/**
* Video part of the LivePhotos and MotionPhotos
*/
Hidden = 'hidden',
Locked = 'locked',
}

View File

@ -1,15 +1,26 @@
import { type BulkIdResponseDto, type BulkIdsDto } from '@immich/sdk'; import {
getAllAlbums,
type AlbumResponseDto,
type BulkIdResponseDto,
type BulkIdsDto,
type CreateAlbumDto,
} from '@immich/sdk';
// keep in sync with plugin-core/src/index.d.ts'; // keep in sync with plugin-core/src/index.d.ts';
declare module 'extism:host' { declare module 'extism:host' {
interface user { interface user {
albumAddAssets(ptr: PTR): I64; searchAlbums(ptr: PTR): I64;
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64; addAssetsToAlbums(ptr: PTR): I64;
} }
} }
const host = Host.getFunctions(); type AlbumsToAssets = {
type HostFunctionName = keyof typeof host; assetIds: string[];
albumIds: string[];
};
type HostFunctionSuccessResult<T> = { success: true; response: T }; type HostFunctionSuccessResult<T> = { success: true; response: T };
type HostFunctionErrorResult = { type HostFunctionErrorResult = {
success: false; success: false;
@ -20,39 +31,49 @@ type HostFunctionResult<T> =
| HostFunctionSuccessResult<T> | HostFunctionSuccessResult<T>
| HostFunctionErrorResult; | HostFunctionErrorResult;
const call = <T, R>(name: HostFunctionName, authToken: string, args: T) => { type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
const pointer1 = Memory.fromString(JSON.stringify({ authToken, args })); type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
const fn = host[name];
const handler = Memory.find(fn(pointer1.offset));
try { export const hostFunctions = (authToken: string) => {
const result = JSON.parse(handler.readString()) as HostFunctionResult<R>; const host = Host.getFunctions();
type HostFunctionName = keyof typeof host;
if (result.success) { const call = <T, R>(name: HostFunctionName, authToken: string, args: T) => {
return result.response; const pointer1 = Memory.fromString(JSON.stringify({ authToken, args }));
const fn = host[name];
const handler = Memory.find(fn(pointer1.offset));
try {
const result = JSON.parse(handler.readString()) as HostFunctionResult<R>;
if (result.success) {
return result.response;
}
throw new Error(
`Failed to call host function "${String(name)}", received ${result.status} - ${JSON.stringify(result.message)}`,
);
} finally {
handler.free();
pointer1.free();
} }
};
throw new Error( return {
`Failed to call host function "${String(name)}", received ${result.status} - ${JSON.stringify(result.message)}`, // album
); searchAlbums: (dto: AlbumSearchDto) =>
} finally { call<[AlbumSearchDto], AlbumResponseDto[]>('searchAlbums', authToken, [
handler.free(); dto,
pointer1.free(); ]),
} createAlbum: (dto: CreateAlbumDto) =>
call<[CreateAlbumDto], AlbumResponseDto>('createAlbum', authToken, [dto]),
addAssetsToAlbum: (albumId: string, assetIds: string[]) =>
call<[string, BulkIdsDto], BulkIdResponseDto[]>(
'addAssetsToAlbum',
authToken,
[albumId, { ids: assetIds }],
),
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
};
}; };
type AlbumsToAssets = {
assetIds: string[];
albumIds: string[];
};
export const hostFunctions = (authToken: string) => ({
albumAddAssets: (albumId: string, assetIds: string[]) =>
call<[string, BulkIdsDto], BulkIdResponseDto[]>(
'albumAddAssets',
authToken,
[albumId, { ids: assetIds }],
),
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
});

View File

@ -1,4 +1,3 @@
export * from 'src/enum.js';
export * from 'src/host-functions.js'; export * from 'src/host-functions.js';
export * from 'src/sdk.js'; export * from 'src/sdk.js';
export * from 'src/types.js'; export * from 'src/types.js';

View File

@ -1,9 +1,10 @@
import type { WorkflowType } from 'src/enum.js'; import type { WorkflowType } from '@immich/sdk';
import { hostFunctions } from 'src/host-functions.js'; import { hostFunctions } from 'src/host-functions.js';
import type { import type {
ConfigValue, ConfigValue,
WorkflowEventPayload, WorkflowEventPayload,
WorkflowResponse, WorkflowResponse,
WorkflowStepConfig,
} from 'src/types.js'; } from 'src/types.js';
export const wrapper = < export const wrapper = <
@ -19,19 +20,28 @@ export const wrapper = <
const input = Host.inputString(); const input = Host.inputString();
try { try {
const event = JSON.parse(input) as WorkflowEventPayload<T, TConfig>; const payload = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
// const debug = event.workflow.debug ?? false; const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
const eventConfigBefore = JSON.stringify(event.config);
console.debug( console.debug(
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${JSON.stringify(event.config)}`, `Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
); );
const response = const response = fn(event) ?? {};
fn({ ...event, functions: hostFunctions(event.workflow.authToken) }) ??
{}; // if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
console.debug( console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}`, `Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
); );
const output = JSON.stringify(response); const output = JSON.stringify(response);

View File

@ -1,10 +1,4 @@
import type { import type { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk';
AssetStatus,
AssetType,
AssetVisibility,
WorkflowTrigger,
WorkflowType,
} from 'src/enum.js';
type DeepPartial<T> = T extends Date type DeepPartial<T> = T extends Date
? T ? T
@ -21,6 +15,12 @@ export type WorkflowEventMap = {
export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T]; export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
export enum WorkflowTrigger {
AssetCreate = 'AssetCreate',
AssetMetadataExtraction = 'AssetMetadataExtraction',
PersonRecognized = 'PersonRecognized',
}
export type WorkflowEventPayload< export type WorkflowEventPayload<
T extends WorkflowType = WorkflowType, T extends WorkflowType = WorkflowType,
TConfig = WorkflowStepConfig, TConfig = WorkflowStepConfig,
@ -48,6 +48,8 @@ export type WorkflowResponse<T extends WorkflowType = WorkflowType> = {
changes?: WorkflowChanges<T>; changes?: WorkflowChanges<T>;
/** data to be passed to the next workflow step */ /** data to be passed to the next workflow step */
data?: Record<string, unknown>; data?: Record<string, unknown>;
/** update step config */
config?: WorkflowStepConfig;
}; };
export type WorkflowStepConfig = { export type WorkflowStepConfig = {
@ -66,7 +68,7 @@ export type AssetV1 = {
asset: { asset: {
id: string; id: string;
ownerId: string; ownerId: string;
type: AssetType; type: AssetTypeEnum;
originalPath: string; originalPath: string;
fileCreatedAt: string; fileCreatedAt: string;
fileModifiedAt: string; fileModifiedAt: string;
@ -83,7 +85,6 @@ export type AssetV1 = {
localDateTime: string; localDateTime: string;
stackId: string | null; stackId: string | null;
duplicateId: string | null; duplicateId: string | null;
status: AssetStatus;
visibility: AssetVisibility; visibility: AssetVisibility;
isEdited: boolean; isEdited: boolean;
exifInfo: { exifInfo: {

View File

@ -7081,6 +7081,7 @@ export enum WorkflowType {
} }
export enum WorkflowTrigger { export enum WorkflowTrigger {
AssetCreate = "AssetCreate", AssetCreate = "AssetCreate",
AssetMetadataExtraction = "AssetMetadataExtraction",
PersonRecognized = "PersonRecognized" PersonRecognized = "PersonRecognized"
} }
export enum QueueJobStatus { export enum QueueJobStatus {

View File

@ -320,6 +320,9 @@ importers:
'@immich/plugin-sdk': '@immich/plugin-sdk':
specifier: workspace:* specifier: workspace:*
version: link:../plugin-sdk version: link:../plugin-sdk
'@immich/sdk':
specifier: workspace:*
version: link:../sdk
esbuild: esbuild:
specifier: ^0.28.0 specifier: ^0.28.0
version: 0.28.0 version: 0.28.0

View File

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

View File

@ -1,6 +1,7 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { JsonSchemaDto } from 'src/dtos/json-schema.dto'; 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 { asPluginKey } from 'src/utils/workflow';
import z from 'zod'; 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 { createZodDto } from 'nestjs-zod';
import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum'; import { WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
import z from 'zod'; import z from 'zod';
const WorkflowTriggerResponseSchema = z const WorkflowTriggerResponseSchema = z

View File

@ -1,3 +1,4 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import z from 'zod'; import z from 'zod';
export enum AuthType { export enum AuthType {
@ -1164,11 +1165,6 @@ export enum PluginContext {
export const PluginContextSchema = z.enum(PluginContext).describe('Plugin context').meta({ id: 'PluginContextType' }); export const PluginContextSchema = z.enum(PluginContext).describe('Plugin context').meta({ id: 'PluginContextType' });
export enum WorkflowTrigger {
AssetCreate = 'AssetCreate',
PersonRecognized = 'PersonRecognized',
}
export const WorkflowTriggerSchema = z export const WorkflowTriggerSchema = z
.enum(WorkflowTrigger) .enum(WorkflowTrigger)
.describe('Plugin trigger type') .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[]) { private async replaceAndReturn(tx: Kysely<DB>, workflowId: string, steps?: WorkflowStepUpsert[]) {
if (steps) { if (steps) {
await tx.deleteFrom('workflow_step').where('workflowId', '=', workflowId).execute(); await tx.deleteFrom('workflow_step').where('workflowId', '=', workflowId).execute();

View File

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

View File

@ -1,9 +1,15 @@
import { CurrentPlugin } from '@extism/extism'; 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 { HttpException, UnauthorizedException } from '@nestjs/common';
import { join } from 'node:path'; import { join } from 'node:path';
import { DummyValue, OnEvent, OnJob } from 'src/decorators'; 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 { 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';
@ -15,7 +21,6 @@ import {
JobName, JobName,
JobStatus, JobStatus,
QueueName, QueueName,
WorkflowTrigger,
WorkflowType, WorkflowType,
} from 'src/enum'; } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
@ -61,7 +66,9 @@ export class WorkflowExecutionService extends BaseService {
const albumService = BaseService.create(AlbumService, this); 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), albumService.addAssets(authDto, ...args),
); );
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) => const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
@ -69,12 +76,16 @@ export class WorkflowExecutionService extends BaseService {
); );
const functions = { const functions = {
albumAddAssets, searchAlbums,
createAlbum,
addAssetsToAlbum,
addAssetsToAlbums, addAssetsToAlbums,
}; };
const stubs = { const stubs: typeof functions = {
albumAddAssets: dummy, searchAlbums: dummy,
createAlbum: dummy,
addAssetsToAlbum: dummy,
addAssetsToAlbums: dummy, addAssetsToAlbums: dummy,
}; };
@ -252,6 +263,17 @@ export class WorkflowExecutionService extends BaseService {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetCreate }); 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) { 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(
@ -286,6 +308,25 @@ export class WorkflowExecutionService extends BaseService {
await assetService.update(auth, assetId, { await assetService.update(auth, assetId, {
isFavorite: asset.isFavorite, isFavorite: asset.isFavorite,
visibility: asset.visibility, 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>; } satisfies ExecuteOptions<typeof type>;
@ -367,6 +408,10 @@ export class WorkflowExecutionService extends BaseService {
({ data } = await read(type)); ({ data } = await read(type));
} }
if (result?.config) {
await this.workflowRepository.updateStep(step.id, { config: result.config });
}
const shouldContinue = result?.workflow?.continue ?? true; const shouldContinue = result?.workflow?.continue ?? true;
if (!shouldContinue) { if (!shouldContinue) {
break; 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 { BadRequestException, Injectable } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import {
@ -11,7 +11,7 @@ import {
WorkflowTriggerResponseDto, WorkflowTriggerResponseDto,
WorkflowUpdateDto, WorkflowUpdateDto,
} from 'src/dtos/workflow.dto'; } from 'src/dtos/workflow.dto';
import { Permission, WorkflowTrigger } from 'src/enum'; import { Permission } from 'src/enum';
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository'; import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { getWorkflowTriggers, isMethodCompatible, resolveMethod } from 'src/utils/workflow'; 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 { ShallowDehydrateObject } from 'kysely';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants'; import { VECTOR_EXTENSIONS } from 'src/constants';
@ -29,7 +30,6 @@ import {
TranscodeTarget, TranscodeTarget,
UserMetadataKey, UserMetadataKey,
VideoCodec, VideoCodec,
WorkflowTrigger,
WorkflowType, WorkflowType,
} from 'src/enum'; } 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'; import { isMethodCompatible } from 'src/utils/workflow';
const tests: Array<{ trigger: WorkflowTrigger; types: WorkflowType[]; expected: boolean }> = [ 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'; import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
export const triggerMap: Record<WorkflowTrigger, WorkflowType[]> = { 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],
}; };
export const getWorkflowTriggers = () => export const getWorkflowTriggers = () =>

View File

@ -1,5 +1,6 @@
import { WorkflowTrigger } from '@immich/plugin-sdk';
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { WorkflowTrigger, WorkflowType } from 'src/enum'; import { WorkflowType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { PluginRepository } from 'src/repositories/plugin.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 { Kysely } from 'kysely';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; 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 { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository'; import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetRepository } from 'src/repositories/asset.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 { LoggingRepository } from 'src/repositories/logging.repository';
import { PluginRepository } from 'src/repositories/plugin.repository'; import { PluginRepository } from 'src/repositories/plugin.repository';
import { StorageRepository } from 'src/repositories/storage.repository'; import { StorageRepository } from 'src/repositories/storage.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { WorkflowRepository } from 'src/repositories/workflow.repository';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { WorkflowExecutionService } from 'src/services/workflow-execution.service'; import { WorkflowExecutionService } from 'src/services/workflow-execution.service';
@ -33,8 +34,9 @@ class WorkflowTestContext extends MediumTestContext<WorkflowExecutionService> {
CryptoRepository, CryptoRepository,
DatabaseRepository, DatabaseRepository,
LoggingRepository, LoggingRepository,
StorageRepository,
PluginRepository, PluginRepository,
StorageRepository,
UserRepository,
WorkflowRepository, WorkflowRepository,
], ],
mock: [ConfigRepository], mock: [ConfigRepository],
@ -231,6 +233,52 @@ describe('core plugin', () => {
}); });
describe('assetAddToAlbums', () => { 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 () => { it('should add an asset to an album', async () => {
const { user } = await ctx.newUser(); const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true }); const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });

View File

@ -2,7 +2,7 @@
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte'; import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAlbumInfo } from '@immich/sdk'; import { getAlbumInfo } from '@immich/sdk';
import { IconButton, LoadingSpinner } from '@immich/ui'; import { IconButton, Text, LoadingSpinner } from '@immich/ui';
import { mdiTrashCanOutline } from '@mdi/js'; import { mdiTrashCanOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -46,5 +46,22 @@
/> />
</div> </div>
</div> </div>
{:catch}
<div class="flex justify-between gap-2">
<div class="flex flex-col gap-1">
<Text>{$t('unknown')}</Text>
<Text color="muted" size="small" variant="italic">{albumId}</Text>
</div>
<div class="">
<IconButton
icon={mdiTrashCanOutline}
shape="round"
color="danger"
variant="ghost"
onclick={onDelete}
aria-label={$t('remove')}
/>
</div>
</div>
{/await} {/await}
</div> </div>