From 368cb7a4ad90f8cc9abd8c0975bf9092bd36c06b Mon Sep 17 00:00:00 2001 From: Tim Jones <57257388+timjonez@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:05:55 -0700 Subject: [PATCH] feat: minimum face count per user (#27452) * add user metadata table and use to filter persons in person.getAllForUser query * update PersonRepository.getAllForUser query * remove minFaces from PersonSearchOptions interface * fix person.getAllForUser query * update types and openapi specs * add minFaces field to user settings page * remove old arg from tests * add e2e test to verify minimumFace user preference * add i18n label and description for english * update default min faces * fetch minFaces ML default and use as per-user default in frontend * update e2e tests * fix bugs in people getAllForUser query * update person getNumberOfPeople query to reflect correct number of people according to minFaces threshold * updated mobile openapi specs? * use subquery in coalesce instead of join * remove out of scope query update --- e2e/src/specs/server/api/server.e2e-spec.ts | 1 + e2e/src/specs/server/api/user.e2e-spec.ts | 15 ++++++++++++ i18n/en.json | 2 ++ mobile/openapi/lib/model/people_response.dart | 23 ++++++++++++++++++- mobile/openapi/lib/model/people_update.dart | 23 ++++++++++++++++++- .../openapi/lib/model/server_config_dto.dart | 14 ++++++++++- open-api/immich-openapi-specs.json | 19 +++++++++++++++ packages/sdk/src/fetch-client.ts | 6 +++++ server/src/dtos/server.dto.ts | 1 + server/src/dtos/user-preferences.dto.ts | 2 ++ server/src/queries/person.repository.sql | 13 ++++++++++- server/src/repositories/person.repository.ts | 15 +++++++++--- server/src/services/person.service.spec.ts | 2 -- server/src/services/person.service.ts | 2 -- server/src/services/server.service.spec.ts | 1 + server/src/services/server.service.ts | 1 + server/src/types.ts | 1 + server/src/utils/preferences.ts | 1 + .../user-settings/FeatureSettings.svelte | 7 +++++- 19 files changed, 137 insertions(+), 12 deletions(-) diff --git a/e2e/src/specs/server/api/server.e2e-spec.ts b/e2e/src/specs/server/api/server.e2e-spec.ts index 9ab2f5d823..902c5302e5 100644 --- a/e2e/src/specs/server/api/server.e2e-spec.ts +++ b/e2e/src/specs/server/api/server.e2e-spec.ts @@ -141,6 +141,7 @@ describe('/server', () => { maintenanceMode: false, mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', + minFaces: 3, }); }); }); diff --git a/e2e/src/specs/server/api/user.e2e-spec.ts b/e2e/src/specs/server/api/user.e2e-spec.ts index 8a2197efde..2dc789a91b 100644 --- a/e2e/src/specs/server/api/user.e2e-spec.ts +++ b/e2e/src/specs/server/api/user.e2e-spec.ts @@ -230,6 +230,21 @@ describe('/users', () => { const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); expect(after).toMatchObject({ download: { includeEmbeddedVideos: true } }); }); + + it('should update minimum face count to display people', async () => { + const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); + expect(before).toMatchObject({ people: { minimumFaces: 3 } }); + + const { status, body } = await request(app) + .put('/users/me/preferences') + .send({ people: { minimumFaces: 2 } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toMatchObject({ people: { minimumFaces: 2 } }); + + const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); + expect(after).toMatchObject({ people: { minimumFaces: 2 } }); + }); }); describe('GET /users/:id', () => { diff --git a/i18n/en.json b/i18n/en.json index 90beb7077e..f4ad3001c2 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1592,6 +1592,8 @@ "merge_people_prompt": "Do you want to merge these people? This action is irreversible.", "merge_people_successfully": "Merge people successfully", "merged_people_count": "Merged {count, plural, one {# person} other {# people}}", + "minFaces": "Minimum faces", + "minFaces_description": "The minimum number of recognized faces for a person to be displayed", "minimize": "Minimize", "minute": "Minute", "minutes": "Minutes", diff --git a/mobile/openapi/lib/model/people_response.dart b/mobile/openapi/lib/model/people_response.dart index 9d5d8ec18a..ba7128d932 100644 --- a/mobile/openapi/lib/model/people_response.dart +++ b/mobile/openapi/lib/model/people_response.dart @@ -14,32 +14,52 @@ class PeopleResponse { /// Returns a new [PeopleResponse] instance. PeopleResponse({ required this.enabled, + this.minimumFaces, required this.sidebarWeb, }); /// Whether people are enabled bool enabled; + /// People face threshold + /// + /// Minimum value: 1 + /// Maximum value: 9007199254740991 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + int? minimumFaces; + /// Whether people appear in web sidebar bool sidebarWeb; @override bool operator ==(Object other) => identical(this, other) || other is PeopleResponse && other.enabled == enabled && + other.minimumFaces == minimumFaces && other.sidebarWeb == sidebarWeb; @override int get hashCode => // ignore: unnecessary_parenthesis (enabled.hashCode) + + (minimumFaces == null ? 0 : minimumFaces!.hashCode) + (sidebarWeb.hashCode); @override - String toString() => 'PeopleResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + String toString() => 'PeopleResponse[enabled=$enabled, minimumFaces=$minimumFaces, sidebarWeb=$sidebarWeb]'; Map toJson() { final json = {}; json[r'enabled'] = this.enabled; + if (this.minimumFaces != null) { + json[r'minimumFaces'] = this.minimumFaces; + } else { + // json[r'minimumFaces'] = null; + } json[r'sidebarWeb'] = this.sidebarWeb; return json; } @@ -54,6 +74,7 @@ class PeopleResponse { return PeopleResponse( enabled: mapValueOfType(json, r'enabled')!, + minimumFaces: mapValueOfType(json, r'minimumFaces'), sidebarWeb: mapValueOfType(json, r'sidebarWeb')!, ); } diff --git a/mobile/openapi/lib/model/people_update.dart b/mobile/openapi/lib/model/people_update.dart index fe16479bac..05459f19f3 100644 --- a/mobile/openapi/lib/model/people_update.dart +++ b/mobile/openapi/lib/model/people_update.dart @@ -14,6 +14,7 @@ class PeopleUpdate { /// Returns a new [PeopleUpdate] instance. PeopleUpdate({ this.enabled, + this.minimumFaces, this.sidebarWeb, }); @@ -26,6 +27,18 @@ class PeopleUpdate { /// bool? enabled; + /// People face threshold + /// + /// Minimum value: 1 + /// Maximum value: 9007199254740991 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + int? minimumFaces; + /// Whether people appear in web sidebar /// /// Please note: This property should have been non-nullable! Since the specification file @@ -38,16 +51,18 @@ class PeopleUpdate { @override bool operator ==(Object other) => identical(this, other) || other is PeopleUpdate && other.enabled == enabled && + other.minimumFaces == minimumFaces && other.sidebarWeb == sidebarWeb; @override int get hashCode => // ignore: unnecessary_parenthesis (enabled == null ? 0 : enabled!.hashCode) + + (minimumFaces == null ? 0 : minimumFaces!.hashCode) + (sidebarWeb == null ? 0 : sidebarWeb!.hashCode); @override - String toString() => 'PeopleUpdate[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + String toString() => 'PeopleUpdate[enabled=$enabled, minimumFaces=$minimumFaces, sidebarWeb=$sidebarWeb]'; Map toJson() { final json = {}; @@ -56,6 +71,11 @@ class PeopleUpdate { } else { // json[r'enabled'] = null; } + if (this.minimumFaces != null) { + json[r'minimumFaces'] = this.minimumFaces; + } else { + // json[r'minimumFaces'] = null; + } if (this.sidebarWeb != null) { json[r'sidebarWeb'] = this.sidebarWeb; } else { @@ -74,6 +94,7 @@ class PeopleUpdate { return PeopleUpdate( enabled: mapValueOfType(json, r'enabled'), + minimumFaces: mapValueOfType(json, r'minimumFaces'), sidebarWeb: mapValueOfType(json, r'sidebarWeb'), ); } diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index 316edb609f..0eaaec7c7f 100644 --- a/mobile/openapi/lib/model/server_config_dto.dart +++ b/mobile/openapi/lib/model/server_config_dto.dart @@ -20,6 +20,7 @@ class ServerConfigDto { required this.maintenanceMode, required this.mapDarkStyleUrl, required this.mapLightStyleUrl, + required this.minFaces, required this.oauthButtonText, required this.publicUsers, required this.trashDays, @@ -47,6 +48,12 @@ class ServerConfigDto { /// Map light style URL String mapLightStyleUrl; + /// People min faces server default + /// + /// Minimum value: -9007199254740991 + /// Maximum value: 9007199254740991 + int minFaces; + /// OAuth button text String oauthButtonText; @@ -74,6 +81,7 @@ class ServerConfigDto { other.maintenanceMode == maintenanceMode && other.mapDarkStyleUrl == mapDarkStyleUrl && other.mapLightStyleUrl == mapLightStyleUrl && + other.minFaces == minFaces && other.oauthButtonText == oauthButtonText && other.publicUsers == publicUsers && other.trashDays == trashDays && @@ -89,13 +97,14 @@ class ServerConfigDto { (maintenanceMode.hashCode) + (mapDarkStyleUrl.hashCode) + (mapLightStyleUrl.hashCode) + + (minFaces.hashCode) + (oauthButtonText.hashCode) + (publicUsers.hashCode) + (trashDays.hashCode) + (userDeleteDelay.hashCode); @override - String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, maintenanceMode=$maintenanceMode, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]'; + String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, maintenanceMode=$maintenanceMode, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, minFaces=$minFaces, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]'; Map toJson() { final json = {}; @@ -106,6 +115,7 @@ class ServerConfigDto { json[r'maintenanceMode'] = this.maintenanceMode; json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl; json[r'mapLightStyleUrl'] = this.mapLightStyleUrl; + json[r'minFaces'] = this.minFaces; json[r'oauthButtonText'] = this.oauthButtonText; json[r'publicUsers'] = this.publicUsers; json[r'trashDays'] = this.trashDays; @@ -129,6 +139,7 @@ class ServerConfigDto { maintenanceMode: mapValueOfType(json, r'maintenanceMode')!, mapDarkStyleUrl: mapValueOfType(json, r'mapDarkStyleUrl')!, mapLightStyleUrl: mapValueOfType(json, r'mapLightStyleUrl')!, + minFaces: mapValueOfType(json, r'minFaces')!, oauthButtonText: mapValueOfType(json, r'oauthButtonText')!, publicUsers: mapValueOfType(json, r'publicUsers')!, trashDays: mapValueOfType(json, r'trashDays')!, @@ -187,6 +198,7 @@ class ServerConfigDto { 'maintenanceMode', 'mapDarkStyleUrl', 'mapLightStyleUrl', + 'minFaces', 'oauthButtonText', 'publicUsers', 'trashDays', diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d9087c375d..33eaf13fc2 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -19907,6 +19907,12 @@ "description": "Whether people are enabled", "type": "boolean" }, + "minimumFaces": { + "description": "People face threshold", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, "sidebarWeb": { "description": "Whether people appear in web sidebar", "type": "boolean" @@ -19968,6 +19974,12 @@ "description": "Whether people are enabled", "type": "boolean" }, + "minimumFaces": { + "description": "People face threshold", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, "sidebarWeb": { "description": "Whether people appear in web sidebar", "type": "boolean" @@ -21604,6 +21616,12 @@ "description": "Map light style URL", "type": "string" }, + "minFaces": { + "description": "People min faces server default", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, "oauthButtonText": { "description": "OAuth button text", "type": "string" @@ -21633,6 +21651,7 @@ "maintenanceMode", "mapDarkStyleUrl", "mapLightStyleUrl", + "minFaces", "oauthButtonText", "publicUsers", "trashDays", diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 163558e6a6..89d0e513d8 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -298,6 +298,8 @@ export type MemoriesResponse = { export type PeopleResponse = { /** Whether people are enabled */ enabled: boolean; + /** People face threshold */ + minimumFaces?: number; /** Whether people appear in web sidebar */ sidebarWeb: boolean; }; @@ -375,6 +377,8 @@ export type MemoriesUpdate = { export type PeopleUpdate = { /** Whether people are enabled */ enabled?: boolean; + /** People face threshold */ + minimumFaces?: number; /** Whether people appear in web sidebar */ sidebarWeb?: boolean; }; @@ -1963,6 +1967,8 @@ export type ServerConfigDto = { mapDarkStyleUrl: string; /** Map light style URL */ mapLightStyleUrl: string; + /** People min faces server default */ + minFaces: number; /** OAuth button text */ oauthButtonText: string; /** Whether public user registration is enabled */ diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index 6370557785..03d45fab1c 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -124,6 +124,7 @@ const ServerConfigSchema = z mapDarkStyleUrl: z.string().describe('Map dark style URL'), mapLightStyleUrl: z.string().describe('Map light style URL'), maintenanceMode: z.boolean().describe('Whether maintenance mode is active'), + minFaces: z.int().describe('People min faces server default'), }) .meta({ id: 'ServerConfigDto' }); diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index 7a7c1d2558..b8894e4d51 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -45,6 +45,7 @@ const PeopleUpdateSchema = z .object({ enabled: z.boolean().optional().describe('Whether people are enabled'), sidebarWeb: z.boolean().optional().describe('Whether people appear in web sidebar'), + minimumFaces: z.int().min(1).optional().describe('People face threshold'), }) .optional() .meta({ id: 'PeopleUpdate' }); @@ -138,6 +139,7 @@ const PeopleResponseSchema = z .object({ enabled: z.boolean().describe('Whether people are enabled'), sidebarWeb: z.boolean().describe('Whether people appear in web sidebar'), + minimumFaces: z.int().min(1).optional().describe('People face threshold'), }) .meta({ id: 'PeopleResponse' }); diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 318c151cca..a2f3f64442 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -42,7 +42,18 @@ group by having ( "person"."name" != $3 - or count("asset_face"."assetId") >= $4 + or count("asset_face"."assetId") >= COALESCE( + ( + SELECT + value -> 'people' ->> 'minimumFaces' + FROM + user_metadata + WHERE + "userId" = $4 + AND key = 'preferences' + ), + '3' + )::int ) order by "person"."isHidden" asc, diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 2a9f822e94..0db03a18c7 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -4,7 +4,7 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFace } from 'src/database'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; -import { AssetFileType, AssetVisibility, SourceType } from 'src/enum'; +import { AssetFileType, AssetVisibility, SourceType, UserMetadataKey } from 'src/enum'; import { DB } from 'src/schema'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; @@ -13,7 +13,6 @@ import { dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database'; import { paginationHelper, PaginationOptions } from 'src/utils/pagination'; export interface PersonSearchOptions { - minimumFaceCount: number; withHidden: boolean; closestFaceAssetId?: string; } @@ -168,7 +167,17 @@ export class PersonRepository { .having((eb) => eb.or([ eb('person.name', '!=', ''), - eb((innerEb) => innerEb.fn.count('asset_face.assetId'), '>=', options?.minimumFaceCount || 1), + eb( + (innerEb) => innerEb.fn.count('asset_face.assetId'), + '>=', + sql`COALESCE( + (SELECT value -> 'people' ->> 'minimumFaces' + FROM user_metadata + WHERE "userId" = ${userId} + AND key = ${sql.lit(UserMetadataKey.Preferences)}), + '3' + )::int `, + ), ]), ) .groupBy('person.id') diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 8b303d04f6..5fa7cb87bd 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -57,7 +57,6 @@ describe(PersonService.name, () => { ], }); expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, { - minimumFaceCount: 3, withHidden: true, }); }); @@ -84,7 +83,6 @@ describe(PersonService.name, () => { ], }); expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, { - minimumFaceCount: 3, withHidden: false, }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index fde5313f4d..de5767ef87 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -63,9 +63,7 @@ export class PersonService extends BaseService { } closestFaceAssetId = person.faceAssetId; } - const { machineLearning } = await this.getConfig({ withCache: false }); const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, { - minimumFaceCount: machineLearning.facialRecognition.minFaces, withHidden, closestFaceAssetId, }); diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index e02945d015..e1575a496a 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -168,6 +168,7 @@ describe(ServerService.name, () => { mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', maintenanceMode: false, + minFaces: 3, }); expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index aeeb41fcb0..3b66b677a5 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -128,6 +128,7 @@ export class ServerService extends BaseService { mapDarkStyleUrl: config.map.darkStyle, mapLightStyleUrl: config.map.lightStyle, maintenanceMode: false, + minFaces: config.machineLearning.facialRecognition.minFaces, }; } diff --git a/server/src/types.ts b/server/src/types.ts index dde279e5d8..4e5a383cca 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -539,6 +539,7 @@ export type UserPreferences = { people: { enabled: boolean; sidebarWeb: boolean; + minimumFaces: number; }; ratings: { enabled: boolean; diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index b25369670a..6b67398d23 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -21,6 +21,7 @@ const getDefaultPreferences = (): UserPreferences => { people: { enabled: true, sidebarWeb: false, + minimumFaces: 3, }, sharedLinks: { enabled: true, diff --git a/web/src/routes/(user)/user-settings/FeatureSettings.svelte b/web/src/routes/(user)/user-settings/FeatureSettings.svelte index 219552cbd2..8077ba190b 100644 --- a/web/src/routes/(user)/user-settings/FeatureSettings.svelte +++ b/web/src/routes/(user)/user-settings/FeatureSettings.svelte @@ -1,4 +1,5 @@