mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00:00
feat(server): Import face regions from metadata (#6455)
* feat: faces-from-metadata - Import face regions from metadata Implements immich-app#1692. - OpenAPI spec changes to accomodate metadata face import configs. New settings to enable the feature. - Updates admin UI compoments - ML faces detection/recognition & Exif/Metadata faces compatibility Signed-off-by: BugFest <bugfest.dev@pm.me> * chore(web): remove unused file confirm-enable-import-faces * chore(web): format metadata-settings * fix(server): faces-from-metadata tests and format * fix(server): code refinements, nullable face asset sourceType * fix(server): Add RegionInfo to ImmichTags interface * fix(server): deleteAllFaces sourceType param can be undefined * fix(server): exiftool-vendored 27.0.0 moves readArgs into ExifToolOptions * fix(server): rename isImportFacesFromMetadataEnabled to isFaceImportEnabled * fix(server): simplify sourceType conditional * fix(server): small fixes * fix(server): handling sourceType * fix(server): sourceType enum * fix(server): refactor metadata applyTaggedFaces * fix(server): create/update signature changes * fix(server): reduce computational cost of Person.getManyByName * fix(server): use faceList instead of faceSet * fix(server): Skip regions without Name defined * fix(mobile): Update open-api (face assets feature changes) * fix(server): Face-Person reconciliation with map/index * fix(server): tags.RegionInfo.AppliedToDimensions must be defined to process face-region * fix(server): fix shared-link.service.ts format * fix(mobile): Update open-api after branch update * simplify * fix(server): minor fixes * fix(server): person create/update methods type enforcement * fix(server): style fixes * fix(server): remove unused metadata code * fix(server): metadata faces unit tests * fix(server): top level config metadata category * fix(server): rename upsertFaces to replaceFaces * fix(server): remove sourceType when unnecessary * fix(server): sourceType as ENUM * fix(server): format fixes * fix(server): fix tests after sourceType ENUM change * fix(server): remove unnecessary JobItem cast * fix(server): fix asset enum imports * fix(open-api): add metadata config * fix(mobile): update open-api after metadata open-api spec changes * fix(web): update web/api metadata config * fix(server): remove duplicated sourceType def * fix(server): update generated sql queries * fix(e2e): tests for metadata face import feature * fix(web): Fix check:typescript * fix(e2e): update subproject ref * fix(server): revert format changes to pass format checks after ci * fix(mobile): update open-api * fix(server,movile,open-api,mobile): sourceType as DB data type * fix(e2e): upload face asset after enabling metadata face import * fix(web): simplify metadata admin settings and i18n keys * Update person.repository.ts Co-authored-by: Jason Rasmussen <jason@rasm.me> * fix(server): asset_faces.sourceType column not nullable * fix(server): simplified syntax * fix(e2e): use SDK for everything except the endpoint being tested * fix(e2e): fix test format * chore: clean up * chore: clean up * chore: update e2e/test-assets --------- Signed-off-by: BugFest <bugfest.dev@pm.me> Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
parent
720412645f
commit
77e6a6d78b
48 changed files with 704 additions and 88 deletions
|
|
@ -6,7 +6,9 @@ import {
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
SharedLinkType,
|
SharedLinkType,
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
|
getConfig,
|
||||||
getMyUser,
|
getMyUser,
|
||||||
|
updateConfig,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { exiftool } from 'exiftool-vendored';
|
import { exiftool } from 'exiftool-vendored';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
@ -43,6 +45,9 @@ const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||||
|
|
||||||
const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
|
const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
|
||||||
const ratingAssetFilepath = `${testAssetDir}/metadata/rating/mongolels.jpg`;
|
const ratingAssetFilepath = `${testAssetDir}/metadata/rating/mongolels.jpg`;
|
||||||
|
const facesAssetFilepath = `${testAssetDir}/metadata/faces/portrait.jpg`;
|
||||||
|
|
||||||
|
const getSystemConfig = (accessToken: string) => getConfig({ headers: asBearerAuth(accessToken) });
|
||||||
|
|
||||||
const readTags = async (bytes: Buffer, filename: string) => {
|
const readTags = async (bytes: Buffer, filename: string) => {
|
||||||
const filepath = join(tempDir, filename);
|
const filepath = join(tempDir, filename);
|
||||||
|
|
@ -71,6 +76,7 @@ describe('/asset', () => {
|
||||||
let user2Assets: AssetMediaResponseDto[];
|
let user2Assets: AssetMediaResponseDto[];
|
||||||
let locationAsset: AssetMediaResponseDto;
|
let locationAsset: AssetMediaResponseDto;
|
||||||
let ratingAsset: AssetMediaResponseDto;
|
let ratingAsset: AssetMediaResponseDto;
|
||||||
|
let facesAsset: AssetMediaResponseDto;
|
||||||
|
|
||||||
const setupTests = async () => {
|
const setupTests = async () => {
|
||||||
await utils.resetDatabase();
|
await utils.resetDatabase();
|
||||||
|
|
@ -224,6 +230,64 @@ describe('/asset', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should get the asset faces', async () => {
|
||||||
|
const config = await getSystemConfig(admin.accessToken);
|
||||||
|
config.metadata.faces.import = true;
|
||||||
|
await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });
|
||||||
|
|
||||||
|
// asset faces
|
||||||
|
facesAsset = await utils.createAsset(admin.accessToken, {
|
||||||
|
assetData: {
|
||||||
|
filename: 'portrait.jpg',
|
||||||
|
bytes: await readFile(facesAssetFilepath),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: facesAsset.id });
|
||||||
|
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.get(`/assets/${facesAsset.id}`)
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.id).toEqual(facesAsset.id);
|
||||||
|
expect(body.people).toMatchObject([
|
||||||
|
{
|
||||||
|
name: 'Marie Curie',
|
||||||
|
birthDate: null,
|
||||||
|
thumbnailPath: '',
|
||||||
|
isHidden: false,
|
||||||
|
faces: [
|
||||||
|
{
|
||||||
|
imageHeight: 700,
|
||||||
|
imageWidth: 840,
|
||||||
|
boundingBoxX1: 261,
|
||||||
|
boundingBoxX2: 356,
|
||||||
|
boundingBoxY1: 146,
|
||||||
|
boundingBoxY2: 284,
|
||||||
|
sourceType: 'exif',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pierre Curie',
|
||||||
|
birthDate: null,
|
||||||
|
thumbnailPath: '',
|
||||||
|
isHidden: false,
|
||||||
|
faces: [
|
||||||
|
{
|
||||||
|
imageHeight: 700,
|
||||||
|
imageWidth: 840,
|
||||||
|
boundingBoxX1: 536,
|
||||||
|
boundingBoxX2: 618,
|
||||||
|
boundingBoxY1: 83,
|
||||||
|
boundingBoxY2: 252,
|
||||||
|
sourceType: 'exif',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('should work with a shared link', async () => {
|
it('should work with a shared link', async () => {
|
||||||
const sharedLink = await utils.createSharedLink(user1.accessToken, {
|
const sharedLink = await utils.createSharedLink(user1.accessToken, {
|
||||||
type: SharedLinkType.Individual,
|
type: SharedLinkType.Individual,
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ describe('/server-info', () => {
|
||||||
configFile: false,
|
configFile: false,
|
||||||
duplicateDetection: false,
|
duplicateDetection: false,
|
||||||
facialRecognition: false,
|
facialRecognition: false,
|
||||||
|
importFaces: false,
|
||||||
map: true,
|
map: true,
|
||||||
reverseGeocoding: true,
|
reverseGeocoding: true,
|
||||||
oauth: false,
|
oauth: false,
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ describe('/server', () => {
|
||||||
facialRecognition: false,
|
facialRecognition: false,
|
||||||
map: true,
|
map: true,
|
||||||
reverseGeocoding: true,
|
reverseGeocoding: true,
|
||||||
|
importFaces: false,
|
||||||
oauth: false,
|
oauth: false,
|
||||||
oauthAutoLaunch: false,
|
oauthAutoLaunch: false,
|
||||||
passwordLogin: true,
|
passwordLogin: true,
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit 4e9731d3fc270fe25901f72a6b6f57277cdb8a30
|
Subproject commit 3e057d2f58750acdf7ff281a3938e34a86cfef4d
|
||||||
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_helper.dart
generated
BIN
mobile/openapi/lib/api_helper.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/asset_face_response_dto.dart
generated
BIN
mobile/openapi/lib/model/asset_face_response_dto.dart
generated
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/lib/model/server_features_dto.dart
generated
BIN
mobile/openapi/lib/model/server_features_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/source_type.dart
generated
Normal file
BIN
mobile/openapi/lib/model/source_type.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_dto.dart
generated
BIN
mobile/openapi/lib/model/system_config_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_faces_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/system_config_faces_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_metadata_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/system_config_metadata_dto.dart
generated
Normal file
Binary file not shown.
|
|
@ -8018,6 +8018,9 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
},
|
||||||
|
"sourceType": {
|
||||||
|
"$ref": "#/components/schemas/SourceType"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|
@ -8086,6 +8089,9 @@
|
||||||
},
|
},
|
||||||
"imageWidth": {
|
"imageWidth": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"sourceType": {
|
||||||
|
"$ref": "#/components/schemas/SourceType"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|
@ -10688,6 +10694,9 @@
|
||||||
"facialRecognition": {
|
"facialRecognition": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"importFaces": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"map": {
|
"map": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
|
@ -10721,6 +10730,7 @@
|
||||||
"duplicateDetection",
|
"duplicateDetection",
|
||||||
"email",
|
"email",
|
||||||
"facialRecognition",
|
"facialRecognition",
|
||||||
|
"importFaces",
|
||||||
"map",
|
"map",
|
||||||
"oauth",
|
"oauth",
|
||||||
"oauthAutoLaunch",
|
"oauthAutoLaunch",
|
||||||
|
|
@ -11229,6 +11239,13 @@
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SourceType": {
|
||||||
|
"enum": [
|
||||||
|
"machine-learning",
|
||||||
|
"exif"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"StackCreateDto": {
|
"StackCreateDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"assetIds": {
|
"assetIds": {
|
||||||
|
|
@ -11299,6 +11316,9 @@
|
||||||
"map": {
|
"map": {
|
||||||
"$ref": "#/components/schemas/SystemConfigMapDto"
|
"$ref": "#/components/schemas/SystemConfigMapDto"
|
||||||
},
|
},
|
||||||
|
"metadata": {
|
||||||
|
"$ref": "#/components/schemas/SystemConfigMetadataDto"
|
||||||
|
},
|
||||||
"newVersionCheck": {
|
"newVersionCheck": {
|
||||||
"$ref": "#/components/schemas/SystemConfigNewVersionCheckDto"
|
"$ref": "#/components/schemas/SystemConfigNewVersionCheckDto"
|
||||||
},
|
},
|
||||||
|
|
@ -11338,6 +11358,7 @@
|
||||||
"logging",
|
"logging",
|
||||||
"machineLearning",
|
"machineLearning",
|
||||||
"map",
|
"map",
|
||||||
|
"metadata",
|
||||||
"newVersionCheck",
|
"newVersionCheck",
|
||||||
"notifications",
|
"notifications",
|
||||||
"oauth",
|
"oauth",
|
||||||
|
|
@ -11464,6 +11485,17 @@
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SystemConfigFacesDto": {
|
||||||
|
"properties": {
|
||||||
|
"import": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"import"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SystemConfigImageDto": {
|
"SystemConfigImageDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"colorspace": {
|
"colorspace": {
|
||||||
|
|
@ -11656,6 +11688,17 @@
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SystemConfigMetadataDto": {
|
||||||
|
"properties": {
|
||||||
|
"faces": {
|
||||||
|
"$ref": "#/components/schemas/SystemConfigFacesDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"faces"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SystemConfigNewVersionCheckDto": {
|
"SystemConfigNewVersionCheckDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,7 @@ export type AssetFaceWithoutPersonResponseDto = {
|
||||||
id: string;
|
id: string;
|
||||||
imageHeight: number;
|
imageHeight: number;
|
||||||
imageWidth: number;
|
imageWidth: number;
|
||||||
|
sourceType?: SourceType;
|
||||||
};
|
};
|
||||||
export type PersonWithFacesResponseDto = {
|
export type PersonWithFacesResponseDto = {
|
||||||
birthDate: string | null;
|
birthDate: string | null;
|
||||||
|
|
@ -508,6 +509,7 @@ export type AssetFaceResponseDto = {
|
||||||
imageHeight: number;
|
imageHeight: number;
|
||||||
imageWidth: number;
|
imageWidth: number;
|
||||||
person: (PersonResponseDto) | null;
|
person: (PersonResponseDto) | null;
|
||||||
|
sourceType?: SourceType;
|
||||||
};
|
};
|
||||||
export type FaceDto = {
|
export type FaceDto = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -893,6 +895,7 @@ export type ServerFeaturesDto = {
|
||||||
duplicateDetection: boolean;
|
duplicateDetection: boolean;
|
||||||
email: boolean;
|
email: boolean;
|
||||||
facialRecognition: boolean;
|
facialRecognition: boolean;
|
||||||
|
importFaces: boolean;
|
||||||
map: boolean;
|
map: boolean;
|
||||||
oauth: boolean;
|
oauth: boolean;
|
||||||
oauthAutoLaunch: boolean;
|
oauthAutoLaunch: boolean;
|
||||||
|
|
@ -1122,6 +1125,12 @@ export type SystemConfigMapDto = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
lightStyle: string;
|
lightStyle: string;
|
||||||
};
|
};
|
||||||
|
export type SystemConfigFacesDto = {
|
||||||
|
"import": boolean;
|
||||||
|
};
|
||||||
|
export type SystemConfigMetadataDto = {
|
||||||
|
faces: SystemConfigFacesDto;
|
||||||
|
};
|
||||||
export type SystemConfigNewVersionCheckDto = {
|
export type SystemConfigNewVersionCheckDto = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
@ -1178,6 +1187,7 @@ export type SystemConfigDto = {
|
||||||
logging: SystemConfigLoggingDto;
|
logging: SystemConfigLoggingDto;
|
||||||
machineLearning: SystemConfigMachineLearningDto;
|
machineLearning: SystemConfigMachineLearningDto;
|
||||||
map: SystemConfigMapDto;
|
map: SystemConfigMapDto;
|
||||||
|
metadata: SystemConfigMetadataDto;
|
||||||
newVersionCheck: SystemConfigNewVersionCheckDto;
|
newVersionCheck: SystemConfigNewVersionCheckDto;
|
||||||
notifications: SystemConfigNotificationsDto;
|
notifications: SystemConfigNotificationsDto;
|
||||||
oauth: SystemConfigOAuthDto;
|
oauth: SystemConfigOAuthDto;
|
||||||
|
|
@ -3226,6 +3236,10 @@ export enum AlbumUserRole {
|
||||||
Editor = "editor",
|
Editor = "editor",
|
||||||
Viewer = "viewer"
|
Viewer = "viewer"
|
||||||
}
|
}
|
||||||
|
export enum SourceType {
|
||||||
|
MachineLearning = "machine-learning",
|
||||||
|
Exif = "exif"
|
||||||
|
}
|
||||||
export enum AssetTypeEnum {
|
export enum AssetTypeEnum {
|
||||||
Image = "IMAGE",
|
Image = "IMAGE",
|
||||||
Video = "VIDEO",
|
Video = "VIDEO",
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,11 @@ export interface SystemConfig {
|
||||||
reverseGeocoding: {
|
reverseGeocoding: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
metadata: {
|
||||||
|
faces: {
|
||||||
|
import: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
oauth: {
|
oauth: {
|
||||||
autoLaunch: boolean;
|
autoLaunch: boolean;
|
||||||
autoRegister: boolean;
|
autoRegister: boolean;
|
||||||
|
|
@ -286,6 +291,11 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||||
reverseGeocoding: {
|
reverseGeocoding: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
metadata: {
|
||||||
|
faces: {
|
||||||
|
import: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
oauth: {
|
oauth: {
|
||||||
autoLaunch: false,
|
autoLaunch: false,
|
||||||
autoRegister: true,
|
autoRegister: true,
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,7 @@ export class StorageCore {
|
||||||
return this.assetRepository.update({ id, sidecarPath: newPath });
|
return this.assetRepository.update({ id, sidecarPath: newPath });
|
||||||
}
|
}
|
||||||
case PersonPathType.FACE: {
|
case PersonPathType.FACE: {
|
||||||
return this.personRepository.update({ id, thumbnailPath: newPath });
|
return this.personRepository.update([{ id, thumbnailPath: newPath }]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { PropertyLifecycle } from 'src/decorators';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
import { PersonEntity } from 'src/entities/person.entity';
|
||||||
|
import { SourceType } from 'src/enum';
|
||||||
import { IsDateStringFormat, MaxDateString, Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
|
import { IsDateStringFormat, MaxDateString, Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
export class PersonCreateDto {
|
export class PersonCreateDto {
|
||||||
|
|
@ -113,6 +114,8 @@ export class AssetFaceWithoutPersonResponseDto {
|
||||||
boundingBoxY1!: number;
|
boundingBoxY1!: number;
|
||||||
@ApiProperty({ type: 'integer' })
|
@ApiProperty({ type: 'integer' })
|
||||||
boundingBoxY2!: number;
|
boundingBoxY2!: number;
|
||||||
|
@ApiProperty({ enum: SourceType, enumName: 'SourceType' })
|
||||||
|
sourceType?: SourceType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AssetFaceResponseDto extends AssetFaceWithoutPersonResponseDto {
|
export class AssetFaceResponseDto extends AssetFaceWithoutPersonResponseDto {
|
||||||
|
|
@ -176,6 +179,7 @@ export function mapFacesWithoutPerson(face: AssetFaceEntity): AssetFaceWithoutPe
|
||||||
boundingBoxX2: face.boundingBoxX2,
|
boundingBoxX2: face.boundingBoxX2,
|
||||||
boundingBoxY1: face.boundingBoxY1,
|
boundingBoxY1: face.boundingBoxY1,
|
||||||
boundingBoxY2: face.boundingBoxY2,
|
boundingBoxY2: face.boundingBoxY2,
|
||||||
|
sourceType: face.sourceType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ export class ServerFeaturesDto {
|
||||||
map!: boolean;
|
map!: boolean;
|
||||||
trash!: boolean;
|
trash!: boolean;
|
||||||
reverseGeocoding!: boolean;
|
reverseGeocoding!: boolean;
|
||||||
|
importFaces!: boolean;
|
||||||
oauth!: boolean;
|
oauth!: boolean;
|
||||||
oauthAutoLaunch!: boolean;
|
oauthAutoLaunch!: boolean;
|
||||||
passwordLogin!: boolean;
|
passwordLogin!: boolean;
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,18 @@ class SystemConfigReverseGeocodingDto {
|
||||||
enabled!: boolean;
|
enabled!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SystemConfigFacesDto {
|
||||||
|
@IsBoolean()
|
||||||
|
import!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SystemConfigMetadataDto {
|
||||||
|
@Type(() => SystemConfigFacesDto)
|
||||||
|
@ValidateNested()
|
||||||
|
@IsObject()
|
||||||
|
faces!: SystemConfigFacesDto;
|
||||||
|
}
|
||||||
|
|
||||||
class SystemConfigServerDto {
|
class SystemConfigServerDto {
|
||||||
@ValidateIf((_, value: string) => value !== '')
|
@ValidateIf((_, value: string) => value !== '')
|
||||||
@IsUrl({ require_tld: false, require_protocol: true, protocols: ['http', 'https'] })
|
@IsUrl({ require_tld: false, require_protocol: true, protocols: ['http', 'https'] })
|
||||||
|
|
@ -555,6 +567,11 @@ export class SystemConfigDto implements SystemConfig {
|
||||||
@IsObject()
|
@IsObject()
|
||||||
reverseGeocoding!: SystemConfigReverseGeocodingDto;
|
reverseGeocoding!: SystemConfigReverseGeocodingDto;
|
||||||
|
|
||||||
|
@Type(() => SystemConfigMetadataDto)
|
||||||
|
@ValidateNested()
|
||||||
|
@IsObject()
|
||||||
|
metadata!: SystemConfigMetadataDto;
|
||||||
|
|
||||||
@Type(() => SystemConfigStorageTemplateDto)
|
@Type(() => SystemConfigStorageTemplateDto)
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { FaceSearchEntity } from 'src/entities/face-search.entity';
|
import { FaceSearchEntity } from 'src/entities/face-search.entity';
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
import { PersonEntity } from 'src/entities/person.entity';
|
||||||
|
import { SourceType } from 'src/enum';
|
||||||
import { Column, Entity, Index, ManyToOne, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
|
import { Column, Entity, Index, ManyToOne, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
@Entity('asset_faces', { synchronize: false })
|
@Entity('asset_faces', { synchronize: false })
|
||||||
|
|
@ -37,6 +38,9 @@ export class AssetFaceEntity {
|
||||||
@Column({ default: 0, type: 'int' })
|
@Column({ default: 0, type: 'int' })
|
||||||
boundingBoxY2!: number;
|
boundingBoxY2!: number;
|
||||||
|
|
||||||
|
@Column({ default: SourceType.MACHINE_LEARNING, type: 'enum', enum: SourceType })
|
||||||
|
sourceType!: SourceType;
|
||||||
|
|
||||||
@ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
@ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||||
asset!: AssetEntity;
|
asset!: AssetEntity;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -180,3 +180,8 @@ export enum UserStatus {
|
||||||
REMOVING = 'removing',
|
REMOVING = 'removing',
|
||||||
DELETED = 'deleted',
|
DELETED = 'deleted',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum SourceType {
|
||||||
|
MACHINE_LEARNING = 'machine-learning',
|
||||||
|
EXIF = 'exif',
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ export interface ExifDuration {
|
||||||
Scale?: number;
|
Scale?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImmichTags extends Omit<Tags, 'FocalLength' | 'Duration' | 'Description' | 'ImageDescription'> {
|
type TagsWithWrongTypes = 'FocalLength' | 'Duration' | 'Description' | 'ImageDescription' | 'RegionInfo';
|
||||||
|
export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
|
||||||
ContentIdentifier?: string;
|
ContentIdentifier?: string;
|
||||||
MotionPhoto?: number;
|
MotionPhoto?: number;
|
||||||
MotionPhotoVersion?: number;
|
MotionPhotoVersion?: number;
|
||||||
|
|
@ -23,6 +24,28 @@ export interface ImmichTags extends Omit<Tags, 'FocalLength' | 'Duration' | 'Des
|
||||||
// Type is wrong, can also be number.
|
// Type is wrong, can also be number.
|
||||||
Description?: string | number;
|
Description?: string | number;
|
||||||
ImageDescription?: string | number;
|
ImageDescription?: string | number;
|
||||||
|
|
||||||
|
// Extended properties for image regions, such as faces
|
||||||
|
RegionInfo?: {
|
||||||
|
AppliedToDimensions: {
|
||||||
|
W: number;
|
||||||
|
H: number;
|
||||||
|
Unit: string;
|
||||||
|
};
|
||||||
|
RegionList: {
|
||||||
|
Area: {
|
||||||
|
// (X,Y) // center of the rectangle
|
||||||
|
X: number;
|
||||||
|
Y: number;
|
||||||
|
W: number;
|
||||||
|
H: number;
|
||||||
|
Unit: string;
|
||||||
|
};
|
||||||
|
Rotation?: number;
|
||||||
|
Type?: string;
|
||||||
|
Name?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IMetadataRepository {
|
export interface IMetadataRepository {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ export interface PersonNameSearchOptions {
|
||||||
withHidden?: boolean;
|
withHidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PersonNameResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AssetFaceId {
|
export interface AssetFaceId {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
personId: string;
|
personId: string;
|
||||||
|
|
@ -35,20 +40,26 @@ export interface PeopleStatistics {
|
||||||
hidden: number;
|
hidden: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeleteAllFacesOptions {
|
||||||
|
sourceType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IPersonRepository {
|
export interface IPersonRepository {
|
||||||
getAll(pagination: PaginationOptions, options?: FindManyOptions<PersonEntity>): Paginated<PersonEntity>;
|
getAll(pagination: PaginationOptions, options?: FindManyOptions<PersonEntity>): Paginated<PersonEntity>;
|
||||||
getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated<PersonEntity>;
|
getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated<PersonEntity>;
|
||||||
getAllWithoutFaces(): Promise<PersonEntity[]>;
|
getAllWithoutFaces(): Promise<PersonEntity[]>;
|
||||||
getById(personId: string): Promise<PersonEntity | null>;
|
getById(personId: string): Promise<PersonEntity | null>;
|
||||||
getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise<PersonEntity[]>;
|
getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise<PersonEntity[]>;
|
||||||
|
getDistinctNames(userId: string, options: PersonNameSearchOptions): Promise<PersonNameResponse[]>;
|
||||||
|
|
||||||
getAssets(personId: string): Promise<AssetEntity[]>;
|
getAssets(personId: string): Promise<AssetEntity[]>;
|
||||||
|
|
||||||
create(entity: Partial<PersonEntity>): Promise<PersonEntity>;
|
create(entities: Partial<PersonEntity>[]): Promise<PersonEntity[]>;
|
||||||
createFaces(entities: Partial<AssetFaceEntity>[]): Promise<string[]>;
|
createFaces(entities: Partial<AssetFaceEntity>[]): Promise<string[]>;
|
||||||
delete(entities: PersonEntity[]): Promise<void>;
|
delete(entities: PersonEntity[]): Promise<void>;
|
||||||
deleteAll(): Promise<void>;
|
deleteAll(): Promise<void>;
|
||||||
deleteAllFaces(): Promise<void>;
|
deleteAllFaces(options: DeleteAllFacesOptions): Promise<void>;
|
||||||
|
replaceFaces(assetId: string, entities: Partial<AssetFaceEntity>[], sourceType?: string): Promise<string[]>;
|
||||||
getAllFaces(pagination: PaginationOptions, options?: FindManyOptions<AssetFaceEntity>): Paginated<AssetFaceEntity>;
|
getAllFaces(pagination: PaginationOptions, options?: FindManyOptions<AssetFaceEntity>): Paginated<AssetFaceEntity>;
|
||||||
getFaceById(id: string): Promise<AssetFaceEntity>;
|
getFaceById(id: string): Promise<AssetFaceEntity>;
|
||||||
getFaceByIdWithAssets(
|
getFaceByIdWithAssets(
|
||||||
|
|
@ -63,6 +74,6 @@ export interface IPersonRepository {
|
||||||
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
|
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
|
||||||
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
|
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
|
||||||
reassignFaces(data: UpdateFacesData): Promise<number>;
|
reassignFaces(data: UpdateFacesData): Promise<number>;
|
||||||
update(entity: Partial<PersonEntity>): Promise<PersonEntity>;
|
update(entities: Partial<PersonEntity>[]): Promise<PersonEntity[]>;
|
||||||
getLatestFaceDate(): Promise<string | undefined>;
|
getLatestFaceDate(): Promise<string | undefined>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddSourceColumnToAssetFace1721249222549 implements MigrationInterface {
|
||||||
|
name = 'AddSourceColumnToAssetFace1721249222549'
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`CREATE TYPE sourceType AS ENUM ('machine-learning', 'exif');`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "sourceType" sourceType NOT NULL DEFAULT 'machine-learning'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "sourceType"`);
|
||||||
|
await queryRunner.query(`DROP TYPE sourceType`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -199,6 +199,7 @@ SELECT
|
||||||
"AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1",
|
"AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1",
|
||||||
"AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2",
|
"AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2",
|
||||||
"AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2",
|
"AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2",
|
||||||
|
"AssetEntity__AssetEntity_faces"."sourceType" AS "AssetEntity__AssetEntity_faces_sourceType",
|
||||||
"8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id",
|
"8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id",
|
||||||
"8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt",
|
"8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt",
|
||||||
"8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt",
|
"8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt",
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ SELECT
|
||||||
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
||||||
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
||||||
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
||||||
|
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
|
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
|
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
|
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
|
||||||
|
|
@ -106,6 +107,7 @@ FROM
|
||||||
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
||||||
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
||||||
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
||||||
|
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
|
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
|
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
|
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
|
||||||
|
|
@ -141,6 +143,7 @@ FROM
|
||||||
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
||||||
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
||||||
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
||||||
|
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
|
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
|
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
|
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
|
||||||
|
|
@ -226,6 +229,16 @@ ORDER BY
|
||||||
LIMIT
|
LIMIT
|
||||||
20
|
20
|
||||||
|
|
||||||
|
-- PersonRepository.getDistinctNames
|
||||||
|
SELECT DISTINCT
|
||||||
|
ON (lower("person"."name")) "person"."id" AS "person_id",
|
||||||
|
"person"."name" AS "person_name"
|
||||||
|
FROM
|
||||||
|
"person" "person"
|
||||||
|
WHERE
|
||||||
|
"person"."ownerId" = $1
|
||||||
|
AND "person"."name" != ''
|
||||||
|
|
||||||
-- PersonRepository.getStatistics
|
-- PersonRepository.getStatistics
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT ("asset"."id")) AS "count"
|
COUNT(DISTINCT ("asset"."id")) AS "count"
|
||||||
|
|
@ -282,6 +295,7 @@ FROM
|
||||||
"AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1",
|
"AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1",
|
||||||
"AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2",
|
"AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2",
|
||||||
"AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2",
|
"AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2",
|
||||||
|
"AssetEntity__AssetEntity_faces"."sourceType" AS "AssetEntity__AssetEntity_faces_sourceType",
|
||||||
"8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id",
|
"8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id",
|
||||||
"8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt",
|
"8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt",
|
||||||
"8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt",
|
"8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt",
|
||||||
|
|
@ -375,6 +389,7 @@ SELECT
|
||||||
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
||||||
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
||||||
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
||||||
|
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType",
|
||||||
"AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id",
|
"AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id",
|
||||||
"AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId",
|
"AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId",
|
||||||
"AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId",
|
"AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId",
|
||||||
|
|
@ -425,7 +440,8 @@ SELECT
|
||||||
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
|
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
|
||||||
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
||||||
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
||||||
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2"
|
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
||||||
|
"AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType"
|
||||||
FROM
|
FROM
|
||||||
"asset_faces" "AssetFaceEntity"
|
"asset_faces" "AssetFaceEntity"
|
||||||
WHERE
|
WHERE
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,7 @@ WITH
|
||||||
"faces"."boundingBoxY1" AS "boundingBoxY1",
|
"faces"."boundingBoxY1" AS "boundingBoxY1",
|
||||||
"faces"."boundingBoxX2" AS "boundingBoxX2",
|
"faces"."boundingBoxX2" AS "boundingBoxX2",
|
||||||
"faces"."boundingBoxY2" AS "boundingBoxY2",
|
"faces"."boundingBoxY2" AS "boundingBoxY2",
|
||||||
|
"faces"."sourceType" AS "sourceType",
|
||||||
"search"."embedding" <= > $1 AS "distance"
|
"search"."embedding" <= > $1 AS "distance"
|
||||||
FROM
|
FROM
|
||||||
"asset_faces" "faces"
|
"asset_faces" "faces"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
|
|
@ -8,8 +8,10 @@ import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
import { PersonEntity } from 'src/entities/person.entity';
|
||||||
import {
|
import {
|
||||||
AssetFaceId,
|
AssetFaceId,
|
||||||
|
DeleteAllFacesOptions,
|
||||||
IPersonRepository,
|
IPersonRepository,
|
||||||
PeopleStatistics,
|
PeopleStatistics,
|
||||||
|
PersonNameResponse,
|
||||||
PersonNameSearchOptions,
|
PersonNameSearchOptions,
|
||||||
PersonSearchOptions,
|
PersonSearchOptions,
|
||||||
PersonStatistics,
|
PersonStatistics,
|
||||||
|
|
@ -17,12 +19,13 @@ import {
|
||||||
} from 'src/interfaces/person.interface';
|
} from 'src/interfaces/person.interface';
|
||||||
import { Instrumentation } from 'src/utils/instrumentation';
|
import { Instrumentation } from 'src/utils/instrumentation';
|
||||||
import { Paginated, PaginationMode, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination';
|
import { Paginated, PaginationMode, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination';
|
||||||
import { FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm';
|
import { DataSource, FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm';
|
||||||
|
|
||||||
@Instrumentation()
|
@Instrumentation()
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PersonRepository implements IPersonRepository {
|
export class PersonRepository implements IPersonRepository {
|
||||||
constructor(
|
constructor(
|
||||||
|
@InjectDataSource() private dataSource: DataSource,
|
||||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||||
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
|
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
|
||||||
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
|
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
|
||||||
|
|
@ -49,7 +52,16 @@ export class PersonRepository implements IPersonRepository {
|
||||||
await this.personRepository.clear();
|
await this.personRepository.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAllFaces(): Promise<void> {
|
async deleteAllFaces({ sourceType }: DeleteAllFacesOptions): Promise<void> {
|
||||||
|
if (sourceType) {
|
||||||
|
await this.assetFaceRepository
|
||||||
|
.createQueryBuilder('asset_faces')
|
||||||
|
.delete()
|
||||||
|
.andWhere('sourceType = :sourceType', { sourceType })
|
||||||
|
.execute();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE');
|
await this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,6 +194,21 @@ export class PersonRepository implements IPersonRepository {
|
||||||
return queryBuilder.getMany();
|
return queryBuilder.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] })
|
||||||
|
getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise<PersonNameResponse[]> {
|
||||||
|
const queryBuilder = this.personRepository
|
||||||
|
.createQueryBuilder('person')
|
||||||
|
.select(['person.id', 'person.name'])
|
||||||
|
.distinctOn(['lower(person.name)'])
|
||||||
|
.where(`person.ownerId = :userId AND person.name != ''`, { userId });
|
||||||
|
|
||||||
|
if (!withHidden) {
|
||||||
|
queryBuilder.andWhere('person.isHidden = false');
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryBuilder.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
async getStatistics(personId: string): Promise<PersonStatistics> {
|
async getStatistics(personId: string): Promise<PersonStatistics> {
|
||||||
const items = await this.assetFaceRepository
|
const items = await this.assetFaceRepository
|
||||||
|
|
@ -248,8 +275,8 @@ export class PersonRepository implements IPersonRepository {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
create(entity: Partial<PersonEntity>): Promise<PersonEntity> {
|
create(entities: Partial<PersonEntity>[]): Promise<PersonEntity[]> {
|
||||||
return this.personRepository.save(entity);
|
return this.personRepository.save(entities);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createFaces(entities: AssetFaceEntity[]): Promise<string[]> {
|
async createFaces(entities: AssetFaceEntity[]): Promise<string[]> {
|
||||||
|
|
@ -257,9 +284,16 @@ export class PersonRepository implements IPersonRepository {
|
||||||
return res.map((row) => row.id);
|
return res.map((row) => row.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(entity: Partial<PersonEntity>): Promise<PersonEntity> {
|
async replaceFaces(assetId: string, entities: AssetFaceEntity[], sourceType: string): Promise<string[]> {
|
||||||
const { id } = await this.personRepository.save(entity);
|
return this.dataSource.transaction(async (manager) => {
|
||||||
return this.personRepository.findOneByOrFail({ id });
|
await manager.delete(AssetFaceEntity, { assetId, sourceType });
|
||||||
|
const assetFaces = await manager.save(AssetFaceEntity, entities);
|
||||||
|
return assetFaces.map(({ id }) => id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(entities: Partial<PersonEntity>[]): Promise<PersonEntity[]> {
|
||||||
|
return await this.personRepository.save(entities);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
|
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ export class AuditService {
|
||||||
}
|
}
|
||||||
|
|
||||||
case PersonPathType.FACE: {
|
case PersonPathType.FACE: {
|
||||||
await this.personRepository.update({ id, thumbnailPath: pathValue });
|
await this.personRepository.update([{ id, thumbnailPath: pathValue }]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ export class MediaService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.personRepository.update({ id: person.id, faceAssetId: face.id });
|
await this.personRepository.update([{ id: person.id, faceAssetId: face.id }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } });
|
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } });
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { randomBytes } from 'node:crypto';
|
||||||
import { Stats } from 'node:fs';
|
import { Stats } from 'node:fs';
|
||||||
import { constants } from 'node:fs/promises';
|
import { constants } from 'node:fs/promises';
|
||||||
import { ExifEntity } from 'src/entities/exif.entity';
|
import { ExifEntity } from 'src/entities/exif.entity';
|
||||||
import { AssetType } from 'src/enum';
|
import { AssetType, SourceType } from 'src/enum';
|
||||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||||
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
||||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||||
|
|
@ -24,6 +24,8 @@ import { MetadataService, Orientation } from 'src/services/metadata.service';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { assetStub } from 'test/fixtures/asset.stub';
|
||||||
import { fileStub } from 'test/fixtures/file.stub';
|
import { fileStub } from 'test/fixtures/file.stub';
|
||||||
import { probeStub } from 'test/fixtures/media.stub';
|
import { probeStub } from 'test/fixtures/media.stub';
|
||||||
|
import { metadataStub } from 'test/fixtures/metadata.stub';
|
||||||
|
import { personStub } from 'test/fixtures/person.stub';
|
||||||
import { tagStub } from 'test/fixtures/tag.stub';
|
import { tagStub } from 'test/fixtures/tag.stub';
|
||||||
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
|
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
|
||||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
||||||
|
|
@ -956,6 +958,123 @@ describe(MetadataService.name, () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should skip importing metadata when the feature is disabled', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||||
|
systemMock.get.mockResolvedValue({ metadata: { faces: { import: false } } });
|
||||||
|
metadataMock.readTags.mockResolvedValue(metadataStub.withFace);
|
||||||
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
expect(personMock.getDistinctNames).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip importing metadata face for assets without tags.RegionInfo', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||||
|
systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
|
metadataMock.readTags.mockResolvedValue(metadataStub.empty);
|
||||||
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
expect(personMock.getDistinctNames).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip importing faces without name', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||||
|
systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
|
metadataMock.readTags.mockResolvedValue(metadataStub.withFaceNoName);
|
||||||
|
personMock.getDistinctNames.mockResolvedValue([]);
|
||||||
|
personMock.create.mockResolvedValue([]);
|
||||||
|
personMock.replaceFaces.mockResolvedValue([]);
|
||||||
|
personMock.update.mockResolvedValue([]);
|
||||||
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
expect(personMock.create).toHaveBeenCalledWith([]);
|
||||||
|
expect(personMock.replaceFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF);
|
||||||
|
expect(personMock.update).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip importing faces with empty name', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||||
|
systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
|
metadataMock.readTags.mockResolvedValue(metadataStub.withFaceEmptyName);
|
||||||
|
personMock.getDistinctNames.mockResolvedValue([]);
|
||||||
|
personMock.create.mockResolvedValue([]);
|
||||||
|
personMock.replaceFaces.mockResolvedValue([]);
|
||||||
|
personMock.update.mockResolvedValue([]);
|
||||||
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
expect(personMock.create).toHaveBeenCalledWith([]);
|
||||||
|
expect(personMock.replaceFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF);
|
||||||
|
expect(personMock.update).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply metadata face tags creating new persons', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||||
|
systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
|
metadataMock.readTags.mockResolvedValue(metadataStub.withFace);
|
||||||
|
personMock.getDistinctNames.mockResolvedValue([]);
|
||||||
|
personMock.create.mockResolvedValue([personStub.withName]);
|
||||||
|
personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']);
|
||||||
|
personMock.update.mockResolvedValue([personStub.withName]);
|
||||||
|
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
|
||||||
|
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id]);
|
||||||
|
expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
|
||||||
|
expect(personMock.create).toHaveBeenCalledWith([expect.objectContaining({ name: personStub.withName.name })]);
|
||||||
|
expect(personMock.replaceFaces).toHaveBeenCalledWith(
|
||||||
|
assetStub.primaryImage.id,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'random-uuid',
|
||||||
|
assetId: assetStub.primaryImage.id,
|
||||||
|
personId: 'random-uuid',
|
||||||
|
imageHeight: 100,
|
||||||
|
imageWidth: 100,
|
||||||
|
boundingBoxX1: 0,
|
||||||
|
boundingBoxX2: 10,
|
||||||
|
boundingBoxY1: 0,
|
||||||
|
boundingBoxY2: 10,
|
||||||
|
sourceType: SourceType.EXIF,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
SourceType.EXIF,
|
||||||
|
);
|
||||||
|
expect(personMock.update).toHaveBeenCalledWith([{ id: 'random-uuid', faceAssetId: 'random-uuid' }]);
|
||||||
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
|
{
|
||||||
|
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||||
|
data: { id: personStub.withName.id },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should assign metadata face tags to existing persons', async () => {
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||||
|
systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||||
|
metadataMock.readTags.mockResolvedValue(metadataStub.withFace);
|
||||||
|
personMock.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]);
|
||||||
|
personMock.create.mockResolvedValue([]);
|
||||||
|
personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']);
|
||||||
|
personMock.update.mockResolvedValue([personStub.withName]);
|
||||||
|
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
|
||||||
|
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id]);
|
||||||
|
expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
|
||||||
|
expect(personMock.create).toHaveBeenCalledWith([]);
|
||||||
|
expect(personMock.replaceFaces).toHaveBeenCalledWith(
|
||||||
|
assetStub.primaryImage.id,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'random-uuid',
|
||||||
|
assetId: assetStub.primaryImage.id,
|
||||||
|
personId: personStub.withName.id,
|
||||||
|
imageHeight: 100,
|
||||||
|
imageWidth: 100,
|
||||||
|
boundingBoxX1: 0,
|
||||||
|
boundingBoxX2: 10,
|
||||||
|
boundingBoxY1: 0,
|
||||||
|
boundingBoxY2: 10,
|
||||||
|
sourceType: SourceType.EXIF,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
SourceType.EXIF,
|
||||||
|
);
|
||||||
|
expect(personMock.update).toHaveBeenCalledWith([]);
|
||||||
|
expect(jobMock.queueAll).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleQueueSidecar', () => {
|
describe('handleQueueSidecar', () => {
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@ import { SystemConfig } from 'src/config';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||||
import { OnEmit } from 'src/decorators';
|
import { OnEmit } from 'src/decorators';
|
||||||
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { ExifEntity } from 'src/entities/exif.entity';
|
import { ExifEntity } from 'src/entities/exif.entity';
|
||||||
import { AssetType } from 'src/enum';
|
import { PersonEntity } from 'src/entities/person.entity';
|
||||||
|
import { AssetType, SourceType } from 'src/enum';
|
||||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||||
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
||||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||||
|
|
@ -37,6 +39,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||||
import { ITagRepository } from 'src/interfaces/tag.interface';
|
import { ITagRepository } from 'src/interfaces/tag.interface';
|
||||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||||
|
import { isFaceImportEnabled } from 'src/utils/misc';
|
||||||
import { usePagination } from 'src/utils/pagination';
|
import { usePagination } from 'src/utils/pagination';
|
||||||
import { upsertTags } from 'src/utils/tag';
|
import { upsertTags } from 'src/utils/tag';
|
||||||
|
|
||||||
|
|
@ -104,7 +107,7 @@ export class MetadataService {
|
||||||
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
||||||
@Inject(IMetadataRepository) private repository: IMetadataRepository,
|
@Inject(IMetadataRepository) private repository: IMetadataRepository,
|
||||||
@Inject(IMoveRepository) moveRepository: IMoveRepository,
|
@Inject(IMoveRepository) moveRepository: IMoveRepository,
|
||||||
@Inject(IPersonRepository) personRepository: IPersonRepository,
|
@Inject(IPersonRepository) private personRepository: IPersonRepository,
|
||||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||||
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
|
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
|
||||||
@Inject(ITagRepository) private tagRepository: ITagRepository,
|
@Inject(ITagRepository) private tagRepository: ITagRepository,
|
||||||
|
|
@ -215,6 +218,7 @@ export class MetadataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleMetadataExtraction({ id }: IEntityJob): Promise<JobStatus> {
|
async handleMetadataExtraction({ id }: IEntityJob): Promise<JobStatus> {
|
||||||
|
const { metadata } = await this.configCore.getConfig({ withCache: true });
|
||||||
const [asset] = await this.assetRepository.getByIds([id]);
|
const [asset] = await this.assetRepository.getByIds([id]);
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
|
|
@ -253,6 +257,10 @@ export class MetadataService {
|
||||||
metadataExtractedAt: new Date(),
|
metadataExtractedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isFaceImportEnabled(metadata)) {
|
||||||
|
await this.applyTaggedFaces(asset, exifTags);
|
||||||
|
}
|
||||||
|
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -512,6 +520,65 @@ export class MetadataService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async applyTaggedFaces(asset: AssetEntity, tags: ImmichTags) {
|
||||||
|
if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const discoveredFaces: Partial<AssetFaceEntity>[] = [];
|
||||||
|
const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true });
|
||||||
|
const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id]));
|
||||||
|
const missing: Partial<PersonEntity>[] = [];
|
||||||
|
const missingWithFaceAsset: Partial<PersonEntity>[] = [];
|
||||||
|
for (const region of tags.RegionInfo.RegionList) {
|
||||||
|
if (!region.Name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageWidth = tags.RegionInfo.AppliedToDimensions.W;
|
||||||
|
const imageHeight = tags.RegionInfo.AppliedToDimensions.H;
|
||||||
|
const loweredName = region.Name.toLowerCase();
|
||||||
|
const personId = existingNameMap.get(loweredName) || this.cryptoRepository.randomUUID();
|
||||||
|
|
||||||
|
const face = {
|
||||||
|
id: this.cryptoRepository.randomUUID(),
|
||||||
|
personId,
|
||||||
|
assetId: asset.id,
|
||||||
|
imageWidth,
|
||||||
|
imageHeight,
|
||||||
|
boundingBoxX1: Math.floor((region.Area.X - region.Area.W / 2) * imageWidth),
|
||||||
|
boundingBoxY1: Math.floor((region.Area.Y - region.Area.H / 2) * imageHeight),
|
||||||
|
boundingBoxX2: Math.floor((region.Area.X + region.Area.W / 2) * imageWidth),
|
||||||
|
boundingBoxY2: Math.floor((region.Area.Y + region.Area.H / 2) * imageHeight),
|
||||||
|
sourceType: SourceType.EXIF,
|
||||||
|
};
|
||||||
|
|
||||||
|
discoveredFaces.push(face);
|
||||||
|
if (!existingNameMap.has(loweredName)) {
|
||||||
|
missing.push({ id: personId, ownerId: asset.ownerId, name: region.Name });
|
||||||
|
missingWithFaceAsset.push({ id: personId, faceAssetId: face.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
this.logger.debug(`Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPersons = await this.personRepository.create(missing);
|
||||||
|
|
||||||
|
const faceIds = await this.personRepository.replaceFaces(asset.id, discoveredFaces, SourceType.EXIF);
|
||||||
|
this.logger.debug(`Created ${faceIds.length} faces for asset ${asset.id}`);
|
||||||
|
|
||||||
|
await this.personRepository.update(missingWithFaceAsset);
|
||||||
|
|
||||||
|
await this.jobRepository.queueAll(
|
||||||
|
newPersons.map((person) => ({
|
||||||
|
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||||
|
data: { id: person.id },
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async exifData(
|
private async exifData(
|
||||||
asset: AssetEntity,
|
asset: AssetEntity,
|
||||||
): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> {
|
): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Colorspace } from 'src/config';
|
||||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto';
|
import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
import { SystemMetadataKey } from 'src/enum';
|
import { SourceType, SystemMetadataKey } from 'src/enum';
|
||||||
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
||||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||||
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||||
|
|
@ -241,18 +241,18 @@ describe(PersonService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update a person's name", async () => {
|
it("should update a person's name", async () => {
|
||||||
personMock.update.mockResolvedValue(personStub.withName);
|
personMock.update.mockResolvedValue([personStub.withName]);
|
||||||
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
||||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||||
|
|
||||||
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto);
|
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto);
|
||||||
|
|
||||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' });
|
expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', name: 'Person 1' }]);
|
||||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update a person's date of birth", async () => {
|
it("should update a person's date of birth", async () => {
|
||||||
personMock.update.mockResolvedValue(personStub.withBirthDate);
|
personMock.update.mockResolvedValue([personStub.withBirthDate]);
|
||||||
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
||||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||||
|
|
||||||
|
|
@ -264,25 +264,25 @@ describe(PersonService.name, () => {
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
});
|
});
|
||||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' });
|
expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', birthDate: '1976-06-30' }]);
|
||||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update a person visibility', async () => {
|
it('should update a person visibility', async () => {
|
||||||
personMock.update.mockResolvedValue(personStub.withName);
|
personMock.update.mockResolvedValue([personStub.withName]);
|
||||||
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
||||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||||
|
|
||||||
await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto);
|
await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto);
|
||||||
|
|
||||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false });
|
expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', isHidden: false }]);
|
||||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update a person's thumbnailPath", async () => {
|
it("should update a person's thumbnailPath", async () => {
|
||||||
personMock.update.mockResolvedValue(personStub.withName);
|
personMock.update.mockResolvedValue([personStub.withName]);
|
||||||
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
||||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||||
|
|
@ -291,7 +291,7 @@ describe(PersonService.name, () => {
|
||||||
sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }),
|
sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }),
|
||||||
).resolves.toEqual(responseDto);
|
).resolves.toEqual(responseDto);
|
||||||
|
|
||||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id });
|
expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', faceAssetId: faceStub.face1.id }]);
|
||||||
expect(personMock.getFacesByIds).toHaveBeenCalledWith([
|
expect(personMock.getFacesByIds).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
assetId: faceStub.face1.assetId,
|
assetId: faceStub.face1.assetId,
|
||||||
|
|
@ -441,11 +441,11 @@ describe(PersonService.name, () => {
|
||||||
|
|
||||||
describe('createPerson', () => {
|
describe('createPerson', () => {
|
||||||
it('should create a new person', async () => {
|
it('should create a new person', async () => {
|
||||||
personMock.create.mockResolvedValue(personStub.primaryPerson);
|
personMock.create.mockResolvedValue([personStub.primaryPerson]);
|
||||||
|
|
||||||
await expect(sut.create(authStub.admin, {})).resolves.toBe(personStub.primaryPerson);
|
await expect(sut.create(authStub.admin, {})).resolves.toBe(personStub.primaryPerson);
|
||||||
|
|
||||||
expect(personMock.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id });
|
expect(personMock.create).toHaveBeenCalledWith([{ ownerId: authStub.admin.user.id }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -496,6 +496,7 @@ describe(PersonService.name, () => {
|
||||||
items: [personStub.withName],
|
items: [personStub.withName],
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
});
|
});
|
||||||
|
personMock.getAllWithoutFaces.mockResolvedValue([]);
|
||||||
|
|
||||||
await sut.handleQueueDetectFaces({ force: true });
|
await sut.handleQueueDetectFaces({ force: true });
|
||||||
|
|
||||||
|
|
@ -510,7 +511,7 @@ describe(PersonService.name, () => {
|
||||||
|
|
||||||
it('should delete existing people and faces if forced', async () => {
|
it('should delete existing people and faces if forced', async () => {
|
||||||
personMock.getAll.mockResolvedValue({
|
personMock.getAll.mockResolvedValue({
|
||||||
items: [faceStub.face1.person],
|
items: [faceStub.face1.person, personStub.randomPerson],
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
});
|
});
|
||||||
personMock.getAllFaces.mockResolvedValue({
|
personMock.getAllFaces.mockResolvedValue({
|
||||||
|
|
@ -521,6 +522,7 @@ describe(PersonService.name, () => {
|
||||||
items: [assetStub.image],
|
items: [assetStub.image],
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
});
|
});
|
||||||
|
personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]);
|
||||||
|
|
||||||
await sut.handleQueueDetectFaces({ force: true });
|
await sut.handleQueueDetectFaces({ force: true });
|
||||||
|
|
||||||
|
|
@ -531,8 +533,8 @@ describe(PersonService.name, () => {
|
||||||
data: { id: assetStub.image.id },
|
data: { id: assetStub.image.id },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(personMock.delete).toHaveBeenCalledWith([faceStub.face1.person]);
|
expect(personMock.delete).toHaveBeenCalledWith([personStub.randomPerson]);
|
||||||
expect(storageMock.unlink).toHaveBeenCalledWith(faceStub.face1.person.thumbnailPath);
|
expect(storageMock.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -561,10 +563,14 @@ describe(PersonService.name, () => {
|
||||||
items: [faceStub.face1],
|
items: [faceStub.face1],
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
});
|
});
|
||||||
|
personMock.getAllWithoutFaces.mockResolvedValue([]);
|
||||||
|
|
||||||
await sut.handleQueueRecognizeFaces({});
|
await sut.handleQueueRecognizeFaces({});
|
||||||
|
|
||||||
expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { personId: IsNull() } });
|
expect(personMock.getAllFaces).toHaveBeenCalledWith(
|
||||||
|
{ skip: 0, take: 1000 },
|
||||||
|
{ where: { personId: IsNull(), sourceType: IsNull() } },
|
||||||
|
);
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.FACIAL_RECOGNITION,
|
name: JobName.FACIAL_RECOGNITION,
|
||||||
|
|
@ -586,6 +592,7 @@ describe(PersonService.name, () => {
|
||||||
items: [faceStub.face1],
|
items: [faceStub.face1],
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
});
|
});
|
||||||
|
personMock.getAllWithoutFaces.mockResolvedValue([]);
|
||||||
|
|
||||||
await sut.handleQueueRecognizeFaces({ force: true });
|
await sut.handleQueueRecognizeFaces({ force: true });
|
||||||
|
|
||||||
|
|
@ -616,6 +623,8 @@ describe(PersonService.name, () => {
|
||||||
items: [faceStub.face1],
|
items: [faceStub.face1],
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
});
|
});
|
||||||
|
personMock.getAllWithoutFaces.mockResolvedValue([]);
|
||||||
|
|
||||||
await sut.handleQueueRecognizeFaces({ force: true, nightly: true });
|
await sut.handleQueueRecognizeFaces({ force: true, nightly: true });
|
||||||
|
|
||||||
expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE);
|
expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE);
|
||||||
|
|
@ -641,6 +650,7 @@ describe(PersonService.name, () => {
|
||||||
items: [faceStub.face1],
|
items: [faceStub.face1],
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
});
|
});
|
||||||
|
personMock.getAllWithoutFaces.mockResolvedValue([]);
|
||||||
|
|
||||||
await sut.handleQueueRecognizeFaces({ force: true, nightly: true });
|
await sut.handleQueueRecognizeFaces({ force: true, nightly: true });
|
||||||
|
|
||||||
|
|
@ -654,7 +664,7 @@ describe(PersonService.name, () => {
|
||||||
it('should delete existing people and faces if forced', async () => {
|
it('should delete existing people and faces if forced', async () => {
|
||||||
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
|
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
|
||||||
personMock.getAll.mockResolvedValue({
|
personMock.getAll.mockResolvedValue({
|
||||||
items: [faceStub.face1.person],
|
items: [faceStub.face1.person, personStub.randomPerson],
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
});
|
});
|
||||||
personMock.getAllFaces.mockResolvedValue({
|
personMock.getAllFaces.mockResolvedValue({
|
||||||
|
|
@ -662,17 +672,19 @@ describe(PersonService.name, () => {
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]);
|
||||||
|
|
||||||
await sut.handleQueueRecognizeFaces({ force: true });
|
await sut.handleQueueRecognizeFaces({ force: true });
|
||||||
|
|
||||||
expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, {});
|
expect(personMock.deleteAllFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING });
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.FACIAL_RECOGNITION,
|
name: JobName.FACIAL_RECOGNITION,
|
||||||
data: { id: faceStub.face1.id, deferred: false },
|
data: { id: faceStub.face1.id, deferred: false },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(personMock.delete).toHaveBeenCalledWith([faceStub.face1.person]);
|
expect(personMock.delete).toHaveBeenCalledWith([personStub.randomPerson]);
|
||||||
expect(storageMock.unlink).toHaveBeenCalledWith(faceStub.face1.person.thumbnailPath);
|
expect(storageMock.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -807,7 +819,7 @@ describe(PersonService.name, () => {
|
||||||
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||||
searchMock.searchFaces.mockResolvedValue(faces);
|
searchMock.searchFaces.mockResolvedValue(faces);
|
||||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||||
personMock.create.mockResolvedValue(faceStub.primaryFace1.person);
|
personMock.create.mockResolvedValue([faceStub.primaryFace1.person]);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||||
|
|
||||||
|
|
@ -832,14 +844,16 @@ describe(PersonService.name, () => {
|
||||||
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||||
searchMock.searchFaces.mockResolvedValue(faces);
|
searchMock.searchFaces.mockResolvedValue(faces);
|
||||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||||
personMock.create.mockResolvedValue(personStub.withName);
|
personMock.create.mockResolvedValue([personStub.withName]);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||||
|
|
||||||
expect(personMock.create).toHaveBeenCalledWith({
|
expect(personMock.create).toHaveBeenCalledWith([
|
||||||
ownerId: faceStub.noPerson1.asset.ownerId,
|
{
|
||||||
faceAssetId: faceStub.noPerson1.id,
|
ownerId: faceStub.noPerson1.asset.ownerId,
|
||||||
});
|
faceAssetId: faceStub.noPerson1.id,
|
||||||
|
},
|
||||||
|
]);
|
||||||
expect(personMock.reassignFaces).toHaveBeenCalledWith({
|
expect(personMock.reassignFaces).toHaveBeenCalledWith({
|
||||||
faceIds: [faceStub.noPerson1.id],
|
faceIds: [faceStub.noPerson1.id],
|
||||||
newPersonId: personStub.withName.id,
|
newPersonId: personStub.withName.id,
|
||||||
|
|
@ -851,7 +865,7 @@ describe(PersonService.name, () => {
|
||||||
|
|
||||||
searchMock.searchFaces.mockResolvedValue(faces);
|
searchMock.searchFaces.mockResolvedValue(faces);
|
||||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||||
personMock.create.mockResolvedValue(personStub.withName);
|
personMock.create.mockResolvedValue([personStub.withName]);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||||
|
|
||||||
|
|
@ -870,7 +884,7 @@ describe(PersonService.name, () => {
|
||||||
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
||||||
searchMock.searchFaces.mockResolvedValue(faces);
|
searchMock.searchFaces.mockResolvedValue(faces);
|
||||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||||
personMock.create.mockResolvedValue(personStub.withName);
|
personMock.create.mockResolvedValue([personStub.withName]);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||||
|
|
||||||
|
|
@ -892,7 +906,7 @@ describe(PersonService.name, () => {
|
||||||
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
||||||
searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]);
|
searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]);
|
||||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||||
personMock.create.mockResolvedValue(personStub.withName);
|
personMock.create.mockResolvedValue([personStub.withName]);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true });
|
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true });
|
||||||
|
|
||||||
|
|
@ -965,10 +979,12 @@ describe(PersonService.name, () => {
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(personMock.update).toHaveBeenCalledWith({
|
expect(personMock.update).toHaveBeenCalledWith([
|
||||||
id: 'person-1',
|
{
|
||||||
thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
|
id: 'person-1',
|
||||||
});
|
thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate a thumbnail without going negative', async () => {
|
it('should generate a thumbnail without going negative', async () => {
|
||||||
|
|
@ -1087,7 +1103,7 @@ describe(PersonService.name, () => {
|
||||||
it('should merge two people with smart merge', async () => {
|
it('should merge two people with smart merge', async () => {
|
||||||
personMock.getById.mockResolvedValueOnce(personStub.randomPerson);
|
personMock.getById.mockResolvedValueOnce(personStub.randomPerson);
|
||||||
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
||||||
personMock.update.mockResolvedValue({ ...personStub.randomPerson, name: personStub.primaryPerson.name });
|
personMock.update.mockResolvedValue([{ ...personStub.randomPerson, name: personStub.primaryPerson.name }]);
|
||||||
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3']));
|
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3']));
|
||||||
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
||||||
|
|
||||||
|
|
@ -1100,10 +1116,12 @@ describe(PersonService.name, () => {
|
||||||
oldPersonId: personStub.primaryPerson.id,
|
oldPersonId: personStub.primaryPerson.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(personMock.update).toHaveBeenCalledWith({
|
expect(personMock.update).toHaveBeenCalledWith([
|
||||||
id: personStub.randomPerson.id,
|
{
|
||||||
name: personStub.primaryPerson.name,
|
id: personStub.randomPerson.id,
|
||||||
});
|
name: personStub.primaryPerson.name,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||||
});
|
});
|
||||||
|
|
@ -1177,6 +1195,7 @@ describe(PersonService.name, () => {
|
||||||
id: faceStub.face1.id,
|
id: faceStub.face1.id,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
person: mapPerson(personStub.withName),
|
person: mapPerson(personStub.withName),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { PersonPathType } from 'src/entities/move.entity';
|
import { PersonPathType } from 'src/entities/move.entity';
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
import { PersonEntity } from 'src/entities/person.entity';
|
||||||
import { AssetType, Permission, SystemMetadataKey } from 'src/enum';
|
import { AssetType, Permission, SourceType, SystemMetadataKey } from 'src/enum';
|
||||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||||
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
||||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||||
|
|
@ -53,7 +53,7 @@ import { checkAccess, requireAccess } from 'src/utils/access';
|
||||||
import { getAssetFiles } from 'src/utils/asset.util';
|
import { getAssetFiles } from 'src/utils/asset.util';
|
||||||
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
|
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
import { isFacialRecognitionEnabled } from 'src/utils/misc';
|
import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||||
import { usePagination } from 'src/utils/pagination';
|
import { usePagination } from 'src/utils/pagination';
|
||||||
import { IsNull } from 'typeorm';
|
import { IsNull } from 'typeorm';
|
||||||
|
|
||||||
|
|
@ -173,10 +173,7 @@ export class PersonService {
|
||||||
const assetFace = await this.repository.getRandomFace(personId);
|
const assetFace = await this.repository.getRandomFace(personId);
|
||||||
|
|
||||||
if (assetFace !== null) {
|
if (assetFace !== null) {
|
||||||
await this.repository.update({
|
await this.repository.update([{ id: personId, faceAssetId: assetFace.id }]);
|
||||||
id: personId,
|
|
||||||
faceAssetId: assetFace.id,
|
|
||||||
});
|
|
||||||
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personId } });
|
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personId } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -214,13 +211,16 @@ export class PersonService {
|
||||||
return assets.map((asset) => mapAsset(asset));
|
return assets.map((asset) => mapAsset(asset));
|
||||||
}
|
}
|
||||||
|
|
||||||
create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
|
async create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
|
||||||
return this.repository.create({
|
const [created] = await this.repository.create([
|
||||||
ownerId: auth.user.id,
|
{
|
||||||
name: dto.name,
|
ownerId: auth.user.id,
|
||||||
birthDate: dto.birthDate,
|
name: dto.name,
|
||||||
isHidden: dto.isHidden,
|
birthDate: dto.birthDate,
|
||||||
});
|
isHidden: dto.isHidden,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
||||||
|
|
@ -239,7 +239,7 @@ export class PersonService {
|
||||||
faceId = face.id;
|
faceId = face.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const person = await this.repository.update({ id, faceAssetId: faceId, name, birthDate, isHidden });
|
const [person] = await this.repository.update([{ id, faceAssetId: faceId, name, birthDate, isHidden }]);
|
||||||
|
|
||||||
if (assetId) {
|
if (assetId) {
|
||||||
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
|
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
|
||||||
|
|
@ -296,8 +296,8 @@ export class PersonService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (force) {
|
if (force) {
|
||||||
await this.deleteAllPeople();
|
await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING });
|
||||||
await this.repository.deleteAllFaces();
|
await this.handlePersonCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||||
|
|
@ -339,11 +339,7 @@ export class PersonService {
|
||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!asset.isVisible) {
|
if (!asset.isVisible || asset.faces.length > 0) {
|
||||||
return JobStatus.SKIPPED;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!asset.isVisible) {
|
|
||||||
return JobStatus.SKIPPED;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -408,7 +404,8 @@ export class PersonService {
|
||||||
const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION);
|
const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION);
|
||||||
|
|
||||||
if (force) {
|
if (force) {
|
||||||
await this.deleteAllPeople();
|
await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING });
|
||||||
|
await this.handlePersonCleanup();
|
||||||
} else if (waiting) {
|
} else if (waiting) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Skipping facial recognition queueing because ${waiting} job${waiting > 1 ? 's are' : ' is'} already queued`,
|
`Skipping facial recognition queueing because ${waiting} job${waiting > 1 ? 's are' : ' is'} already queued`,
|
||||||
|
|
@ -418,7 +415,9 @@ export class PersonService {
|
||||||
|
|
||||||
const lastRun = new Date().toISOString();
|
const lastRun = new Date().toISOString();
|
||||||
const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||||
this.repository.getAllFaces(pagination, { where: force ? undefined : { personId: IsNull() } }),
|
this.repository.getAllFaces(pagination, {
|
||||||
|
where: force ? undefined : { personId: IsNull(), sourceType: IsNull() },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
for await (const page of facePagination) {
|
for await (const page of facePagination) {
|
||||||
|
|
@ -441,13 +440,18 @@ export class PersonService {
|
||||||
const face = await this.repository.getFaceByIdWithAssets(
|
const face = await this.repository.getFaceByIdWithAssets(
|
||||||
id,
|
id,
|
||||||
{ person: true, asset: true, faceSearch: true },
|
{ person: true, asset: true, faceSearch: true },
|
||||||
{ id: true, personId: true, faceSearch: { embedding: true } },
|
{ id: true, personId: true, sourceType: true, faceSearch: { embedding: true } },
|
||||||
);
|
);
|
||||||
if (!face || !face.asset) {
|
if (!face || !face.asset) {
|
||||||
this.logger.warn(`Face ${id} not found`);
|
this.logger.warn(`Face ${id} not found`);
|
||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (face.sourceType !== SourceType.MACHINE_LEARNING) {
|
||||||
|
this.logger.warn(`Skipping face ${id} due to source ${face.sourceType}`);
|
||||||
|
return JobStatus.SKIPPED;
|
||||||
|
}
|
||||||
|
|
||||||
if (!face.faceSearch?.embedding) {
|
if (!face.faceSearch?.embedding) {
|
||||||
this.logger.warn(`Face ${id} does not have an embedding`);
|
this.logger.warn(`Face ${id} does not have an embedding`);
|
||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
|
|
@ -497,7 +501,7 @@ export class PersonService {
|
||||||
|
|
||||||
if (isCore && !personId) {
|
if (isCore && !personId) {
|
||||||
this.logger.log(`Creating new person for face ${id}`);
|
this.logger.log(`Creating new person for face ${id}`);
|
||||||
const newPerson = await this.repository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
|
const [newPerson] = await this.repository.create([{ ownerId: face.asset.ownerId, faceAssetId: face.id }]);
|
||||||
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } });
|
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } });
|
||||||
personId = newPerson.id;
|
personId = newPerson.id;
|
||||||
}
|
}
|
||||||
|
|
@ -522,8 +526,8 @@ export class PersonService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleGeneratePersonThumbnail(data: IEntityJob): Promise<JobStatus> {
|
async handleGeneratePersonThumbnail(data: IEntityJob): Promise<JobStatus> {
|
||||||
const { machineLearning, image } = await this.configCore.getConfig({ withCache: true });
|
const { machineLearning, metadata, image } = await this.configCore.getConfig({ withCache: true });
|
||||||
if (!isFacialRecognitionEnabled(machineLearning)) {
|
if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) {
|
||||||
return JobStatus.SKIPPED;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -573,7 +577,7 @@ export class PersonService {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
await this.mediaRepository.generateThumbnail(inputPath, thumbnailPath, thumbnailOptions);
|
await this.mediaRepository.generateThumbnail(inputPath, thumbnailPath, thumbnailOptions);
|
||||||
await this.repository.update({ id: person.id, thumbnailPath });
|
await this.repository.update([{ id: person.id, thumbnailPath }]);
|
||||||
|
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
@ -620,7 +624,7 @@ export class PersonService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(update).length > 0) {
|
if (Object.keys(update).length > 0) {
|
||||||
primaryPerson = await this.repository.update({ id: primaryPerson.id, ...update });
|
[primaryPerson] = await this.repository.update([{ id: primaryPerson.id, ...update }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergeName = mergePerson.name || mergePerson.id;
|
const mergeName = mergePerson.name || mergePerson.id;
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@ describe(ServerService.name, () => {
|
||||||
smartSearch: true,
|
smartSearch: true,
|
||||||
duplicateDetection: true,
|
duplicateDetection: true,
|
||||||
facialRecognition: true,
|
facialRecognition: true,
|
||||||
|
importFaces: false,
|
||||||
map: true,
|
map: true,
|
||||||
reverseGeocoding: true,
|
reverseGeocoding: true,
|
||||||
oauth: false,
|
oauth: false,
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ export class ServerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFeatures(): Promise<ServerFeaturesDto> {
|
async getFeatures(): Promise<ServerFeaturesDto> {
|
||||||
const { reverseGeocoding, map, machineLearning, trash, oauth, passwordLogin, notifications } =
|
const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications } =
|
||||||
await this.configCore.getConfig({ withCache: false });
|
await this.configCore.getConfig({ withCache: false });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -99,6 +99,7 @@ export class ServerService {
|
||||||
duplicateDetection: isDuplicateDetectionEnabled(machineLearning),
|
duplicateDetection: isDuplicateDetectionEnabled(machineLearning),
|
||||||
map: map.enabled,
|
map: map.enabled,
|
||||||
reverseGeocoding: reverseGeocoding.enabled,
|
reverseGeocoding: reverseGeocoding.enabled,
|
||||||
|
importFaces: metadata.faces.import,
|
||||||
sidecar: true,
|
sidecar: true,
|
||||||
search: true,
|
search: true,
|
||||||
trash: trash.enabled,
|
trash: trash.enabled,
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,11 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
level: LogLevel.LOG,
|
level: LogLevel.LOG,
|
||||||
},
|
},
|
||||||
|
metadata: {
|
||||||
|
faces: {
|
||||||
|
import: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
machineLearning: {
|
machineLearning: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
url: 'http://immich-machine-learning:3003',
|
url: 'http://immich-machine-learning:3003',
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ export const isFacialRecognitionEnabled = (machineLearning: SystemConfig['machin
|
||||||
isMachineLearningEnabled(machineLearning) && machineLearning.facialRecognition.enabled;
|
isMachineLearningEnabled(machineLearning) && machineLearning.facialRecognition.enabled;
|
||||||
export const isDuplicateDetectionEnabled = (machineLearning: SystemConfig['machineLearning']) =>
|
export const isDuplicateDetectionEnabled = (machineLearning: SystemConfig['machineLearning']) =>
|
||||||
isSmartSearchEnabled(machineLearning) && machineLearning.duplicateDetection.enabled;
|
isSmartSearchEnabled(machineLearning) && machineLearning.duplicateDetection.enabled;
|
||||||
|
export const isFaceImportEnabled = (metadata: SystemConfig['metadata']) => metadata.faces.import;
|
||||||
|
|
||||||
export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED';
|
export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED';
|
||||||
|
|
||||||
|
|
|
||||||
10
server/test/fixtures/face.stub.ts
vendored
10
server/test/fixtures/face.stub.ts
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
|
import { SourceType } from 'src/enum';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { assetStub } from 'test/fixtures/asset.stub';
|
||||||
import { personStub } from 'test/fixtures/person.stub';
|
import { personStub } from 'test/fixtures/person.stub';
|
||||||
|
|
||||||
|
|
@ -17,6 +18,7 @@ export const faceStub = {
|
||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
faceSearch: { faceId: 'assetFaceId1', embedding: [1, 2, 3, 4] },
|
faceSearch: { faceId: 'assetFaceId1', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
primaryFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
primaryFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
|
|
@ -31,6 +33,7 @@ export const faceStub = {
|
||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
faceSearch: { faceId: 'assetFaceId2', embedding: [1, 2, 3, 4] },
|
faceSearch: { faceId: 'assetFaceId2', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
mergeFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
mergeFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
|
|
@ -45,6 +48,7 @@ export const faceStub = {
|
||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
faceSearch: { faceId: 'assetFaceId3', embedding: [1, 2, 3, 4] },
|
faceSearch: { faceId: 'assetFaceId3', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
mergeFace2: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
mergeFace2: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
|
|
@ -59,6 +63,7 @@ export const faceStub = {
|
||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
faceSearch: { faceId: 'assetFaceId4', embedding: [1, 2, 3, 4] },
|
faceSearch: { faceId: 'assetFaceId4', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
start: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
start: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
|
|
@ -73,6 +78,7 @@ export const faceStub = {
|
||||||
boundingBoxY2: 505,
|
boundingBoxY2: 505,
|
||||||
imageHeight: 2880,
|
imageHeight: 2880,
|
||||||
imageWidth: 2160,
|
imageWidth: 2160,
|
||||||
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
faceSearch: { faceId: 'assetFaceId5', embedding: [1, 2, 3, 4] },
|
faceSearch: { faceId: 'assetFaceId5', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
middle: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
middle: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
|
|
@ -87,6 +93,7 @@ export const faceStub = {
|
||||||
boundingBoxY2: 200,
|
boundingBoxY2: 200,
|
||||||
imageHeight: 500,
|
imageHeight: 500,
|
||||||
imageWidth: 400,
|
imageWidth: 400,
|
||||||
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
faceSearch: { faceId: 'assetFaceId6', embedding: [1, 2, 3, 4] },
|
faceSearch: { faceId: 'assetFaceId6', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
end: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
end: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
|
|
@ -101,6 +108,7 @@ export const faceStub = {
|
||||||
boundingBoxY2: 495,
|
boundingBoxY2: 495,
|
||||||
imageHeight: 500,
|
imageHeight: 500,
|
||||||
imageWidth: 500,
|
imageWidth: 500,
|
||||||
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
faceSearch: { faceId: 'assetFaceId7', embedding: [1, 2, 3, 4] },
|
faceSearch: { faceId: 'assetFaceId7', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
noPerson1: Object.freeze<AssetFaceEntity>({
|
noPerson1: Object.freeze<AssetFaceEntity>({
|
||||||
|
|
@ -115,6 +123,7 @@ export const faceStub = {
|
||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
faceSearch: { faceId: 'assetFaceId8', embedding: [1, 2, 3, 4] },
|
faceSearch: { faceId: 'assetFaceId8', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
noPerson2: Object.freeze<AssetFaceEntity>({
|
noPerson2: Object.freeze<AssetFaceEntity>({
|
||||||
|
|
@ -129,6 +138,7 @@ export const faceStub = {
|
||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
faceSearch: { faceId: 'assetFaceId9', embedding: [1, 2, 3, 4] },
|
faceSearch: { faceId: 'assetFaceId9', embedding: [1, 2, 3, 4] },
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
71
server/test/fixtures/metadata.stub.ts
vendored
Normal file
71
server/test/fixtures/metadata.stub.ts
vendored
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { ImmichTags } from 'src/interfaces/metadata.interface';
|
||||||
|
import { personStub } from 'test/fixtures/person.stub';
|
||||||
|
|
||||||
|
export const metadataStub = {
|
||||||
|
empty: Object.freeze<ImmichTags>({}),
|
||||||
|
withFace: Object.freeze<ImmichTags>({
|
||||||
|
RegionInfo: {
|
||||||
|
AppliedToDimensions: {
|
||||||
|
W: 100,
|
||||||
|
H: 100,
|
||||||
|
Unit: 'normalized',
|
||||||
|
},
|
||||||
|
RegionList: [
|
||||||
|
{
|
||||||
|
Type: 'face',
|
||||||
|
Name: personStub.withName.name,
|
||||||
|
Area: {
|
||||||
|
X: 0.05,
|
||||||
|
Y: 0.05,
|
||||||
|
W: 0.1,
|
||||||
|
H: 0.1,
|
||||||
|
Unit: 'normalized',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
withFaceEmptyName: Object.freeze<ImmichTags>({
|
||||||
|
RegionInfo: {
|
||||||
|
AppliedToDimensions: {
|
||||||
|
W: 100,
|
||||||
|
H: 100,
|
||||||
|
Unit: 'normalized',
|
||||||
|
},
|
||||||
|
RegionList: [
|
||||||
|
{
|
||||||
|
Type: 'face',
|
||||||
|
Name: '',
|
||||||
|
Area: {
|
||||||
|
X: 0.05,
|
||||||
|
Y: 0.05,
|
||||||
|
W: 0.1,
|
||||||
|
H: 0.1,
|
||||||
|
Unit: 'normalized',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
withFaceNoName: Object.freeze<ImmichTags>({
|
||||||
|
RegionInfo: {
|
||||||
|
AppliedToDimensions: {
|
||||||
|
W: 100,
|
||||||
|
H: 100,
|
||||||
|
Unit: 'normalized',
|
||||||
|
},
|
||||||
|
RegionList: [
|
||||||
|
{
|
||||||
|
Type: 'face',
|
||||||
|
Area: {
|
||||||
|
X: 0.05,
|
||||||
|
Y: 0.05,
|
||||||
|
W: 0.1,
|
||||||
|
H: 0.1,
|
||||||
|
Unit: 'normalized',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
@ -10,6 +10,7 @@ export const newPersonRepositoryMock = (): Mocked<IPersonRepository> => {
|
||||||
getAllWithoutFaces: vitest.fn(),
|
getAllWithoutFaces: vitest.fn(),
|
||||||
|
|
||||||
getByName: vitest.fn(),
|
getByName: vitest.fn(),
|
||||||
|
getDistinctNames: vitest.fn(),
|
||||||
|
|
||||||
create: vitest.fn(),
|
create: vitest.fn(),
|
||||||
update: vitest.fn(),
|
update: vitest.fn(),
|
||||||
|
|
@ -24,6 +25,7 @@ export const newPersonRepositoryMock = (): Mocked<IPersonRepository> => {
|
||||||
|
|
||||||
reassignFaces: vitest.fn(),
|
reassignFaces: vitest.fn(),
|
||||||
createFaces: vitest.fn(),
|
createFaces: vitest.fn(),
|
||||||
|
replaceFaces: vitest.fn(),
|
||||||
getFaces: vitest.fn(),
|
getFaces: vitest.fn(),
|
||||||
reassignFace: vitest.fn(),
|
reassignFace: vitest.fn(),
|
||||||
getFaceById: vitest.fn(),
|
getFaceById: vitest.fn(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { SystemConfigDto } from '@immich/sdk';
|
||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
export let savedConfig: SystemConfigDto;
|
||||||
|
export let defaultConfig: SystemConfigDto;
|
||||||
|
export let config: SystemConfigDto; // this is the config that is being edited
|
||||||
|
export let disabled = false;
|
||||||
|
export let onReset: SettingsResetEvent;
|
||||||
|
export let onSave: SettingsSaveEvent;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<div in:fade={{ duration: 500 }}>
|
||||||
|
<form autocomplete="off" on:submit|preventDefault class="mx-4 mt-4">
|
||||||
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
|
<SettingSwitch
|
||||||
|
title={$t('admin.metadata_faces_import_setting')}
|
||||||
|
subtitle={$t('admin.metadata_faces_import_setting_description')}
|
||||||
|
bind:checked={config.metadata.faces.import}
|
||||||
|
{disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingButtonsRow
|
||||||
|
onReset={(options) => onReset({ ...options, configKeys: ['metadata'] })}
|
||||||
|
onSave={() => onSave({ metadata: config.metadata })}
|
||||||
|
showResetToDefault={!isEqual(savedConfig.metadata.faces.import, defaultConfig.metadata.faces.import)}
|
||||||
|
{disabled}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -137,7 +137,11 @@
|
||||||
"map_settings_description": "Manage map settings",
|
"map_settings_description": "Manage map settings",
|
||||||
"map_style_description": "URL to a style.json map theme",
|
"map_style_description": "URL to a style.json map theme",
|
||||||
"metadata_extraction_job": "Extract metadata",
|
"metadata_extraction_job": "Extract metadata",
|
||||||
"metadata_extraction_job_description": "Extract metadata information from each asset, such as GPS and resolution",
|
"metadata_extraction_job_description": "Extract metadata information from each asset, such as GPS, faces and resolution",
|
||||||
|
"metadata_faces_import_setting": "Enable face import",
|
||||||
|
"metadata_faces_import_setting_description": "Import faces from image EXIF data and sidecar files",
|
||||||
|
"metadata_settings": "Metadata Settings",
|
||||||
|
"metadata_settings_description": "Manage metadata settings",
|
||||||
"migration_job": "Migration",
|
"migration_job": "Migration",
|
||||||
"migration_job_description": "Migrate thumbnails for assets and faces to the latest folder structure",
|
"migration_job_description": "Migrate thumbnails for assets and faces to the latest folder structure",
|
||||||
"no_paths_added": "No paths added",
|
"no_paths_added": "No paths added",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export const featureFlags = writable<FeatureFlags>({
|
||||||
smartSearch: true,
|
smartSearch: true,
|
||||||
duplicateDetection: false,
|
duplicateDetection: false,
|
||||||
facialRecognition: true,
|
facialRecognition: true,
|
||||||
|
importFaces: false,
|
||||||
sidecar: true,
|
sidecar: true,
|
||||||
map: true,
|
map: true,
|
||||||
reverseGeocoding: true,
|
reverseGeocoding: true,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte';
|
import FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte';
|
||||||
import ImageSettings from '$lib/components/admin-page/settings/image/image-settings.svelte';
|
import ImageSettings from '$lib/components/admin-page/settings/image/image-settings.svelte';
|
||||||
import JobSettings from '$lib/components/admin-page/settings/job-settings/job-settings.svelte';
|
import JobSettings from '$lib/components/admin-page/settings/job-settings/job-settings.svelte';
|
||||||
|
import MetadataSettings from '$lib/components/admin-page/settings/metadata-settings/metadata-settings.svelte';
|
||||||
import LibrarySettings from '$lib/components/admin-page/settings/library-settings/library-settings.svelte';
|
import LibrarySettings from '$lib/components/admin-page/settings/library-settings/library-settings.svelte';
|
||||||
import LoggingSettings from '$lib/components/admin-page/settings/logging-settings/logging-settings.svelte';
|
import LoggingSettings from '$lib/components/admin-page/settings/logging-settings/logging-settings.svelte';
|
||||||
import MachineLearningSettings from '$lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte';
|
import MachineLearningSettings from '$lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte';
|
||||||
|
|
@ -86,6 +87,12 @@
|
||||||
subtitle: $t('admin.job_settings_description'),
|
subtitle: $t('admin.job_settings_description'),
|
||||||
key: 'job',
|
key: 'job',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
component: MetadataSettings,
|
||||||
|
title: $t('admin.metadata_settings'),
|
||||||
|
subtitle: $t('admin.metadata_settings_description'),
|
||||||
|
key: 'metadata',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
component: LibrarySettings,
|
component: LibrarySettings,
|
||||||
title: $t('admin.library_settings'),
|
title: $t('admin.library_settings'),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue