1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(web/server): merge faces (#3121)

* feat(server/web): Merge faces

* get parent id

* update

* query to get identical asset and change controller

* change delete asset signature

* delete identical assets

* gaming time

* delete merge person

* query

* query

* generate api

* pr feedback

* generate api

* naming

* remove unused method

* Update server/src/domain/person/person.service.ts

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* Update server/src/domain/person/person.service.ts

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* better method signature

* cleaning up

* fix bug

* added interfaces

* added tests

* merge main

* api

* build merge face interface

* api

* selector interface

* style

* more style

* clean up import

* styling

* styling

* better

* styling

* styling

* add merge face diablog

* finished

* refactor: merge person endpoint

* refactor: merge person component

* chore: open api

* fix: tests

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Alex 2023-07-11 16:52:41 -05:00 committed by GitHub
parent 848ba685eb
commit c86b2ae500
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 958 additions and 71 deletions

View File

@ -798,6 +798,41 @@ export interface AuthDeviceResponseDto {
*/
'deviceOS': string;
}
/**
*
* @export
* @interface BulkIdResponseDto
*/
export interface BulkIdResponseDto {
/**
*
* @type {string}
* @memberof BulkIdResponseDto
*/
'id': string;
/**
*
* @type {boolean}
* @memberof BulkIdResponseDto
*/
'success': boolean;
/**
*
* @type {string}
* @memberof BulkIdResponseDto
*/
'error'?: BulkIdResponseDtoErrorEnum;
}
export const BulkIdResponseDtoErrorEnum = {
Duplicate: 'duplicate',
NoPermission: 'no_permission',
NotFound: 'not_found',
Unknown: 'unknown'
} as const;
export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum];
/**
*
* @export
@ -1686,6 +1721,19 @@ export interface MemoryLaneResponseDto {
*/
'assets': Array<AssetResponseDto>;
}
/**
*
* @export
* @interface MergePersonDto
*/
export interface MergePersonDto {
/**
*
* @type {Array<string>}
* @memberof MergePersonDto
*/
'ids': Array<string>;
}
/**
*
* @export
@ -8807,6 +8855,54 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {MergePersonDto} mergePersonDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
mergePerson: async (id: string, mergePersonDto: MergePersonDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('mergePerson', 'id', id)
// verify required parameter 'mergePersonDto' is not null or undefined
assertParamExists('mergePerson', 'mergePersonDto', mergePersonDto)
const localVarPath = `/person/{id}/merge`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(mergePersonDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
@ -8904,6 +9000,17 @@ export const PersonApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonThumbnail(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
* @param {MergePersonDto} mergePersonDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async mergePerson(id: string, mergePersonDto: MergePersonDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
@ -8960,6 +9067,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
getPersonThumbnail(requestParameters: PersonApiGetPersonThumbnailRequest, options?: AxiosRequestConfig): AxiosPromise<File> {
return localVarFp.getPersonThumbnail(requestParameters.id, options).then((request) => request(axios, basePath));
},
/**
*
* @param {PersonApiMergePersonRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {PersonApiUpdatePersonRequest} requestParameters Request parameters.
@ -9014,6 +9130,27 @@ export interface PersonApiGetPersonThumbnailRequest {
readonly id: string
}
/**
* Request parameters for mergePerson operation in PersonApi.
* @export
* @interface PersonApiMergePersonRequest
*/
export interface PersonApiMergePersonRequest {
/**
*
* @type {string}
* @memberof PersonApiMergePerson
*/
readonly id: string
/**
*
* @type {MergePersonDto}
* @memberof PersonApiMergePerson
*/
readonly mergePersonDto: MergePersonDto
}
/**
* Request parameters for updatePerson operation in PersonApi.
* @export
@ -9085,6 +9222,17 @@ export class PersonApi extends BaseAPI {
return PersonApiFp(this.configuration).getPersonThumbnail(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {PersonApiMergePersonRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {PersonApiUpdatePersonRequest} requestParameters Request parameters.

View File

@ -32,6 +32,7 @@ doc/AssetTypeEnum.md
doc/AudioCodec.md
doc/AuthDeviceResponseDto.md
doc/AuthenticationApi.md
doc/BulkIdResponseDto.md
doc/ChangePasswordDto.md
doc/CheckDuplicateAssetDto.md
doc/CheckDuplicateAssetResponseDto.md
@ -64,6 +65,7 @@ doc/LoginResponseDto.md
doc/LogoutResponseDto.md
doc/MapMarkerResponseDto.md
doc/MemoryLaneResponseDto.md
doc/MergePersonDto.md
doc/OAuthApi.md
doc/OAuthCallbackDto.md
doc/OAuthConfigDto.md
@ -169,6 +171,7 @@ lib/model/asset_response_dto.dart
lib/model/asset_type_enum.dart
lib/model/audio_codec.dart
lib/model/auth_device_response_dto.dart
lib/model/bulk_id_response_dto.dart
lib/model/change_password_dto.dart
lib/model/check_duplicate_asset_dto.dart
lib/model/check_duplicate_asset_response_dto.dart
@ -200,6 +203,7 @@ lib/model/login_response_dto.dart
lib/model/logout_response_dto.dart
lib/model/map_marker_response_dto.dart
lib/model/memory_lane_response_dto.dart
lib/model/merge_person_dto.dart
lib/model/o_auth_callback_dto.dart
lib/model/o_auth_config_dto.dart
lib/model/o_auth_config_response_dto.dart
@ -277,6 +281,7 @@ test/asset_type_enum_test.dart
test/audio_codec_test.dart
test/auth_device_response_dto_test.dart
test/authentication_api_test.dart
test/bulk_id_response_dto_test.dart
test/change_password_dto_test.dart
test/check_duplicate_asset_dto_test.dart
test/check_duplicate_asset_response_dto_test.dart
@ -309,6 +314,7 @@ test/login_response_dto_test.dart
test/logout_response_dto_test.dart
test/map_marker_response_dto_test.dart
test/memory_lane_response_dto_test.dart
test/merge_person_dto_test.dart
test/o_auth_api_test.dart
test/o_auth_callback_dto_test.dart
test/o_auth_config_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

BIN
mobile/openapi/doc/BulkIdResponseDto.md generated Normal file

Binary file not shown.

BIN
mobile/openapi/doc/MergePersonDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -2693,6 +2693,61 @@
]
}
},
"/person/{id}/merge": {
"post": {
"operationId": "mergePerson",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MergePersonDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/BulkIdResponseDto"
}
}
}
}
}
},
"tags": [
"Person"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/person/{id}/thumbnail": {
"get": {
"operationId": "getPersonThumbnail",
@ -4963,6 +5018,30 @@
"deviceOS"
]
},
"BulkIdResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"success": {
"type": "boolean"
},
"error": {
"type": "string",
"enum": [
"duplicate",
"no_permission",
"not_found",
"unknown"
]
}
},
"required": [
"id",
"success"
]
},
"ChangePasswordDto": {
"type": "object",
"properties": {
@ -5756,6 +5835,21 @@
"assets"
]
},
"MergePersonDto": {
"type": "object",
"properties": {
"ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
}
},
"required": [
"ids"
]
},
"OAuthCallbackDto": {
"type": "object",
"properties": {

View File

@ -1,11 +1,26 @@
/** @deprecated Use `BulkIdResponseDto` instead */
export enum AssetIdErrorReason {
DUPLICATE = 'duplicate',
NO_PERMISSION = 'no_permission',
NOT_FOUND = 'not_found',
}
/** @deprecated Use `BulkIdResponseDto` instead */
export class AssetIdsResponseDto {
assetId!: string;
success!: boolean;
error?: AssetIdErrorReason;
}
export enum BulkIdErrorReason {
DUPLICATE = 'duplicate',
NO_PERMISSION = 'no_permission',
NOT_FOUND = 'not_found',
UNKNOWN = 'unknown',
}
export class BulkIdResponseDto {
id!: string;
success!: boolean;
error?: BulkIdErrorReason;
}

