Compare commits

...

4 Commits

Author SHA1 Message Date
Alex Tran b75d9b74b9 feat(plugin-core): add filterByAlbum workflow step
Adds a filterByAlbum core plugin method so workflows (e.g. the
AlbumAssetAdded trigger) can be scoped to specific albums. The step
checks the asset's album membership via the searchAlbums host function
and halts the workflow when the asset is not in any of the configured
albums (or, with inverse, when it is).
2026-06-04 04:03:01 +00:00
Alex Tran 72eaba6ee2 refactor(workflow): map AlbumAssetAdded trigger to AssetV1
Drop the AssetAlbumV1 workflow type and have the AlbumAssetAdded trigger
reuse the existing asset-aware AssetV1 type, so all current AssetV1
plugin methods work with it out of the box.
2026-06-04 01:51:36 +00:00
Alex Tran 57d77ad5da Merge branch 'main' of github.com:immich-app/immich into asset-add-to-album-trigger 2026-06-03 20:42:06 -05:00
Alex Tran 18ec30c3b8 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.
2026-06-02 20:30:31 +00:00
16 changed files with 159 additions and 2 deletions
+2
View File
@@ -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",
+3
View File
@@ -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 = <WorkflowTrigger>[
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');
+2 -1
View File
@@ -26811,7 +26811,8 @@
"enum": [
"AssetCreate",
"AssetMetadataExtraction",
"PersonRecognized"
"PersonRecognized",
"AlbumAssetAdded"
],
"type": "string"
},
+27
View File
@@ -152,6 +152,33 @@
},
"uiHints": ["Filter"]
},
{
"name": "filterByAlbum",
"title": "Filter by album",
"description": "Continue only when the asset belongs to one of the selected albums",
"types": ["AssetV1"],
"hostFunctions": true,
"schema": {
"type": "object",
"properties": {
"albumIds": {
"type": "string",
"array": true,
"title": "Album IDs",
"description": "Albums to match against",
"uiHint": "AlbumId"
},
"inverse": {
"type": "boolean",
"title": "Inverse",
"description": "Continue only when the asset is NOT in the selected albums",
"default": false
}
},
"required": ["albumIds"]
},
"uiHints": ["Filter"]
},
{
"name": "assetArchive",
"title": "Archive asset",
+1
View File
@@ -13,6 +13,7 @@ declare module 'main' {
// filters
export function assetFileFilter(): I32;
export function assetMissingTimeZoneFilter(): I32;
export function filterByAlbum(): I32;
// updates
export function assetFavorite(): I32;
+14
View File
@@ -50,6 +50,20 @@ export const assetMissingTimeZoneFilter = () => {
});
};
export const filterByAlbum = () => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[]; inverse?: boolean }>(({ config, data, functions }) => {
const { albumIds = [], inverse = false } = config;
if (albumIds.length === 0) {
return {};
}
const albums = functions.searchAlbums({ assetId: data.asset.id });
const isMember = albums.some((album) => albumIds.includes(album.id));
return { workflow: { continue: isMember !== inverse } };
});
};
export const assetFavorite = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
const target = config.inverse ? false : true;
+1
View File
@@ -19,6 +19,7 @@ export enum WorkflowTrigger {
AssetCreate = 'AssetCreate',
AssetMetadataExtraction = 'AssetMetadataExtraction',
PersonRecognized = 'PersonRecognized',
AlbumAssetAdded = 'AlbumAssetAdded',
}
export type WorkflowEventPayload<
+2 -1
View File
@@ -7184,7 +7184,8 @@ export enum WorkflowType {
export enum WorkflowTrigger {
AssetCreate = "AssetCreate",
AssetMetadataExtraction = "AssetMetadataExtraction",
PersonRecognized = "PersonRecognized"
PersonRecognized = "PersonRecognized",
AlbumAssetAdded = "AlbumAssetAdded"
}
export enum QueueJobStatus {
Active = "active",
@@ -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 }];
+13
View File
@@ -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 () => {
+10
View File
@@ -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;
}
@@ -274,6 +274,11 @@ export class WorkflowExecutionService extends BaseService {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction });
}
@OnEvent({ name: 'AlbumAssetAdd' })
onAlbumAssetAdd({ userId, assetId }: ArgOf<'AlbumAssetAdd'>) {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AlbumAssetAdded });
}
private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) {
const items = await this.workflowRepository.search({ userId, trigger });
await this.jobRepository.queueAll(
+10
View File
@@ -28,6 +28,16 @@ const tests: Array<{ trigger: WorkflowTrigger; types: WorkflowType[]; expected:
types: [WorkflowType.AssetV1, WorkflowType.AssetPersonV1],
expected: true,
},
{
trigger: WorkflowTrigger.AlbumAssetAdded,
types: [WorkflowType.AssetV1],
expected: true,
},
{
trigger: WorkflowTrigger.AlbumAssetAdded,
types: [WorkflowType.AssetPersonV1],
expected: true,
},
];
describe(isMethodCompatible.name, () => {
+1
View File
@@ -6,6 +6,7 @@ export const triggerMap: Record<WorkflowTrigger, WorkflowType[]> = {
[WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1],
[WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1],
[WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1],
[WorkflowTrigger.AlbumAssetAdded]: [WorkflowType.AssetV1],
};
export const getWorkflowTriggers = () =>
@@ -332,4 +332,65 @@ describe('core plugin', () => {
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id);
});
});
describe('filterByAlbum', () => {
it('should continue when the asset is in a selected album', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { album } = await ctx.newAlbum({ ownerId: user.id }, [asset.id]);
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AlbumAssetAdded,
steps: [
{ method: 'immich-plugin-core#filterByAlbum', config: { albumIds: [album.id] } },
{ method: 'immich-plugin-core#assetFavorite' },
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
it('should stop when the asset is not in a selected album', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const [{ album }, { album: other }] = await Promise.all([
ctx.newAlbum({ ownerId: user.id }, [asset.id]),
ctx.newAlbum({ ownerId: user.id }),
]);
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AlbumAssetAdded,
steps: [
{ method: 'immich-plugin-core#filterByAlbum', config: { albumIds: [other.id] } },
{ method: 'immich-plugin-core#assetFavorite' },
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
});
it('should continue when no albums are configured', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AlbumAssetAdded,
steps: [
{ method: 'immich-plugin-core#filterByAlbum', config: { albumIds: [] } },
{ method: 'immich-plugin-core#assetFavorite' },
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
});
});
+6
View File
@@ -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;
}