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
pull/28771/head
Tim Jones 2026-06-02 09:05:55 -07:00 committed by GitHub
parent 109e0a7ad0
commit 368cb7a4ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 137 additions and 12 deletions

View File

@ -141,6 +141,7 @@ describe('/server', () => {
maintenanceMode: false, maintenanceMode: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
minFaces: 3,
}); });
}); });
}); });

View File

@ -230,6 +230,21 @@ describe('/users', () => {
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ download: { includeEmbeddedVideos: true } }); 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', () => { describe('GET /users/:id', () => {

View File

@ -1592,6 +1592,8 @@
"merge_people_prompt": "Do you want to merge these people? This action is irreversible.", "merge_people_prompt": "Do you want to merge these people? This action is irreversible.",
"merge_people_successfully": "Merge people successfully", "merge_people_successfully": "Merge people successfully",
"merged_people_count": "Merged {count, plural, one {# person} other {# people}}", "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", "minimize": "Minimize",
"minute": "Minute", "minute": "Minute",
"minutes": "Minutes", "minutes": "Minutes",

View File

@ -14,32 +14,52 @@ class PeopleResponse {
/// Returns a new [PeopleResponse] instance. /// Returns a new [PeopleResponse] instance.
PeopleResponse({ PeopleResponse({
required this.enabled, required this.enabled,
this.minimumFaces,
required this.sidebarWeb, required this.sidebarWeb,
}); });
/// Whether people are enabled /// Whether people are enabled
bool 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 /// Whether people appear in web sidebar
bool sidebarWeb; bool sidebarWeb;
@override @override
bool operator ==(Object other) => identical(this, other) || other is PeopleResponse && bool operator ==(Object other) => identical(this, other) || other is PeopleResponse &&
other.enabled == enabled && other.enabled == enabled &&
other.minimumFaces == minimumFaces &&
other.sidebarWeb == sidebarWeb; other.sidebarWeb == sidebarWeb;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(enabled.hashCode) + (enabled.hashCode) +
(minimumFaces == null ? 0 : minimumFaces!.hashCode) +
(sidebarWeb.hashCode); (sidebarWeb.hashCode);
@override @override
String toString() => 'PeopleResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]'; String toString() => 'PeopleResponse[enabled=$enabled, minimumFaces=$minimumFaces, sidebarWeb=$sidebarWeb]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'enabled'] = this.enabled; json[r'enabled'] = this.enabled;
if (this.minimumFaces != null) {
json[r'minimumFaces'] = this.minimumFaces;
} else {
// json[r'minimumFaces'] = null;
}
json[r'sidebarWeb'] = this.sidebarWeb; json[r'sidebarWeb'] = this.sidebarWeb;
return json; return json;
} }
@ -54,6 +74,7 @@ class PeopleResponse {
return PeopleResponse( return PeopleResponse(
enabled: mapValueOfType<bool>(json, r'enabled')!, enabled: mapValueOfType<bool>(json, r'enabled')!,
minimumFaces: mapValueOfType<int>(json, r'minimumFaces'),
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb')!, sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb')!,
); );
} }

View File