View File

@ -1,5 +1,6 @@
import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
import { IsOptional, IsString } from 'class-validator';
import { ValidateUUID } from '../domain.util';
export class PersonUpdateDto {
/**
@ -17,6 +18,11 @@ export class PersonUpdateDto {
featureFaceAssetId?: string;
}
export class MergePersonDto {
@ValidateUUID({ each: true })
ids!: string[];
}
export class PersonResponseDto {
id!: string;
name!: string;

View File

@ -6,11 +6,19 @@ export interface PersonSearchOptions {
minimumFaceCount: number;
}
export interface UpdateFacesData {
oldPersonId: string;
newPersonId: string;
}
export interface IPersonRepository {
getAll(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>;
getAllWithoutFaces(): Promise<PersonEntity[]>;
getById(userId: string, personId: string): Promise<PersonEntity | null>;
getAssets(userId: string, id: string): Promise<AssetEntity[]>;
getAssets(userId: string, personId: string): Promise<AssetEntity[]>;
prepareReassignFaces(data: UpdateFacesData): Promise<string[]>;
reassignFaces(data: UpdateFacesData): Promise<number>;
create(entity: Partial<PersonEntity>): Promise<PersonEntity>;
update(entity: Partial<PersonEntity>): Promise<PersonEntity>;

View File

@ -8,7 +8,8 @@ import {
newStorageRepositoryMock,
personStub,
} from '@test';
import { IJobRepository, JobName } from '..';
import { BulkIdErrorReason } from '../asset';
import { IJobRepository, JobName } from '../job';
import { IStorageRepository } from '../storage';
import { PersonResponseDto } from './person.dto';
import { IPersonRepository } from './person.repository';
@ -154,4 +155,85 @@ describe(PersonService.name, () => {
});
});
});
describe('mergePerson', () => {
it('should merge two people', async () => {
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
personMock.getById.mockResolvedValueOnce(personStub.mergePerson);
personMock.prepareReassignFaces.mockResolvedValue([]);
personMock.delete.mockResolvedValue(personStub.mergePerson);
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
{ id: 'person-2', success: true },
]);
expect(personMock.prepareReassignFaces).toHaveBeenCalledWith({
newPersonId: personStub.primaryPerson.id,
oldPersonId: personStub.mergePerson.id,
});
expect(personMock.reassignFaces).toHaveBeenCalledWith({
newPersonId: personStub.primaryPerson.id,
oldPersonId: personStub.mergePerson.id,
});
expect(personMock.delete).toHaveBeenCalledWith(personStub.mergePerson);
});
it('should delete conflicting faces before merging', async () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson);
personMock.getById.mockResolvedValue(personStub.mergePerson);
personMock.prepareReassignFaces.mockResolvedValue([assetEntityStub.image.id]);
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
{ id: 'person-2', success: true },
]);
expect(personMock.prepareReassignFaces).toHaveBeenCalledWith({
newPersonId: personStub.primaryPerson.id,
oldPersonId: personStub.mergePerson.id,
});
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEARCH_REMOVE_FACE,
data: { assetId: assetEntityStub.image.id, personId: personStub.mergePerson.id },
});
});
it('should throw an error when the primary person is not found', async () => {
personMock.getById.mockResolvedValue(null);
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(personMock.delete).not.toHaveBeenCalled();
});
it('should handle invalid merge ids', async () => {
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
personMock.getById.mockResolvedValueOnce(null);
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
{ id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
]);
expect(personMock.prepareReassignFaces).not.toHaveBeenCalled();
expect(personMock.reassignFaces).not.toHaveBeenCalled();
expect(personMock.delete).not.toHaveBeenCalled();
});
it('should handle an error reassigning faces', async () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson);
personMock.getById.mockResolvedValue(personStub.mergePerson);
personMock.prepareReassignFaces.mockResolvedValue([assetEntityStub.image.id]);
personMock.reassignFaces.mockRejectedValue(new Error('update failed'));
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
{ id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN },
]);
expect(personMock.delete).not.toHaveBeenCalled();
});
});
});

View File

@ -1,12 +1,11 @@
import { PersonEntity } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AssetResponseDto, mapAsset } from '../asset';
import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset';
import { AuthUserDto } from '../auth';
import { mimeTypes } from '../domain.constant';
import { IJobRepository, JobName } from '../job';
import { ImmichReadStream, IStorageRepository } from '../storage';
import { mapPerson, PersonResponseDto, PersonUpdateDto } from './person.dto';
import { IPersonRepository } from './person.repository';
import { mapPerson, MergePersonDto, PersonResponseDto, PersonUpdateDto } from './person.dto';
import { IPersonRepository, UpdateFacesData } from './person.repository';
@Injectable()
export class PersonService {
@ -30,17 +29,12 @@ export class PersonService {
);
}
async getById(authUser: AuthUserDto, personId: string): Promise<PersonResponseDto> {
const person = await this.repository.getById(authUser.id, personId);
if (!person) {
throw new BadRequestException();
getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> {
return this.findOrFail(authUser, id).then(mapPerson);
}
return mapPerson(person);
}
async getThumbnail(authUser: AuthUserDto, personId: string): Promise<ImmichReadStream> {
const person = await this.repository.getById(authUser.id, personId);
async getThumbnail(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> {
const person = await this.repository.getById(authUser.id, id);
if (!person || !person.thumbnailPath) {
throw new NotFoundException();
}
@ -48,50 +42,33 @@ export class PersonService {
return this.storageRepository.createReadStream(person.thumbnailPath, mimeTypes.lookup(person.thumbnailPath));
}
async getAssets(authUser: AuthUserDto, personId: string): Promise<AssetResponseDto[]> {
const assets = await this.repository.getAssets(authUser.id, personId);
async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> {
const assets = await this.repository.getAssets(authUser.id, id);
return assets.map(mapAsset);
}
async update(authUser: AuthUserDto, personId: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
let person = await this.repository.getById(authUser.id, personId);
if (!person) {
throw new BadRequestException();
}
async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
let person = await this.findOrFail(authUser, id);
if (dto.name) {
person = await this.updateName(authUser, personId, dto.name);
person = await this.repository.update({ id, name: dto.name });
const assets = await this.repository.getAssets(authUser.id, id);
const ids = assets.map((asset) => asset.id);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
}
if (dto.featureFaceAssetId) {
await this.updateFaceThumbnail(personId, dto.featureFaceAssetId);
}
return mapPerson(person);
}
private async updateName(authUser: AuthUserDto, personId: string, name: string): Promise<PersonEntity> {
const person = await this.repository.update({ id: personId, name });
const relatedAsset = await this.getAssets(authUser, personId);
const assetIds = relatedAsset.map((asset) => asset.id);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: assetIds } });
return person;
}
private async updateFaceThumbnail(personId: string, assetId: string): Promise<void> {
const face = await this.repository.getFaceById({ assetId, personId });
const assetId = dto.featureFaceAssetId;
const face = await this.repository.getFaceById({ personId: id, assetId });
if (!face) {
throw new BadRequestException();
throw new BadRequestException('Invalid assetId for feature face');
}
return await this.jobRepository.queue({
await this.jobRepository.queue({
name: JobName.GENERATE_FACE_THUMBNAIL,
data: {
assetId: assetId,
personId,
personId: id,
assetId,
boundingBox: {
x1: face.boundingBoxX1,
x2: face.boundingBoxX2,
@ -104,6 +81,9 @@ export class PersonService {
});
}
return mapPerson(person);
}
async handlePersonCleanup() {
const people = await this.repository.getAllWithoutFaces();
for (const person of people) {
@ -118,4 +98,49 @@ export class PersonService {
return true;
}
async mergePerson(authUser: AuthUserDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
const mergeIds = dto.ids;
const primaryPerson = await this.findOrFail(authUser, id);
const primaryName = primaryPerson.name || primaryPerson.id;
const results: BulkIdResponseDto[] = [];
for (const mergeId of mergeIds) {
try {
const mergePerson = await this.repository.getById(authUser.id, mergeId);
if (!mergePerson) {
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND });
continue;
}
const mergeName = mergePerson.name || mergePerson.id;
const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id };
this.logger.log(`Merging ${mergeName} into ${primaryName}`);
const assetIds = await this.repository.prepareReassignFaces(mergeData);
for (const assetId of assetIds) {
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId: mergeId } });
}
await this.repository.reassignFaces(mergeData);
await this.repository.delete(mergePerson);
this.logger.log(`Merged ${mergeName} into ${primaryName}`);
results.push({ id: mergeId, success: true });
} catch (error: Error | any) {
this.logger.error(`Unable to merge ${mergeId} into ${id}: ${error}`, error?.stack);
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.UNKNOWN });
}
}
return results;
}
private async findOrFail(authUser: AuthUserDto, id: string) {
const person = await this.repository.getById(authUser.id, id);
if (!person) {
throw new BadRequestException('Person not found');
}
return person;
}
}

View File

@ -1,12 +1,14 @@
import {
AssetResponseDto,
AuthUserDto,
BulkIdResponseDto,
ImmichReadStream,
MergePersonDto,
PersonResponseDto,
PersonService,
PersonUpdateDto,
} from '@app/domain';
import { Body, Controller, Get, Param, Put, StreamableFile } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Put, StreamableFile } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Authenticated, AuthUser } from '../app.guard';
import { UseValidation } from '../app.utils';
@ -56,4 +58,13 @@ export class PersonController {
getPersonAssets(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto[]> {
return this.service.getAssets(authUser, id);
}
@Post(':id/merge')
mergePerson(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: MergePersonDto,
): Promise<BulkIdResponseDto[]> {
return this.service.mergePerson(authUser, id, dto);
}
}

View File

@ -1,6 +1,6 @@
import { AssetFaceId, IPersonRepository, PersonSearchOptions } from '@app/domain';
import { AssetFaceId, IPersonRepository, PersonSearchOptions, UpdateFacesData } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities';
export class PersonRepository implements IPersonRepository {
@ -10,6 +10,36 @@ export class PersonRepository implements IPersonRepository {
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
) {}
/**
* Before reassigning faces, delete potential key violations
*/
async prepareReassignFaces({ oldPersonId, newPersonId }: UpdateFacesData): Promise<string[]> {
const results = await this.assetFaceRepository
.createQueryBuilder('face')
.select('face."assetId"')
.where(`face."personId" IN (:...ids)`, { ids: [oldPersonId, newPersonId] })
.groupBy('face."assetId"')
.having('COUNT(face."personId") > 1')
.getRawMany();
const assetIds = results.map(({ assetId }) => assetId);
await this.assetFaceRepository.delete({ personId: oldPersonId, assetId: In(assetIds) });
return assetIds;
}
async reassignFaces({ oldPersonId, newPersonId }: UpdateFacesData): Promise<number> {
const result = await this.assetFaceRepository
.createQueryBuilder()
.update()
.set({ personId: newPersonId })
.where({ personId: oldPersonId })
.execute();
return result.affected ?? 0;
}
delete(entity: PersonEntity): Promise<PersonEntity | null> {
return this.personRepository.remove(entity);
}

View File

@ -327,6 +327,39 @@ export const assetEntityStub = {
fileSizeInByte: 5_000,
} as ExifEntity,
}),
image1: Object.freeze<AssetEntity>({
id: 'asset-id-1',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userEntityStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
resizePath: '/uploads/user-id/thumbs/path.ext',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
webpPath: '/uploads/user-id/webp/path.ext',
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isReadOnly: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5_000,
} as ExifEntity,
}),
video: Object.freeze<AssetEntity>({
id: 'asset-id',
originalFileName: 'asset-id.ext',
@ -1158,6 +1191,26 @@ export const personStub = {
thumbnailPath: '/new/path/to/thumbnail.jpg',
faces: [],
}),
primaryPerson: Object.freeze<PersonEntity>({
id: 'person-1',
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
ownerId: userEntityStub.admin.id,
owner: userEntityStub.admin,
name: 'Person 1',
thumbnailPath: '/path/to/thumbnail',
faces: [],
}),
mergePerson: Object.freeze<PersonEntity>({
id: 'person-2',
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
ownerId: userEntityStub.admin.id,
owner: userEntityStub.admin,
name: 'Person 2',
thumbnailPath: '/path/to/thumbnail',
faces: [],
}),
};
export const partnerStub = {
@ -1193,6 +1246,45 @@ export const faceStub = {
imageHeight: 1024,
imageWidth: 1024,
}),
primaryFace1: Object.freeze<AssetFaceEntity>({
assetId: assetEntityStub.image.id,
asset: assetEntityStub.image,
personId: personStub.primaryPerson.id,
person: personStub.primaryPerson,
embedding: [1, 2, 3, 4],
boundingBoxX1: 0,
boundingBoxY1: 0,
boundingBoxX2: 1,
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
}),
mergeFace1: Object.freeze<AssetFaceEntity>({
assetId: assetEntityStub.image.id,
asset: assetEntityStub.image,
personId: personStub.mergePerson.id,
person: personStub.mergePerson,
embedding: [1, 2, 3, 4],
boundingBoxX1: 0,
boundingBoxY1: 0,
boundingBoxX2: 1,
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
}),
mergeFace2: Object.freeze<AssetFaceEntity>({
assetId: assetEntityStub.image1.id,
asset: assetEntityStub.image1,
personId: personStub.mergePerson.id,
person: personStub.mergePerson,
embedding: [1, 2, 3, 4],
boundingBoxX1: 0,
boundingBoxY1: 0,
boundingBoxX2: 1,
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
}),
};
export const tagStub = {

View File

@ -13,5 +13,7 @@ export const newPersonRepositoryMock = (): jest.Mocked<IPersonRepository> => {
delete: jest.fn(),
getFaceById: jest.fn(),
prepareReassignFaces: jest.fn(),
reassignFaces: jest.fn(),
};
};

