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 updatepull/28771/head
parent
109e0a7ad0
commit
368cb7a4ad
|
|
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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')!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue