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; }