View File

@ -798,6 +798,41 @@ export interface AuthDeviceResponseDto {
*/
'deviceOS': string;
}
/**
*
* @export
* @interface BulkIdResponseDto
*/
export interface BulkIdResponseDto {
/**
*
* @type {string}
* @memberof BulkIdResponseDto
*/
'id': string;
/**
*
* @type {boolean}
* @memberof BulkIdResponseDto
*/
'success': boolean;
/**
*
* @type {string}
* @memberof BulkIdResponseDto
*/
'error'?: BulkIdResponseDtoErrorEnum;
}
export const BulkIdResponseDtoErrorEnum = {
Duplicate: 'duplicate',
NoPermission: 'no_permission',
NotFound: 'not_found',
Unknown: 'unknown'
} as const;
export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum];
/**
*
* @export
@ -1686,6 +1721,19 @@ export interface MemoryLaneResponseDto {
*/
'assets': Array<AssetResponseDto>;
}
/**
*
* @export
* @interface MergePersonDto
*/
export interface MergePersonDto {
/**
*
* @type {Array<string>}
* @memberof MergePersonDto
*/
'ids': Array<string>;
}
/**
*
* @export
@ -8852,6 +8900,54 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {MergePersonDto} mergePersonDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
mergePerson: async (id: string, mergePersonDto: MergePersonDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('mergePerson', 'id', id)
// verify required parameter 'mergePersonDto' is not null or undefined
assertParamExists('mergePerson', 'mergePersonDto', mergePersonDto)
const localVarPath = `/person/{id}/merge`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(mergePersonDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
@ -8949,6 +9045,17 @@ export const PersonApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonThumbnail(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
* @param {MergePersonDto} mergePersonDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async mergePerson(id: string, mergePersonDto: MergePersonDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
@ -9005,6 +9112,16 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
getPersonThumbnail(id: string, options?: any): AxiosPromise<File> {
return localVarFp.getPersonThumbnail(id, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id
* @param {MergePersonDto} mergePersonDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
mergePerson(id: string, mergePersonDto: MergePersonDto, options?: any): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.mergePerson(id, mergePersonDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id
@ -9060,6 +9177,27 @@ export interface PersonApiGetPersonThumbnailRequest {
readonly id: string
}
/**
* Request parameters for mergePerson operation in PersonApi.
* @export
* @interface PersonApiMergePersonRequest
*/
export interface PersonApiMergePersonRequest {
/**
*
* @type {string}
* @memberof PersonApiMergePerson
*/
readonly id: string
/**
*
* @type {MergePersonDto}
* @memberof PersonApiMergePerson
*/
readonly mergePersonDto: MergePersonDto
}
/**
* Request parameters for updatePerson operation in PersonApi.
* @export
@ -9131,6 +9269,17 @@ export class PersonApi extends BaseAPI {
return PersonApiFp(this.configuration).getPersonThumbnail(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {PersonApiMergePersonRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {PersonApiUpdatePersonRequest} requestParameters Request parameters.

View File

@ -0,0 +1,66 @@
<script lang="ts">
import { api, type PersonResponseDto } from '@api';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import { createEventDispatcher } from 'svelte';
export let person: PersonResponseDto;
export let selectable = false;
export let selected = false;
export let thumbnailSize: number | null = null;
export let circle = false;
export let border = false;
let dispatch = createEventDispatcher();
const handleOnClicked = () => {
dispatch('click', person);
};
</script>
<button
class="relative transition-all rounded-lg"
on:click={handleOnClicked}
disabled={!selectable}
style:width={thumbnailSize ? thumbnailSize + 'px' : '100%'}
style:height={thumbnailSize ? thumbnailSize + 'px' : '100%'}
>
<div
class="filter w-full h-full brightness-90 border-2"
class:rounded-full={circle}
class:rounded-lg={!circle}
class:border-transparent={!border}
class:dark:border-immich-dark-primary={border}
class:border-immich-primary={border}
>
<ImageThumbnail
{circle}
url={api.getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="100%"
shadow
/>
</div>
<div
class="absolute top-0 left-0 w-full h-full bg-immich-primary/30 opacity-0"
class:hover:opacity-100={selectable}
class:rounded-full={circle}
class:rounded-lg={!circle}
/>
{#if selected}
<div
class="absolute top-0 left-0 w-full h-full bg-blue-500/80"
class:rounded-full={circle}
class:rounded-lg={!circle}
/>
{/if}
{#if person.name}
<span
class="absolute bottom-2 left-0 w-full text-center font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"
>
{person.name}
</span>
{/if}
</button>

View File

@ -0,0 +1,145 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { api, type PersonResponseDto } from '@api';
import FaceThumbnail from './face-thumbnail.svelte';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import Button from '../elements/buttons/button.svelte';
import Merge from 'svelte-material-icons/Merge.svelte';
import CallMerge from 'svelte-material-icons/CallMerge.svelte';
import { flip } from 'svelte/animate';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
import { handleError } from '$lib/utils/handle-error';
import { invalidateAll } from '$app/navigation';
export let person: PersonResponseDto;
let people: PersonResponseDto[] = [];
let selectedPeople: PersonResponseDto[] = [];
let screenHeight: number;
let isShowConfirmation = false;
let dispatch = createEventDispatcher();
$: hasSelection = selectedPeople.length > 0;
$: unselectedPeople = people.filter((source) => !selectedPeople.includes(source) && source.id !== person.id);
onMount(async () => {
const { data } = await api.personApi.getAllPeople();
people = data;
});
const onClose = () => {
dispatch('go-back');
};
const onSelect = (selected: PersonResponseDto) => {
if (selectedPeople.includes(selected)) {
selectedPeople = selectedPeople.filter((person) => person.id !== selected.id);
return;
}
if (selectedPeople.length >= 5) {
notificationController.show({
message: 'You can only merge up to 5 faces at a time',
type: NotificationType.Info,
});
return;
}
selectedPeople = [selected, ...selectedPeople];
};
const handleMerge = async () => {
try {
const { data: results } = await api.personApi.mergePerson({
id: person.id,
mergePersonDto: { ids: selectedPeople.map(({ id }) => id) },
});
const count = results.filter(({ success }) => success).length;
notificationController.show({
message: `Merged ${count} ${count === 1 ? 'person' : 'people'}`,
type: NotificationType.Info,
});
await invalidateAll();
onClose();
} catch (error) {
handleError(error, 'Cannot merge faces');
} finally {
isShowConfirmation = false;
}
};
</script>
<svelte:window bind:innerHeight={screenHeight} />
<section
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
class="absolute top-0 left-0 w-full h-full bg-immich-bg dark:bg-immich-dark-bg z-[9999]"
>
<ControlAppBar on:close-button-click={onClose}>
<svelte:fragment slot="leading">
{#if hasSelection}
Selected {selectedPeople.length}
{:else}
Merge faces
{/if}
<div />
</svelte:fragment>
<svelte:fragment slot="trailing">
<Button
size={'sm'}
disabled={!hasSelection}
on:click={() => {
isShowConfirmation = true;
}}
>
<Merge size={18} />
<span class="ml-2"> Merge</span></Button
>
</svelte:fragment>
</ControlAppBar>
<section class="pt-[100px] px-[70px] bg-immich-bg dark:bg-immich-dark-bg">
<section id="merge-face-selector relative">
<div class="place-items-center place-content-center mb-10 h-[200px]">
<p class="uppercase mb-4 dark:text-white text-center">Choose matching faces to merge</p>
<div class="grid grid-flow-col-dense place-items-center place-content-center gap-4">
{#each selectedPeople as person (person.id)}
<div animate:flip={{ duration: 250, easing: quintOut }}>
<FaceThumbnail border circle {person} selectable thumbnailSize={120} on:click={() => onSelect(person)} />
</div>
{/each}
{#if hasSelection}
<span><CallMerge size={48} class="rotate-90 dark:text-white" /> </span>
{/if}
<FaceThumbnail {person} border circle selectable={false} thumbnailSize={180} />
</div>
</div>
<div
class="p-10 overflow-y-auto rounded-3xl bg-gray-200 dark:bg-immich-dark-gray"
style:max-height={screenHeight - 200 - 200 + 'px'}
>
<div class="grid grid-col-2 md:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10 gap-8">
{#each unselectedPeople as person (person.id)}
<FaceThumbnail {person} on:click={() => onSelect(person)} circle border selectable />
{/each}
</div>
</div>
</section>
{#if isShowConfirmation}
<ConfirmDialogue
title="Merge faces"
confirmText="Merge"
on:confirm={handleMerge}
on:cancel={() => (isShowConfirmation = false)}
>
<svelte:fragment slot="prompt">
<p>Are you sure you want merge these faces? <br />This action is <strong>irreversible</strong>.</p>
</svelte:fragment>
</ConfirmDialogue>
{/if}
</section>
</section>

View File

@ -38,7 +38,7 @@
{#if data.people.length > MAX_ITEMS}
<a
href={AppRoute.PEOPLE}
class="font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary dark:text-immich-dark-fg"
class="font-medium text-sm pr-4 hover:text-immich-primary dark:hover:text-immich-dark-primary dark:text-immich-dark-fg"
draggable="false">View All</a
>
{/if}

View File

@ -15,7 +15,7 @@
{#each data.people as person (person.id)}
<div class="relative">
<a href="/people/{person.id}" draggable="false">
<div class="filter brightness-75 rounded-xl w-48">
<div class="filter brightness-95 rounded-xl w-48">
<ImageThumbnail
shadow
url={api.getPeopleThumbnailUrl(person.id)}

View File

@ -28,17 +28,20 @@
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
export let data: PageData;
let isEditingName = false;
let isSelectingFace = false;
let showFaceThumbnailSelection = false;
let showMergeFacePanel = false;
let previousRoute: string = AppRoute.EXPLORE;
let selectedAssets: Set<AssetResponseDto> = new Set();
$: isMultiSelectionMode = selectedAssets.size > 0;
$: isAllArchive = Array.from(selectedAssets).every((asset) => asset.isArchived);
$: isAllFavorite = Array.from(selectedAssets).every((asset) => asset.isFavorite);
$: showAssets = !showMergeFacePanel && !showFaceThumbnailSelection;
afterNavigate(({ from }) => {
// Prevent setting previousRoute to the current page.
if (from && from.route.id !== $page.route.id) {
@ -64,7 +67,7 @@
};
const handleSelectFeaturePhoto = async (event: CustomEvent) => {
isSelectingFace = false;
showFaceThumbnailSelection = false;
const { selectedAsset }: { selectedAsset: AssetResponseDto | undefined } = event.detail;
@ -102,7 +105,8 @@
<ControlAppBar showBackButton backIcon={ArrowLeft} on:close-button-click={() => goto(previousRoute)}>
<svelte:fragment slot="trailing">
<AssetSelectContextMenu icon={DotsVertical} title="Menu">
<MenuOption text="Change feature photo" on:click={() => (isSelectingFace = true)} />
<MenuOption text="Change feature photo" on:click={() => (showFaceThumbnailSelection = true)} />
<MenuOption text="Merge face" on:click={() => (showMergeFacePanel = true)} />
</AssetSelectContextMenu>
</svelte:fragment>
</ControlAppBar>
@ -117,7 +121,7 @@
on:cancel={() => (isEditingName = false)}
/>
{:else}
<button on:click={() => (isSelectingFace = true)}>
<button on:click={() => (showFaceThumbnailSelection = true)}>
<ImageThumbnail
circle
shadow
@ -144,9 +148,9 @@
</section>
<!-- Gallery Block -->
{#if !isSelectingFace}
{#if showAssets}
<section class="relative pt-8 sm:px-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg">
<section class="overflow-y-auto relative immich-scrollbar">
<section class="overflow-y-scroll relative immich-scrollbar">
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
<GalleryViewer assets={data.assets} viewFrom="search-page" showArchiveIcon={true} bind:selectedAssets />
</section>
@ -154,6 +158,10 @@
</section>
{/if}
{#if isSelectingFace}
{#if showFaceThumbnailSelection}
<FaceThumbnailSelector assets={data.assets} on:go-back={handleSelectFeaturePhoto} />
{/if}
{#if showMergeFacePanel}
<MergeFaceSelector person={data.person} on:go-back={() => (showMergeFacePanel = false)} />
{/if}