@ -14,6 +14,7 @@ class PeopleUpdate {
/// Returns a new [PeopleUpdate] instance. /// Returns a new [PeopleUpdate] instance.
PeopleUpdate({ PeopleUpdate({
this.enabled, this.enabled,
this.minimumFaces,
this.sidebarWeb, this.sidebarWeb,
}); });
@ -26,6 +27,18 @@ class PeopleUpdate {
/// ///
bool? 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 /// Whether people appear in web sidebar
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
@ -38,16 +51,18 @@ class PeopleUpdate {
@override @override
bool operator ==(Object other) => identical(this, other) || other is PeopleUpdate && bool operator ==(Object other) => identical(this, other) || other is PeopleUpdate &&
other.enabled == enabled && other.enabled == enabled &&
other.minimumFaces == minimumFaces &&
other.sidebarWeb == sidebarWeb; other.sidebarWeb == sidebarWeb;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(enabled == null ? 0 : enabled!.hashCode) + (enabled == null ? 0 : enabled!.hashCode) +
(minimumFaces == null ? 0 : minimumFaces!.hashCode) +
(sidebarWeb == null ? 0 : sidebarWeb!.hashCode); (sidebarWeb == null ? 0 : sidebarWeb!.hashCode);
@override @override
String toString() => 'PeopleUpdate[enabled=$enabled, sidebarWeb=$sidebarWeb]'; String toString() => 'PeopleUpdate[enabled=$enabled, minimumFaces=$minimumFaces, sidebarWeb=$sidebarWeb]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -56,6 +71,11 @@ class PeopleUpdate {
} else { } else {
// json[r'enabled'] = null; // json[r'enabled'] = null;
} }
if (this.minimumFaces != null) {
json[r'minimumFaces'] = this.minimumFaces;
} else {
// json[r'minimumFaces'] = null;
}
if (this.sidebarWeb != null) { if (this.sidebarWeb != null) {
json[r'sidebarWeb'] = this.sidebarWeb; json[r'sidebarWeb'] = this.sidebarWeb;
} else { } else {
@ -74,6 +94,7 @@ class PeopleUpdate {
return PeopleUpdate( return PeopleUpdate(
enabled: mapValueOfType<bool>(json, r'enabled'), enabled: mapValueOfType<bool>(json, r'enabled'),
minimumFaces: mapValueOfType<int>(json, r'minimumFaces'),
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb'), sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb'),
); );
} }

View File

@ -20,6 +20,7 @@ class ServerConfigDto {
required this.maintenanceMode, required this.maintenanceMode,
required this.mapDarkStyleUrl, required this.mapDarkStyleUrl,
required this.mapLightStyleUrl, required this.mapLightStyleUrl,
required this.minFaces,
required this.oauthButtonText, required this.oauthButtonText,
required this.publicUsers, required this.publicUsers,
required this.trashDays, required this.trashDays,
@ -47,6 +48,12 @@ class ServerConfigDto {
/// Map light style URL /// Map light style URL
String mapLightStyleUrl; String mapLightStyleUrl;
/// People min faces server default
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int minFaces;
/// OAuth button text /// OAuth button text
String oauthButtonText; String oauthButtonText;
@ -74,6 +81,7 @@ class ServerConfigDto {
other.maintenanceMode == maintenanceMode && other.maintenanceMode == maintenanceMode &&
other.mapDarkStyleUrl == mapDarkStyleUrl && other.mapDarkStyleUrl == mapDarkStyleUrl &&
other.mapLightStyleUrl == mapLightStyleUrl && other.mapLightStyleUrl == mapLightStyleUrl &&
other.minFaces == minFaces &&
other.oauthButtonText == oauthButtonText && other.oauthButtonText == oauthButtonText &&
other.publicUsers == publicUsers && other.publicUsers == publicUsers &&
other.trashDays == trashDays && other.trashDays == trashDays &&
@ -89,13 +97,14 @@ class ServerConfigDto {
(maintenanceMode.hashCode) + (maintenanceMode.hashCode) +
(mapDarkStyleUrl.hashCode) + (mapDarkStyleUrl.hashCode) +
(mapLightStyleUrl.hashCode) + (mapLightStyleUrl.hashCode) +
(minFaces.hashCode) +
(oauthButtonText.hashCode) + (oauthButtonText.hashCode) +
(publicUsers.hashCode) + (publicUsers.hashCode) +
(trashDays.hashCode) + (trashDays.hashCode) +
(userDeleteDelay.hashCode); (userDeleteDelay.hashCode);
@override @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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -106,6 +115,7 @@ class ServerConfigDto {
json[r'maintenanceMode'] = this.maintenanceMode; json[r'maintenanceMode'] = this.maintenanceMode;
json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl; json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl;
json[r'mapLightStyleUrl'] = this.mapLightStyleUrl; json[r'mapLightStyleUrl'] = this.mapLightStyleUrl;
json[r'minFaces'] = this.minFaces;
json[r'oauthButtonText'] = this.oauthButtonText; json[r'oauthButtonText'] = this.oauthButtonText;
json[r'publicUsers'] = this.publicUsers; json[r'publicUsers'] = this.publicUsers;
json[r'trashDays'] = this.trashDays; json[r'trashDays'] = this.trashDays;
@ -129,6 +139,7 @@ class ServerConfigDto {
maintenanceMode: mapValueOfType<bool>(json, r'maintenanceMode')!, maintenanceMode: mapValueOfType<bool>(json, r'maintenanceMode')!,
mapDarkStyleUrl: mapValueOfType<String>(json, r'mapDarkStyleUrl')!, mapDarkStyleUrl: mapValueOfType<String>(json, r'mapDarkStyleUrl')!,
mapLightStyleUrl: mapValueOfType<String>(json, r'mapLightStyleUrl')!, mapLightStyleUrl: mapValueOfType<String>(json, r'mapLightStyleUrl')!,
minFaces: mapValueOfType<int>(json, r'minFaces')!,
oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!, oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
publicUsers: mapValueOfType<bool>(json, r'publicUsers')!, publicUsers: mapValueOfType<bool>(json, r'publicUsers')!,
trashDays: mapValueOfType<int>(json, r'trashDays')!, trashDays: mapValueOfType<int>(json, r'trashDays')!,
@ -187,6 +198,7 @@ class ServerConfigDto {
'maintenanceMode', 'maintenanceMode',
'mapDarkStyleUrl', 'mapDarkStyleUrl',
'mapLightStyleUrl', 'mapLightStyleUrl',
'minFaces',
'oauthButtonText', 'oauthButtonText',
'publicUsers', 'publicUsers',
'trashDays', 'trashDays',

View File

@ -19907,6 +19907,12 @@
"description": "Whether people are enabled", "description": "Whether people are enabled",
"type": "boolean" "type": "boolean"
}, },
"minimumFaces": {
"description": "People face threshold",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer"
},
"sidebarWeb": { "sidebarWeb": {
"description": "Whether people appear in web sidebar", "description": "Whether people appear in web sidebar",
"type": "boolean" "type": "boolean"
@ -19968,6 +19974,12 @@
"description": "Whether people are enabled", "description": "Whether people are enabled",
"type": "boolean" "type": "boolean"
}, },
"minimumFaces": {
"description": "People face threshold",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer"
},
"sidebarWeb": { "sidebarWeb": {
"description": "Whether people appear in web sidebar", "description": "Whether people appear in web sidebar",
"type": "boolean" "type": "boolean"
@ -21604,6 +21616,12 @@
"description": "Map light style URL", "description": "Map light style URL",
"type": "string" "type": "string"
}, },
"minFaces": {
"description": "People min faces server default",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
},
"oauthButtonText": { "oauthButtonText": {
"description": "OAuth button text", "description": "OAuth button text",
"type": "string" "type": "string"
@ -21633,6 +21651,7 @@
"maintenanceMode", "maintenanceMode",
"mapDarkStyleUrl", "mapDarkStyleUrl",
"mapLightStyleUrl", "mapLightStyleUrl",
"minFaces",
"oauthButtonText", "oauthButtonText",
"publicUsers", "publicUsers",
"trashDays", "trashDays",

View File

@ -298,6 +298,8 @@ export type MemoriesResponse = {
export type PeopleResponse = { export type PeopleResponse = {
/** Whether people are enabled */ /** Whether people are enabled */
enabled: boolean; enabled: boolean;
/** People face threshold */
minimumFaces?: number;
/** Whether people appear in web sidebar */ /** Whether people appear in web sidebar */
sidebarWeb: boolean; sidebarWeb: boolean;
}; };
@ -375,6 +377,8 @@ export type MemoriesUpdate = {
export type PeopleUpdate = { export type PeopleUpdate = {
/** Whether people are enabled */ /** Whether people are enabled */
enabled?: boolean; enabled?: boolean;
/** People face threshold */
minimumFaces?: number;
/** Whether people appear in web sidebar */ /** Whether people appear in web sidebar */
sidebarWeb?: boolean; sidebarWeb?: boolean;
}; };
@ -1963,6 +1967,8 @@ export type ServerConfigDto = {
mapDarkStyleUrl: string; mapDarkStyleUrl: string;
/** Map light style URL */ /** Map light style URL */
mapLightStyleUrl: string; mapLightStyleUrl: string;
/** People min faces server default */
minFaces: number;
/** OAuth button text */ /** OAuth button text */
oauthButtonText: string; oauthButtonText: string;
/** Whether public user registration is enabled */ /** Whether public user registration is enabled */

View File

@ -124,6 +124,7 @@ const ServerConfigSchema = z
mapDarkStyleUrl: z.string().describe('Map dark style URL'), mapDarkStyleUrl: z.string().describe('Map dark style URL'),
mapLightStyleUrl: z.string().describe('Map light style URL'), mapLightStyleUrl: z.string().describe('Map light style URL'),
maintenanceMode: z.boolean().describe('Whether maintenance mode is active'), maintenanceMode: z.boolean().describe('Whether maintenance mode is active'),
minFaces: z.int().describe('People min faces server default'),
}) })
.meta({ id: 'ServerConfigDto' }); .meta({ id: 'ServerConfigDto' });

View File

@ -45,6 +45,7 @@ const PeopleUpdateSchema = z
.object({ .object({
enabled: z.boolean().optional().describe('Whether people are enabled'), enabled: z.boolean().optional().describe('Whether people are enabled'),
sidebarWeb: z.boolean().optional().describe('Whether people appear in web sidebar'), sidebarWeb: z.boolean().optional().describe('Whether people appear in web sidebar'),
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
}) })
.optional() .optional()
.meta({ id: 'PeopleUpdate' }); .meta({ id: 'PeopleUpdate' });
@ -138,6 +139,7 @@ const PeopleResponseSchema = z
.object({ .object({
enabled: z.boolean().describe('Whether people are enabled'), enabled: z.boolean().describe('Whether people are enabled'),
sidebarWeb: z.boolean().describe('Whether people appear in web sidebar'), sidebarWeb: z.boolean().describe('Whether people appear in web sidebar'),
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
}) })
.meta({ id: 'PeopleResponse' }); .meta({ id: 'PeopleResponse' });

View File

@ -42,7 +42,18 @@ group by
having having
( (
"person"."name" != $3 "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 order by
"person"."isHidden" asc, "person"."isHidden" asc,

View File

@ -4,7 +4,7 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { AssetFace } from 'src/database'; import { AssetFace } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; 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 { DB } from 'src/schema';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.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'; import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
export interface PersonSearchOptions { export interface PersonSearchOptions {
minimumFaceCount: number;
withHidden: boolean; withHidden: boolean;
closestFaceAssetId?: string; closestFaceAssetId?: string;
} }
@ -168,7 +167,17 @@ export class PersonRepository {
.having((eb) => .having((eb) =>
eb.or([ eb.or([
eb('person.name', '!=', ''), eb('person.name', '!=', ''),
eb((innerEb) => innerEb.fn.count('asset_face.assetId'), '>=', options?.minimumFaceCount || 1), eb(
(innerEb) => innerEb.fn.count('asset_face.assetId'),
'>=',
sql<number>`COALESCE(
(SELECT value -> 'people' ->> 'minimumFaces'
FROM user_metadata
WHERE "userId" = ${userId}
AND key = ${sql.lit(UserMetadataKey.Preferences)}),
'3'
)::int `,
),
]), ]),
) )
.groupBy('person.id') .groupBy('person.id')

View File

@ -57,7 +57,6 @@ describe(PersonService.name, () => {
], ],
}); });
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, { expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
minimumFaceCount: 3,
withHidden: true, withHidden: true,
}); });
}); });
@ -84,7 +83,6 @@ describe(PersonService.name, () => {
], ],
}); });
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, { expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
minimumFaceCount: 3,
withHidden: false, withHidden: false,
}); });
}); });

