From a6669e0ca838f6f080842cd63db0cc7802356da8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 20:30:31 +0000 Subject: [PATCH] feat(workflow): add trigger for assets added to an album Introduces the AlbumAssetAdded workflow trigger and a new AssetAlbumV1 workflow type that carries album context (id, owner, name, description) alongside the asset, mirroring AssetPersonV1. A new AlbumAssetAdd event is emitted from AlbumService when assets are added to an album (both addAssets and addAssetsToAlbums), and WorkflowExecutionService queues the workflow trigger with the album id so steps can act on a specific album. --- i18n/en.json | 2 + .../openapi/lib/model/workflow_trigger.dart | 3 + mobile/openapi/lib/model/workflow_type.dart | 3 + open-api/immich-openapi-specs.json | 6 +- packages/plugin-sdk/src/types.ts | 11 +++ packages/sdk/src/fetch-client.ts | 6 +- server/src/enum.ts | 1 + server/src/repositories/event.repository.ts | 1 + .../src/repositories/workflow.repository.ts | 12 +++ server/src/services/album.service.spec.ts | 13 +++ server/src/services/album.service.ts | 10 +++ .../services/workflow-execution.service.ts | 90 ++++++++++++------- server/src/types.ts | 2 +- server/src/utils/workflow.spec.ts | 15 ++++ server/src/utils/workflow.ts | 2 + web/src/lib/utils/workflow.ts | 6 ++ 16 files changed, 147 insertions(+), 36 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index f4ad3001c2..6ec23fa08c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2364,6 +2364,8 @@ "trash_page_title": "Trash ({count})", "trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.", "trigger": "Trigger", + "trigger_album_asset_added": "Asset Added to Album", + "trigger_album_asset_added_description": "Triggered when an asset is added to an album", "trigger_asset_uploaded": "Asset Upload", "trigger_asset_uploaded_description": "Triggered when a new asset is uploaded", "trigger_description": "An event that kicks off the workflow", diff --git a/mobile/openapi/lib/model/workflow_trigger.dart b/mobile/openapi/lib/model/workflow_trigger.dart index b56d1b0dba..3bc76178d0 100644 --- a/mobile/openapi/lib/model/workflow_trigger.dart +++ b/mobile/openapi/lib/model/workflow_trigger.dart @@ -26,12 +26,14 @@ class WorkflowTrigger { static const assetCreate = WorkflowTrigger._(r'AssetCreate'); static const assetMetadataExtraction = WorkflowTrigger._(r'AssetMetadataExtraction'); static const personRecognized = WorkflowTrigger._(r'PersonRecognized'); + static const albumAssetAdded = WorkflowTrigger._(r'AlbumAssetAdded'); /// List of all possible values in this [enum][WorkflowTrigger]. static const values = [ assetCreate, assetMetadataExtraction, personRecognized, + albumAssetAdded, ]; static WorkflowTrigger? fromJson(dynamic value) => WorkflowTriggerTypeTransformer().decode(value); @@ -73,6 +75,7 @@ class WorkflowTriggerTypeTransformer { case r'AssetCreate': return WorkflowTrigger.assetCreate; case r'AssetMetadataExtraction': return WorkflowTrigger.assetMetadataExtraction; case r'PersonRecognized': return WorkflowTrigger.personRecognized; + case r'AlbumAssetAdded': return WorkflowTrigger.albumAssetAdded; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/workflow_type.dart b/mobile/openapi/lib/model/workflow_type.dart index c18b07e9fb..170c0715bc 100644 --- a/mobile/openapi/lib/model/workflow_type.dart +++ b/mobile/openapi/lib/model/workflow_type.dart @@ -25,11 +25,13 @@ class WorkflowType { static const assetV1 = WorkflowType._(r'AssetV1'); static const assetPersonV1 = WorkflowType._(r'AssetPersonV1'); + static const assetAlbumV1 = WorkflowType._(r'AssetAlbumV1'); /// List of all possible values in this [enum][WorkflowType]. static const values = [ assetV1, assetPersonV1, + assetAlbumV1, ]; static WorkflowType? fromJson(dynamic value) => WorkflowTypeTypeTransformer().decode(value); @@ -70,6 +72,7 @@ class WorkflowTypeTypeTransformer { switch (data) { case r'AssetV1': return WorkflowType.assetV1; case r'AssetPersonV1': return WorkflowType.assetPersonV1; + case r'AssetAlbumV1': return WorkflowType.assetAlbumV1; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 33eaf13fc2..7f4fff9cf2 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -26811,7 +26811,8 @@ "enum": [ "AssetCreate", "AssetMetadataExtraction", - "PersonRecognized" + "PersonRecognized", + "AlbumAssetAdded" ], "type": "string" }, @@ -26839,7 +26840,8 @@ "description": "Workflow type", "enum": [ "AssetV1", - "AssetPersonV1" + "AssetPersonV1", + "AssetAlbumV1" ], "type": "string" }, diff --git a/packages/plugin-sdk/src/types.ts b/packages/plugin-sdk/src/types.ts index 67c179f4a6..21962fb3ab 100644 --- a/packages/plugin-sdk/src/types.ts +++ b/packages/plugin-sdk/src/types.ts @@ -11,6 +11,7 @@ type DeepPartial = T extends Date export type WorkflowEventMap = { [WorkflowType.AssetV1]: AssetV1; [WorkflowType.AssetPersonV1]: AssetPersonV1; + [WorkflowType.AssetAlbumV1]: AssetAlbumV1; }; export type WorkflowEventData = WorkflowEventMap[T]; @@ -19,6 +20,7 @@ export enum WorkflowTrigger { AssetCreate = 'AssetCreate', AssetMetadataExtraction = 'AssetMetadataExtraction', PersonRecognized = 'PersonRecognized', + AlbumAssetAdded = 'AlbumAssetAdded', } export type WorkflowEventPayload< @@ -128,3 +130,12 @@ export type AssetPersonV1 = AssetV1 & { name: string; }; }; + +export type AssetAlbumV1 = AssetV1 & { + album: { + id: string; + ownerId: string; + albumName: string; + description: string; + }; +}; diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 89d0e513d8..5923539e6f 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -7179,12 +7179,14 @@ export enum PartnerDirection { } export enum WorkflowType { AssetV1 = "AssetV1", - AssetPersonV1 = "AssetPersonV1" + AssetPersonV1 = "AssetPersonV1", + AssetAlbumV1 = "AssetAlbumV1" } export enum WorkflowTrigger { AssetCreate = "AssetCreate", AssetMetadataExtraction = "AssetMetadataExtraction", - PersonRecognized = "PersonRecognized" + PersonRecognized = "PersonRecognized", + AlbumAssetAdded = "AlbumAssetAdded" } export enum QueueJobStatus { Active = "active", diff --git a/server/src/enum.ts b/server/src/enum.ts index 9dee1db313..79d0c9bfcf 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -1172,6 +1172,7 @@ export const WorkflowTriggerSchema = z export enum WorkflowType { AssetV1 = 'AssetV1', AssetPersonV1 = 'AssetPersonV1', + AssetAlbumV1 = 'AssetAlbumV1', } export const WorkflowTypeSchema = z.enum(WorkflowType).describe('Workflow type').meta({ id: 'WorkflowType' }); diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index b4d968599b..e2098837e9 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -40,6 +40,7 @@ type EventMap = { // album events AlbumUpdate: [{ id: string; recipientId: string }]; AlbumInvite: [{ id: string; userId: string; senderName: string }]; + AlbumAssetAdd: [{ albumId: string; assetId: string; userId: string }]; // asset events AssetCreate: [{ asset: Asset; file: UploadFile }]; diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts index 9ceef72a50..f1669c3e09 100644 --- a/server/src/repositories/workflow.repository.ts +++ b/server/src/repositories/workflow.repository.ts @@ -5,6 +5,7 @@ import { InjectKysely } from 'nestjs-kysely'; import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; import { WorkflowSearchDto } from 'src/dtos/workflow.dto'; +import { AlbumUserRole } from 'src/enum'; import { DB } from 'src/schema'; import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table'; import { WorkflowTable } from 'src/schema/tables/workflow.table'; @@ -182,4 +183,15 @@ export class WorkflowRepository { .where('id', '=', assetId) .executeTakeFirstOrThrow(); } + + getAlbumForWorkflow(albumId: string) { + return this.db + .selectFrom('album') + .innerJoin('album_user', 'album_user.albumId', 'album.id') + .where('album_user.role', '=', AlbumUserRole.Owner) + .select(['album.id', 'album_user.userId as ownerId', 'album.albumName', 'album.description']) + .where('album.id', '=', albumId) + .where('album.deletedAt', 'is', null) + .executeTakeFirstOrThrow(); + } } diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 288c3c1d3c..02acb7bd63 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -742,6 +742,9 @@ describe(AlbumService.name, () => { owner.id, ); expect(mocks.album.addAssetIds).toHaveBeenCalledWith(album.id, [asset1.id, asset2.id, asset3.id]); + for (const assetId of [asset1.id, asset2.id, asset3.id]) { + expect(mocks.event.emit).toHaveBeenCalledWith('AlbumAssetAdd', { albumId: album.id, assetId, userId: owner.id }); + } }); it('should not set the thumbnail if the album has one already', async () => { @@ -1055,6 +1058,16 @@ describe(AlbumService.name, () => { id: album2.id, recipientId: owner2.id, }); + for (const { albumId, assetId } of [ + { albumId: album1.id, assetId: asset1.id }, + { albumId: album1.id, assetId: asset2.id }, + { albumId: album1.id, assetId: asset3.id }, + { albumId: album2.id, assetId: asset1.id }, + { albumId: album2.id, assetId: asset2.id }, + { albumId: album2.id, assetId: asset3.id }, + ]) { + expect(mocks.event.emit).toHaveBeenCalledWith('AlbumAssetAdd', { albumId, assetId, userId: user.id }); + } }); it('should not allow a shared user with viewer access to add assets', async () => { diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 31c4ff2e38..7d8614e7c2 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -201,6 +201,12 @@ export class AlbumService extends BaseService { } } + for (const { id: assetId, success } of results) { + if (success) { + await this.eventRepository.emit('AlbumAssetAdd', { albumId: id, assetId, userId: auth.user.id }); + } + } + return results; } @@ -261,6 +267,10 @@ export class AlbumService extends BaseService { } } + for (const { albumId, assetId } of albumAssetValues) { + await this.eventRepository.emit('AlbumAssetAdd', { albumId, assetId, userId: auth.user.id }); + } + return results; } diff --git a/server/src/services/workflow-execution.service.ts b/server/src/services/workflow-execution.service.ts index 0a5f025fc1..231ea7039c 100644 --- a/server/src/services/workflow-execution.service.ts +++ b/server/src/services/workflow-execution.service.ts @@ -40,7 +40,7 @@ type ExecuteOptions = { write: (auth: AuthDto, changes: WorkflowChanges) => Promise; }; -type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger }; +type AssetTrigger = { userId: string; assetId: string; albumId?: string; trigger: WorkflowTrigger }; export class WorkflowExecutionService extends BaseService { private jwtSecret!: string; @@ -274,21 +274,57 @@ export class WorkflowExecutionService extends BaseService { return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction }); } - private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) { + @OnEvent({ name: 'AlbumAssetAdd' }) + onAlbumAssetAdd({ userId, assetId, albumId }: ArgOf<'AlbumAssetAdd'>) { + return this.onAssetTrigger({ userId, assetId, albumId, trigger: WorkflowTrigger.AlbumAssetAdded }); + } + + private async onAssetTrigger({ userId, assetId, albumId, trigger }: AssetTrigger) { const items = await this.workflowRepository.search({ userId, trigger }); await this.jobRepository.queueAll( items.map((workflow) => ({ name: JobName.WorkflowAssetTrigger, - data: { workflowId: workflow.id, assetId, trigger }, + data: { workflowId: workflow.id, assetId, albumId, trigger }, })), ); } @OnJob({ name: JobName.WorkflowAssetTrigger, queue: QueueName.Workflow }) - handleAssetTrigger({ workflowId, assetId }: JobOf) { + handleAssetTrigger({ workflowId, assetId, albumId }: JobOf) { return this.execute(workflowId, (type) => { const assetService = BaseService.create(AssetService, this); + const writeAsset: ExecuteOptions['write'] = async (auth, changes) => { + const asset = changes.asset; + if (!asset) { + return; + } + + 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, + }); + }; + switch (type) { case WorkflowType.AssetV1: { return { @@ -299,36 +335,28 @@ export class WorkflowExecutionService extends BaseService { authUserId: asset.ownerId, }; }, - write: async (auth, changes) => { - const asset = changes.asset; - if (!asset) { - return; - } + write: writeAsset, + } satisfies ExecuteOptions; + } - 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, + case WorkflowType.AssetAlbumV1: { + if (!albumId) { + this.logger.error(`Misconfigured workflow ${workflowId}: missing albumId for type ${type}`); + return; + } - // 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, - }); + return { + read: async () => { + const [asset, album] = await Promise.all([ + this.workflowRepository.getForAssetV1(assetId), + this.workflowRepository.getAlbumForWorkflow(albumId), + ]); + return { + data: { asset, album } as any, + authUserId: asset.ownerId, + }; }, + write: writeAsset, } satisfies ExecuteOptions; } } diff --git a/server/src/types.ts b/server/src/types.ts index 4e5a383cca..96110e791a 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -420,7 +420,7 @@ export type JobItem = | { name: JobName.Ocr; data: IEntityJob } // Workflow - | { name: JobName.WorkflowAssetTrigger; data: { workflowId: string; assetId: string } } + | { name: JobName.WorkflowAssetTrigger; data: { workflowId: string; assetId: string; albumId?: string } } // Editor | { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob }; diff --git a/server/src/utils/workflow.spec.ts b/server/src/utils/workflow.spec.ts index 5defe92d90..ff5a645ffe 100644 --- a/server/src/utils/workflow.spec.ts +++ b/server/src/utils/workflow.spec.ts @@ -28,6 +28,21 @@ const tests: Array<{ trigger: WorkflowTrigger; types: WorkflowType[]; expected: types: [WorkflowType.AssetV1, WorkflowType.AssetPersonV1], expected: true, }, + { + trigger: WorkflowTrigger.AlbumAssetAdded, + types: [WorkflowType.AssetAlbumV1], + expected: true, + }, + { + trigger: WorkflowTrigger.AlbumAssetAdded, + types: [WorkflowType.AssetV1], + expected: false, + }, + { + trigger: WorkflowTrigger.AssetCreate, + types: [WorkflowType.AssetAlbumV1], + expected: true, + }, ]; describe(isMethodCompatible.name, () => { diff --git a/server/src/utils/workflow.ts b/server/src/utils/workflow.ts index 5383db818e..1f83f6b789 100644 --- a/server/src/utils/workflow.ts +++ b/server/src/utils/workflow.ts @@ -6,6 +6,7 @@ export const triggerMap: Record = { [WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1], [WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1], [WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1], + [WorkflowTrigger.AlbumAssetAdded]: [WorkflowType.AssetAlbumV1], }; export const getWorkflowTriggers = () => @@ -15,6 +16,7 @@ export const getWorkflowTriggers = () => const inferredMap: Record = { [WorkflowType.AssetV1]: [], [WorkflowType.AssetPersonV1]: [WorkflowType.AssetV1], + [WorkflowType.AssetAlbumV1]: [WorkflowType.AssetV1], }; const withImpliedItems = (type: WorkflowType): WorkflowType[] => { diff --git a/web/src/lib/utils/workflow.ts b/web/src/lib/utils/workflow.ts index 85127a01c0..532b56a7ec 100644 --- a/web/src/lib/utils/workflow.ts +++ b/web/src/lib/utils/workflow.ts @@ -9,6 +9,9 @@ export const getTriggerName = ($t: MessageFormatter, type: WorkflowTrigger) => { case WorkflowTrigger.PersonRecognized: { return $t('trigger_person_recognized'); } + case WorkflowTrigger.AlbumAssetAdded: { + return $t('trigger_album_asset_added'); + } default: { return type; } @@ -23,6 +26,9 @@ export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigge case WorkflowTrigger.PersonRecognized: { return $t('trigger_person_recognized_description'); } + case WorkflowTrigger.AlbumAssetAdded: { + return $t('trigger_album_asset_added_description'); + } default: { return type; }