mirror of
https://github.com/immich-app/immich.git
synced 2026-06-04 13:55:19 -04:00
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.
This commit is contained in:
@@ -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
@@ -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');
|
||||
|
||||
+3
@@ -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 = <WorkflowType>[
|
||||
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');
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ type DeepPartial<T> = T extends Date
|
||||
export type WorkflowEventMap = {
|
||||
[WorkflowType.AssetV1]: AssetV1;
|
||||
[WorkflowType.AssetPersonV1]: AssetPersonV1;
|
||||
[WorkflowType.AssetAlbumV1]: AssetAlbumV1;
|
||||
};
|
||||
|
||||
export type WorkflowEventData<T extends WorkflowType> = 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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ type ExecuteOptions<T extends WorkflowType> = {
|
||||
write: (auth: AuthDto, changes: WorkflowChanges<T>) => Promise<void>;
|
||||
};
|
||||
|
||||
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<JobName.WorkflowAssetTrigger>) {
|
||||
handleAssetTrigger({ workflowId, assetId, albumId }: JobOf<JobName.WorkflowAssetTrigger>) {
|
||||
return this.execute(workflowId, (type) => {
|
||||
const assetService = BaseService.create(AssetService, this);
|
||||
|
||||
const writeAsset: ExecuteOptions<WorkflowType.AssetV1>['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<typeof type>;
|
||||
}
|
||||
|
||||
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<typeof type>;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 };
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
@@ -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.AssetAlbumV1],
|
||||
};
|
||||
|
||||
export const getWorkflowTriggers = () =>
|
||||
@@ -15,6 +16,7 @@ export const getWorkflowTriggers = () =>
|
||||
const inferredMap: Record<WorkflowType, WorkflowType[]> = {
|
||||
[WorkflowType.AssetV1]: [],
|
||||
[WorkflowType.AssetPersonV1]: [WorkflowType.AssetV1],
|
||||
[WorkflowType.AssetAlbumV1]: [WorkflowType.AssetV1],
|
||||
};
|
||||
|
||||
const withImpliedItems = (type: WorkflowType): WorkflowType[] => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user