View File

@ -63,9 +63,7 @@ export class PersonService extends BaseService {
} }
closestFaceAssetId = person.faceAssetId; closestFaceAssetId = person.faceAssetId;
} }
const { machineLearning } = await this.getConfig({ withCache: false });
const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, { const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, {
minimumFaceCount: machineLearning.facialRecognition.minFaces,
withHidden, withHidden,
closestFaceAssetId, closestFaceAssetId,
}); });

View File

@ -168,6 +168,7 @@ describe(ServerService.name, () => {
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
maintenanceMode: false, maintenanceMode: false,
minFaces: 3,
}); });
expect(mocks.systemMetadata.get).toHaveBeenCalled(); expect(mocks.systemMetadata.get).toHaveBeenCalled();
}); });

View File

@ -128,6 +128,7 @@ export class ServerService extends BaseService {
mapDarkStyleUrl: config.map.darkStyle, mapDarkStyleUrl: config.map.darkStyle,
mapLightStyleUrl: config.map.lightStyle, mapLightStyleUrl: config.map.lightStyle,
maintenanceMode: false, maintenanceMode: false,
minFaces: config.machineLearning.facialRecognition.minFaces,
}; };
} }

View File

@ -539,6 +539,7 @@ export type UserPreferences = {
people: { people: {
enabled: boolean; enabled: boolean;
sidebarWeb: boolean; sidebarWeb: boolean;
minimumFaces: number;
}; };
ratings: { ratings: {
enabled: boolean; enabled: boolean;

View File

@ -21,6 +21,7 @@ const getDefaultPreferences = (): UserPreferences => {
people: { people: {
enabled: true, enabled: true,
sidebarWeb: false, sidebarWeb: false,
minimumFaces: 3,
}, },
sharedLinks: { sharedLinks: {
enabled: true, enabled: true,

View File

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte'; import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
@ -21,6 +22,7 @@
// People // People
let peopleEnabled = $state(authManager.preferences.people?.enabled ?? false); let peopleEnabled = $state(authManager.preferences.people?.enabled ?? false);
let peopleSidebar = $state(authManager.preferences.people?.sidebarWeb ?? false); let peopleSidebar = $state(authManager.preferences.people?.sidebarWeb ?? false);
let peopleMinFaces = $state(authManager.preferences.people?.minimumFaces ?? serverConfigManager.value.minFaces);
// Ratings // Ratings
let ratingsEnabled = $state(authManager.preferences.ratings?.enabled ?? false); let ratingsEnabled = $state(authManager.preferences.ratings?.enabled ?? false);
@ -43,7 +45,7 @@
albums: { defaultAssetOrder }, albums: { defaultAssetOrder },
folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar }, folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar },
memories: { enabled: memoriesEnabled, duration: memoriesDuration }, memories: { enabled: memoriesEnabled, duration: memoriesDuration },
people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar }, people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar, minimumFaces: peopleMinFaces },
ratings: { enabled: ratingsEnabled }, ratings: { enabled: ratingsEnabled },
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar }, sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar }, tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar },
@ -117,6 +119,9 @@
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}> <Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
<Switch bind:checked={peopleSidebar} /> <Switch bind:checked={peopleSidebar} />
</Field> </Field>
<Field label={$t('minFaces')} description={$t('minFaces_description')}>
<NumberInput bind:value={peopleMinFaces} />
</Field>
{/if} {/if}
</div> </div>
</SettingAccordion> </SettingAccordion>