diff --git a/mobile/openapi/lib/model/workflow_trigger.dart b/mobile/openapi/lib/model/workflow_trigger.dart index 47bf95e05e..b56d1b0dba 100644 --- a/mobile/openapi/lib/model/workflow_trigger.dart +++ b/mobile/openapi/lib/model/workflow_trigger.dart @@ -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 = [ 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) { diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 20033cbc09..5e9b460ada 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -26355,6 +26355,7 @@ "description": "Plugin trigger type", "enum": [ "AssetCreate", + "AssetMetadataExtraction", "PersonRecognized" ], "type": "string" diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json index 7a9a5f26cd..6157005cab 100644 --- a/packages/plugin-core/manifest.json +++ b/packages/plugin-core/manifest.json @@ -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"] diff --git a/packages/plugin-core/package.json b/packages/plugin-core/package.json index 7c0bdf9af2..26b5124426 100644 --- a/packages/plugin-core/package.json +++ b/packages/plugin-core/package.json @@ -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" diff --git a/packages/plugin-core/src/index.d.ts b/packages/plugin-core/src/index.d.ts index ae45184cbe..170fa13102 100644 --- a/packages/plugin-core/src/index.d.ts +++ b/packages/plugin-core/src/index.d.ts @@ -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; diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index 2b498614fa..bcb05cfa19 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -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(({ config, data }) => { + const hasTimeZone = !!data.asset?.exifInfo?.timeZone; + const needsTimeZone = config.inverse ? true : false; + return { workflow: { continue: hasTimeZone === needsTimeZone } }; + }); +}; + export const assetFavorite = () => { return wrapper(({ config, data }) => { const target = config.inverse ? false : true; @@ -89,28 +98,35 @@ export const assetLock = () => { }; export const assetTrash = () => { - return wrapper(({ 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(() => ({})); }; export const assetAddToAlbums = () => { - return wrapper(({ config, data, functions }) => { + return wrapper(({ config, data, functions }) => { + const assetId = data.asset.id; + if (config.albumIds.length === 0) { - // noop - return {}; + 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 {}; }); }; diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 1121e42f59..7c44368fbc 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -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": { diff --git a/packages/plugin-sdk/src/enum.ts b/packages/plugin-sdk/src/enum.ts deleted file mode 100644 index a11dab64da..0000000000 --- a/packages/plugin-sdk/src/enum.ts +++ /dev/null @@ -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', -} diff --git a/packages/plugin-sdk/src/host-functions.ts b/packages/plugin-sdk/src/host-functions.ts index ab76218551..281e27c83c 100644 --- a/packages/plugin-sdk/src/host-functions.ts +++ b/packages/plugin-sdk/src/host-functions.ts @@ -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 = { success: true; response: T }; type HostFunctionErrorResult = { success: false; @@ -20,39 +31,49 @@ type HostFunctionResult = | HostFunctionSuccessResult | HostFunctionErrorResult; -const call = (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)); +type QueryParams any> = Parameters[0]; +type AlbumSearchDto = QueryParams; - try { - const result = JSON.parse(handler.readString()) as HostFunctionResult; +export const hostFunctions = (authToken: string) => { + const host = Host.getFunctions(); + type HostFunctionName = keyof typeof host; - if (result.success) { - return result.response; + const call = (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)); + + try { + const result = JSON.parse(handler.readString()) as HostFunctionResult; + + 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( - `Failed to call host function "${String(name)}", received ${result.status} - ${JSON.stringify(result.message)}`, - ); - } finally { - handler.free(); - pointer1.free(); - } + 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[]>( + '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 }]), -}); diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 6d4deb2053..c83369410a 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -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'; diff --git a/packages/plugin-sdk/src/sdk.ts b/packages/plugin-sdk/src/sdk.ts index f0ff8723a6..283e9c3dd5 100644 --- a/packages/plugin-sdk/src/sdk.ts +++ b/packages/plugin-sdk/src/sdk.ts @@ -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; - // const debug = event.workflow.debug ?? false; + const payload = JSON.parse(input) as WorkflowEventPayload; + 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); diff --git a/packages/plugin-sdk/src/types.ts b/packages/plugin-sdk/src/types.ts index 2613922e95..67c179f4a6 100644 --- a/packages/plugin-sdk/src/types.ts +++ b/packages/plugin-sdk/src/types.ts @@ -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 extends Date ? T @@ -21,6 +15,12 @@ export type WorkflowEventMap = { export type WorkflowEventData = 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 = { changes?: WorkflowChanges; /** data to be passed to the next workflow step */ data?: Record; + /** 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: { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 2e35f25f3d..e9388eb9de 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -7081,6 +7081,7 @@ export enum WorkflowType { } export enum WorkflowTrigger { AssetCreate = "AssetCreate", + AssetMetadataExtraction = "AssetMetadataExtraction", PersonRecognized = "PersonRecognized" } export enum QueueJobStatus { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 600c0b3cfe..1a76031206 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/server/src/controllers/workflow.controller.spec.ts b/server/src/controllers/workflow.controller.spec.ts index 7bc164e285..140fb00e95 100644 --- a/server/src/controllers/workflow.controller.spec.ts +++ b/server/src/controllers/workflow.controller.spec.ts @@ -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'; diff --git a/server/src/dtos/plugin.dto.ts b/server/src/dtos/plugin.dto.ts index 62ff365a43..8ee695d61c 100644 --- a/server/src/dtos/plugin.dto.ts +++ b/server/src/dtos/plugin.dto.ts @@ -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'; diff --git a/server/src/dtos/workflow.dto.ts b/server/src/dtos/workflow.dto.ts index 8a5960470d..1a2c2ac9f9 100644 --- a/server/src/dtos/workflow.dto.ts +++ b/server/src/dtos/workflow.dto.ts @@ -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 diff --git a/server/src/enum.ts b/server/src/enum.ts index baf34b806e..27cab3fb5e 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -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') diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts index 1b888b759b..9ceef72a50 100644 --- a/server/src/repositories/workflow.repository.ts +++ b/server/src/repositories/workflow.repository.ts @@ -103,6 +103,10 @@ export class WorkflowRepository { }); } + async updateStep(id: string, dto: Updateable) { + await this.db.updateTable('workflow_step').where('workflow_step.id', '=', id).set(dto).execute(); + } + private async replaceAndReturn(tx: Kysely, workflowId: string, steps?: WorkflowStepUpsert[]) { if (steps) { await tx.deleteFrom('workflow_step').where('workflowId', '=', workflowId).execute(); diff --git a/server/src/schema/tables/workflow.table.ts b/server/src/schema/tables/workflow.table.ts index 8ac89d4b65..944fffd9d5 100644 --- a/server/src/schema/tables/workflow.table.ts +++ b/server/src/schema/tables/workflow.table.ts @@ -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') diff --git a/server/src/services/workflow-execution.service.ts b/server/src/services/workflow-execution.service.ts index 0ada9e7e8f..0a5f025fc1 100644 --- a/server/src/services/workflow-execution.service.ts +++ b/server/src/services/workflow-execution.service.ts @@ -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; @@ -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; diff --git a/server/src/services/workflow.service.ts b/server/src/services/workflow.service.ts index 382ae2fe06..850b4ac086 100644 --- a/server/src/services/workflow.service.ts +++ b/server/src/services/workflow.service.ts @@ -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'; diff --git a/server/src/types.ts b/server/src/types.ts index 480bd93118..6b985af662 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -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'; diff --git a/server/src/utils/workflow.spec.ts b/server/src/utils/workflow.spec.ts index 86bdd94e5b..5defe92d90 100644 --- a/server/src/utils/workflow.spec.ts +++ b/server/src/utils/workflow.spec.ts @@ -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 }> = [ diff --git a/server/src/utils/workflow.ts b/server/src/utils/workflow.ts index 879fe4c608..5383db818e 100644 --- a/server/src/utils/workflow.ts +++ b/server/src/utils/workflow.ts @@ -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.AssetCreate]: [WorkflowType.AssetV1], [WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1], + [WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1], }; export const getWorkflowTriggers = () => diff --git a/server/test/medium/specs/services/workflow.service.spec.ts b/server/test/medium/specs/services/workflow.service.spec.ts index 07301581cb..b4b52be98c 100644 --- a/server/test/medium/specs/services/workflow.service.spec.ts +++ b/server/test/medium/specs/services/workflow.service.spec.ts @@ -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'; diff --git a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts index fffddfc32a..2bb9de6af1 100644 --- a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts +++ b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts @@ -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 { 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 }); diff --git a/web/src/lib/components/album-page/AlbumThumbnail.svelte b/web/src/lib/components/album-page/AlbumThumbnail.svelte index 037bb78ab9..e585809dec 100644 --- a/web/src/lib/components/album-page/AlbumThumbnail.svelte +++ b/web/src/lib/components/album-page/AlbumThumbnail.svelte @@ -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 @@ /> + {:catch} +
+
+ {$t('unknown')} + {albumId} +
+
+ +
+
{/await}