diff --git a/plugins/manifest.json b/plugins/manifest.json index 4d2de275ca..a9c07c6141 100644 --- a/plugins/manifest.json +++ b/plugins/manifest.json @@ -1,6 +1,6 @@ { "name": "immich-core", - "version": "2.0.1", + "version": "2.4.1 ", "title": "Immich Core", "description": "Core workflow capabilities for Immich", "author": "Immich Team", @@ -12,9 +12,7 @@ "methodName": "filterFileName", "title": "Filter by filename", "description": "Filter assets by filename pattern using text matching or regular expressions", - "supportedContexts": [ - "asset" - ], + "supportedContexts": ["asset"], "schema": { "type": "object", "properties": { @@ -26,11 +24,7 @@ "matchType": { "type": "string", "title": "Match type", - "enum": [ - "contains", - "regex", - "exact" - ], + "enum": ["contains", "regex", "exact"], "default": "contains", "description": "Type of pattern matching to perform" }, @@ -40,18 +34,14 @@ "description": "Whether matching should be case-sensitive" } }, - "required": [ - "pattern" - ] + "required": ["pattern"] } }, { "methodName": "filterFileType", "title": "Filter by file type", "description": "Filter assets by file type", - "supportedContexts": [ - "asset" - ], + "supportedContexts": ["asset"], "schema": { "type": "object", "properties": { @@ -60,26 +50,19 @@ "title": "File types", "items": { "type": "string", - "enum": [ - "image", - "video" - ] + "enum": ["image", "video"] }, "description": "Allowed file types" } }, - "required": [ - "fileTypes" - ] + "required": ["fileTypes"] } }, { "methodName": "filterPerson", "title": "Filter by person", - "description": "Filter by detected person", - "supportedContexts": [ - "person" - ], + "description": "Filter assets by detected people in the photo", + "supportedContexts": ["person"], "schema": { "type": "object", "properties": { @@ -92,15 +75,14 @@ "description": "List of person to match", "subType": "people-picker" }, - "matchAny": { - "type": "boolean", - "default": true, - "description": "Match any name (true) or require all names (false)" + "matchMode": { + "type": "string", + "title": "Match mode", + "enum": ["any", "all", "exact"], + "default": "any" } }, - "required": [ - "personIds" - ] + "required": ["personIds"] } } ], @@ -109,18 +91,14 @@ "methodName": "actionArchive", "title": "Archive", "description": "Move the asset to archive", - "supportedContexts": [ - "asset" - ], + "supportedContexts": ["asset"], "schema": {} }, { "methodName": "actionFavorite", "title": "Favorite", "description": "Mark the asset as favorite or unfavorite", - "supportedContexts": [ - "asset" - ], + "supportedContexts": ["asset"], "schema": { "type": "object", "properties": { @@ -136,10 +114,7 @@ "methodName": "actionAddToAlbum", "title": "Add to Album", "description": "Add the item to a specified album", - "supportedContexts": [ - "asset", - "person" - ], + "supportedContexts": ["asset", "person"], "schema": { "type": "object", "properties": { @@ -150,9 +125,7 @@ "subType": "album-picker" } }, - "required": [ - "albumId" - ] + "required": ["albumId"] } } ] diff --git a/plugins/src/index.d.ts b/plugins/src/index.d.ts index 7f805aafe6..9d90b26f62 100644 --- a/plugins/src/index.d.ts +++ b/plugins/src/index.d.ts @@ -1,5 +1,6 @@ declare module 'main' { export function filterFileName(): I32; + export function filterPerson(): I32; export function actionAddToAlbum(): I32; export function actionArchive(): I32; } diff --git a/plugins/src/index.ts b/plugins/src/index.ts index 9566c02cd8..137cee7087 100644 --- a/plugins/src/index.ts +++ b/plugins/src/index.ts @@ -9,6 +9,42 @@ function returnOutput(output: any) { return 0; } +export function filterPerson() { + const input = parseInput(); + + const { data, config } = input; + const { personIds, matchMode } = config; + + const faces = data.faces || []; + + if (faces.length === 0) { + return returnOutput({ passed: false }); + } + + const assetPersonIds: string[] = faces + .filter((face: { personId: string | null }) => face.personId !== null) + .map((face: { personId: string }) => face.personId); + + let passed = false; + + if (!personIds || personIds.length === 0) { + passed = true; + } else if (matchMode === 'any') { + passed = personIds.some((id: string) => assetPersonIds.includes(id)); + } else if (matchMode === 'all') { + passed = personIds.every((id: string) => assetPersonIds.includes(id)); + } else if (matchMode === 'exact') { + const uniquePersonIds = new Set(personIds); + const uniqueAssetPersonIds = new Set(assetPersonIds); + + passed = + uniquePersonIds.size === uniqueAssetPersonIds.size && + personIds.every((id: string) => uniqueAssetPersonIds.has(id)); + } + + return returnOutput({ passed }); +} + export function filterFileName() { const input = parseInput(); const { data, config } = input; diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 8ad5b96bbc..cdcd532528 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -119,6 +119,7 @@ select "asset_face"."id", "asset_face"."personId", "asset_face"."sourceType", + "asset_face"."assetId", ( select to_json(obj) diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index 100ab908c0..e82dd7a524 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -318,6 +318,7 @@ export class AlbumRepository { await db .insertInto('album_asset') .values(assetIds.map((assetId) => ({ albumId, assetId }))) + .onConflict((oc) => oc.columns(['albumId', 'assetId']).doNothing()) .execute(); } @@ -326,7 +327,11 @@ export class AlbumRepository { if (values.length === 0) { return; } - await this.db.insertInto('album_asset').values(values).execute(); + await this.db + .insertInto('album_asset') + .values(values) + .onConflict((oc) => oc.columns(['albumId', 'assetId']).doNothing()) + .execute(); } /** diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index fbc281ccb3..4c7dc1c1cd 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -44,6 +44,7 @@ type EventMap = { // asset events AssetCreate: [{ asset: Asset }]; + PersonRecognized: [{ assetId: string; ownerId: string; personId: string }]; AssetTag: [{ assetId: string }]; AssetUntag: [{ assetId: string }]; AssetHide: [{ assetId: string; userId: string }]; diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 725304938c..0fd27d187c 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -241,7 +241,7 @@ export class PersonRepository { getFaceForFacialRecognitionJob(id: string) { return this.db .selectFrom('asset_face') - .select(['asset_face.id', 'asset_face.personId', 'asset_face.sourceType']) + .select(['asset_face.id', 'asset_face.personId', 'asset_face.sourceType', 'asset_face.assetId']) .select((eb) => jsonObjectFrom( eb diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 6fa9b3fdd2..26fa019e8e 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -536,6 +536,12 @@ export class PersonService extends BaseService { if (personId) { this.logger.debug(`Assigning face ${id} to person ${personId}`); await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId }); + + await this.eventRepository.emit('PersonRecognized', { + assetId: face.assetId, + ownerId: face.asset.ownerId, + personId, + }); } return JobStatus.Success; diff --git a/server/src/services/plugin.service.ts b/server/src/services/plugin.service.ts index d82b34c847..7f0ecb3e51 100644 --- a/server/src/services/plugin.service.ts +++ b/server/src/services/plugin.service.ts @@ -17,6 +17,7 @@ import { IWorkflowJob, JobItem, JobOf, WorkflowData } from 'src/types'; interface WorkflowContext { authToken: string; asset: Asset; + faces?: { faceId: string; personId: string | null }[]; } interface PluginInput { @@ -24,6 +25,7 @@ interface PluginInput { config: T; data: { asset: Asset; + faces?: { faceId: string; personId: string | null }[]; }; } @@ -117,7 +119,9 @@ export class PluginService extends BaseService { private async loadPluginToDatabase(manifest: PluginManifestDto, basePath: string): Promise { const currentPlugin = await this.pluginRepository.getPluginByName(manifest.name); - if (currentPlugin != null && currentPlugin.version === manifest.version) { + const isDev = this.configRepository.isDev(); + + if (currentPlugin != null && currentPlugin.version === manifest.version && !isDev) { this.logger.log(`Plugin ${manifest.name} is up to date (version ${manifest.version}). Skipping`); return; } @@ -178,6 +182,14 @@ export class PluginService extends BaseService { }); } + @OnEvent({ name: 'PersonRecognized' }) + async handlePersonRecognized({ assetId, ownerId, personId }: ArgOf<'PersonRecognized'>) { + await this.handleTrigger(PluginTriggerType.PersonRecognized, { + ownerId, + event: { userId: ownerId, assetId, personId }, + }); + } + private async handleTrigger( triggerType: T, params: { ownerId: string; event: WorkflowData[T] }, @@ -230,13 +242,41 @@ export class PluginService extends BaseService { } await this.executeActions(workflowActions, context); - this.logger.debug(`Workflow ${workflowId} executed successfully`); + this.logger.debug(`Workflow ${workflowId} executed successfully for AssetCreate`); return JobStatus.Success; } case PluginTriggerType.PersonRecognized: { - this.logger.error('unimplemented'); - return JobStatus.Skipped; + const data = event as WorkflowData[PluginTriggerType.PersonRecognized]; + + const asset = await this.assetRepository.getById(data.assetId); + if (!asset) { + this.logger.error(`Asset ${data.assetId} not found for workflow ${workflowId}`); + return JobStatus.Failed; + } + + const authToken = this.cryptoRepository.signJwt({ userId: data.userId }, this.pluginJwtSecret); + + const faces = await this.personRepository.getFaces(data.assetId); + const facePayload = faces.map((face) => ({ + faceId: face.id, + personId: face.personId, + })); + + const context = { + authToken, + asset, + faces: facePayload, + }; + + const filtersPassed = await this.executeFilters(workflowFilters, context); + if (!filtersPassed) { + return JobStatus.Skipped; + } + + await this.executeActions(workflowActions, context); + this.logger.debug(`Workflow ${workflowId} executed successfully for PersonRecognized`); + return JobStatus.Success; } default: { @@ -269,6 +309,7 @@ export class PluginService extends BaseService { config: workflowFilter.filterConfig, data: { asset: context.asset, + faces: context.faces, }, }; diff --git a/server/src/types.ts b/server/src/types.ts index e404332fac..30fea9b8c5 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -265,8 +265,9 @@ export interface WorkflowData { asset: Asset; }; [PluginTriggerType.PersonRecognized]: { - personId: string; + userId: string; assetId: string; + personId: string; }; }