feat: more plugin triggers and methods (#28690)
parent
58586483dc
commit
da8505f61d
|
|
@ -24,11 +24,13 @@ class WorkflowTrigger {
|
|||
String toJson() => value;
|
||||
|
||||
static const assetCreate = WorkflowTrigger._(r'AssetCreate');
|
||||
static const assetMetadataExtraction = WorkflowTrigger._(r'AssetMetadataExtraction');
|
||||
static const personRecognized = WorkflowTrigger._(r'PersonRecognized');
|
||||
|
||||
/// List of all possible values in this [enum][WorkflowTrigger].
|
||||
static const values = <WorkflowTrigger>[
|
||||
assetCreate,
|
||||
assetMetadataExtraction,
|
||||
personRecognized,
|
||||
];
|
||||
|
||||
|
|
@ -69,6 +71,7 @@ class WorkflowTriggerTypeTransformer {
|
|||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'AssetCreate': return WorkflowTrigger.assetCreate;
|
||||
case r'AssetMetadataExtraction': return WorkflowTrigger.assetMetadataExtraction;
|
||||
case r'PersonRecognized': return WorkflowTrigger.personRecognized;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
|
|
|
|||
|
|
@ -26355,6 +26355,7 @@
|
|||
"description": "Plugin trigger type",
|
||||
"enum": [
|
||||
"AssetCreate",
|
||||
"AssetMetadataExtraction",
|
||||
"PersonRecognized"
|
||||
],
|
||||
"type": "string"
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
"wasmPath": "dist/plugin.wasm",
|
||||
"templates": [
|
||||
{
|
||||
"name": "auto-archive-screenshots",
|
||||
"title": "Auto-archive screenshots",
|
||||
"name": "screenshots-smart-album",
|
||||
"title": "Archive screenshots",
|
||||
"description": "Archive uploads with \"screenshot\" in the filename and optionally add them to an album",
|
||||
"trigger": "AssetCreate",
|
||||
"steps": [
|
||||
|
|
@ -29,6 +29,27 @@
|
|||
{
|
||||
"method": "immich-plugin-core#assetAddToAlbums",
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
|
|
@ -68,6 +89,24 @@
|
|||
},
|
||||
"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",
|
||||
"title": "Filter by file type",
|
||||
|
|
@ -189,6 +228,12 @@
|
|||
"array": true,
|
||||
"description": "Target album IDs",
|
||||
"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"]
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"license": "AGPL-3.0",
|
||||
"devDependencies": {
|
||||
"@extism/js-pdk": "^1.0.1",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@immich/plugin-sdk": "workspace:*",
|
||||
"esbuild": "^0.28.0",
|
||||
"typescript": "^6.0.0"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
// copy from
|
||||
// import '@immich/plugin-sdk/host-functions';
|
||||
// keep in sync with plugin-sdk/host-functions.ts';
|
||||
declare module 'extism:host' {
|
||||
interface user {
|
||||
albumAddAssets(ptr: PTR): I64;
|
||||
searchAlbums(ptr: PTR): I64;
|
||||
createAlbum(ptr: PTR): I64;
|
||||
addAssetsToAlbum(ptr: PTR): I64;
|
||||
addAssetsToAlbums(ptr: PTR): I64;
|
||||
}
|
||||
}
|
||||
|
||||
// keep in sync with manifest.json
|
||||
declare module 'main' {
|
||||
// filters
|
||||
export function assetFileFilter(): I32;
|
||||
export function assetMissingTimeZoneFilter(): I32;
|
||||
|
||||
// updates
|
||||
export function assetFavorite(): I32;
|
||||
export function assetVisibility(): I32;
|
||||
export function assetArchive(): I32;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
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 = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
|
||||
const target = config.inverse ? false : true;
|
||||
|
|
@ -89,28 +98,35 @@ export const assetLock = () => {
|
|||
};
|
||||
|
||||
export const assetTrash = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => ({
|
||||
changes: {
|
||||
asset: config.inverse
|
||||
? { deletedAt: null, status: AssetStatus.Active }
|
||||
: { deletedAt: new Date().toISOString(), status: AssetStatus.Trashed },
|
||||
},
|
||||
}));
|
||||
// TODO use trash/untrash host functions
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
|
||||
};
|
||||
|
||||
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) {
|
||||
// noop
|
||||
if (!config.albumName) {
|
||||
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) {
|
||||
functions.albumAddAssets(config.albumIds[0], [data.asset.id]);
|
||||
functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
|
||||
return {};
|
||||
}
|
||||
|
||||
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [data.asset.id] });
|
||||
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
|
||||
return {};
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
"name": "@immich/plugin-sdk",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./host-functions": {
|
||||
|
|
@ -11,7 +10,8 @@
|
|||
},
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
@ -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';
|
||||
declare module 'extism:host' {
|
||||
interface user {
|
||||
albumAddAssets(ptr: PTR): I64;
|
||||
searchAlbums(ptr: PTR): I64;
|
||||
createAlbum(ptr: PTR): I64;
|
||||
addAssetsToAlbum(ptr: PTR): I64;
|
||||
addAssetsToAlbums(ptr: PTR): I64;
|
||||
}
|
||||
}
|
||||
|
||||
const host = Host.getFunctions();
|
||||
type HostFunctionName = keyof typeof host;
|
||||
type AlbumsToAssets = {
|
||||
assetIds: string[];
|
||||
albumIds: string[];
|
||||
};
|
||||
|
||||
type HostFunctionSuccessResult<T> = { success: true; response: T };
|
||||
type HostFunctionErrorResult = {
|
||||
success: false;
|
||||
|
|
@ -20,7 +31,14 @@ type HostFunctionResult<T> =
|
|||
| HostFunctionSuccessResult<T>
|
||||
| HostFunctionErrorResult;
|
||||
|
||||
const call = <T, R>(name: HostFunctionName, authToken: string, args: T) => {
|
||||
type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
|
||||
type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
|
||||
|
||||
export const hostFunctions = (authToken: string) => {
|
||||
const host = Host.getFunctions();
|
||||
type HostFunctionName = keyof typeof host;
|
||||
|
||||
const call = <T, R>(name: HostFunctionName, authToken: string, args: T) => {
|
||||
const pointer1 = Memory.fromString(JSON.stringify({ authToken, args }));
|
||||
const fn = host[name];
|
||||
const handler = Memory.find(fn(pointer1.offset));
|
||||
|
|
@ -39,20 +57,23 @@ const call = <T, R>(name: HostFunctionName, authToken: string, args: T) => {
|
|||
handler.free();
|
||||
pointer1.free();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
type AlbumsToAssets = {
|
||||
assetIds: string[];
|
||||
albumIds: string[];
|
||||
};
|
||||
|
||||
export const hostFunctions = (authToken: string) => ({
|
||||
albumAddAssets: (albumId: string, assetIds: string[]) =>
|
||||
return {
|
||||
// album
|
||||
searchAlbums: (dto: AlbumSearchDto) =>
|
||||
call<[AlbumSearchDto], AlbumResponseDto[]>('searchAlbums', authToken, [
|
||||
dto,
|
||||
]),
|
||||
createAlbum: (dto: CreateAlbumDto) =>
|
||||
call<[CreateAlbumDto], AlbumResponseDto>('createAlbum', authToken, [dto]),
|
||||
addAssetsToAlbum: (albumId: string, assetIds: string[]) =>
|
||||
call<[string, BulkIdsDto], BulkIdResponseDto[]>(
|
||||
'albumAddAssets',
|
||||
'addAssetsToAlbum',
|
||||
authToken,
|
||||
[albumId, { ids: assetIds }],
|
||||
),
|
||||
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
|
||||
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
|
||||
});
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
export * from 'src/enum.js';
|
||||
export * from 'src/host-functions.js';
|
||||
export * from 'src/sdk.js';
|
||||
export * from 'src/types.js';
|
||||
|
|
|
|||
|
|
@ -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 type {
|
||||
ConfigValue,
|
||||
WorkflowEventPayload,
|
||||
WorkflowResponse,
|
||||
WorkflowStepConfig,
|
||||
} from 'src/types.js';
|
||||
|
||||
export const wrapper = <
|
||||
|
|
@ -19,19 +20,28 @@ export const wrapper = <
|
|||
const input = Host.inputString();
|
||||
|
||||
try {
|
||||
const event = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
|
||||
// const debug = event.workflow.debug ?? false;
|
||||
const payload = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
|
||||
const event = {
|
||||
...payload,
|
||||
functions: hostFunctions(payload.workflow.authToken),
|
||||
};
|
||||
|
||||
const eventConfigBefore = JSON.stringify(event.config);
|
||||
|
||||
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 =
|
||||
fn({ ...event, functions: hostFunctions(event.workflow.authToken) }) ??
|
||||
{};
|
||||
const response = fn(event) ?? {};
|
||||
|
||||
// if config changed, notify host
|
||||
const eventConfigAfter = JSON.stringify(event.config);
|
||||
if (!response.config && eventConfigBefore !== eventConfigAfter) {
|
||||
response.config = event.config as WorkflowStepConfig;
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,4 @@
|
|||
import type {
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
WorkflowTrigger,
|
||||
WorkflowType,
|
||||
} from 'src/enum.js';
|
||||
import type { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk';
|
||||
|
||||
type DeepPartial<T> = T extends Date
|
||||
? T
|
||||
|
|
@ -21,6 +15,12 @@ export type WorkflowEventMap = {
|
|||
|
||||
export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
|
||||
|
||||
export enum WorkflowTrigger {
|
||||
AssetCreate = 'AssetCreate',
|
||||
AssetMetadataExtraction = 'AssetMetadataExtraction',
|
||||
PersonRecognized = 'PersonRecognized',
|
||||
}
|
||||
|
||||
export type WorkflowEventPayload<
|
||||
T extends WorkflowType = WorkflowType,
|
||||
TConfig = WorkflowStepConfig,
|
||||
|
|
@ -48,6 +48,8 @@ export type WorkflowResponse<T extends WorkflowType = WorkflowType> = {
|
|||
changes?: WorkflowChanges<T>;
|
||||
/** data to be passed to the next workflow step */
|
||||
data?: Record<string, unknown>;
|
||||
/** update step config */
|
||||
config?: WorkflowStepConfig;
|
||||
};
|
||||
|
||||
export type WorkflowStepConfig = {
|
||||
|
|
@ -66,7 +68,7 @@ export type AssetV1 = {
|
|||
asset: {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
type: AssetType;
|
||||
type: AssetTypeEnum;
|
||||
originalPath: string;
|
||||
fileCreatedAt: string;
|
||||
fileModifiedAt: string;
|
||||
|
|
@ -83,7 +85,6 @@ export type AssetV1 = {
|
|||
localDateTime: string;
|
||||
stackId: string | null;
|
||||
duplicateId: string | null;
|
||||
status: AssetStatus;
|
||||
visibility: AssetVisibility;
|
||||
isEdited: boolean;
|
||||
exifInfo: {
|
||||
|
|
|
|||
|
|
@ -7081,6 +7081,7 @@ export enum WorkflowType {
|
|||
}
|
||||
export enum WorkflowTrigger {
|
||||
AssetCreate = "AssetCreate",
|
||||
AssetMetadataExtraction = "AssetMetadataExtraction",
|
||||
PersonRecognized = "PersonRecognized"
|
||||
}
|
||||
export enum QueueJobStatus {
|
||||
|
|
|
|||
|
|
@ -320,6 +320,9 @@ importers:
|
|||
'@immich/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../plugin-sdk
|
||||
'@immich/sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../sdk
|
||||
esbuild:
|
||||
specifier: ^0.28.0
|
||||
version: 0.28.0
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }> = [
|
||||
|
|
|
|||
|
|
@ -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 = () =>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { getAlbumInfo } from '@immich/sdk';
|
||||
import { IconButton, LoadingSpinner } from '@immich/ui';
|
||||
import { IconButton, Text, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiTrashCanOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
|
|
@ -46,5 +46,22 @@
|
|||
/>
|
||||
</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}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue