diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 54cc921499..f8b8188ac9 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -586,6 +586,142 @@ export const AssetBulkUploadCheckResultReasonEnum = { export type AssetBulkUploadCheckResultReasonEnum = typeof AssetBulkUploadCheckResultReasonEnum[keyof typeof AssetBulkUploadCheckResultReasonEnum]; +/** + * + * @export + * @interface AssetFaceResponseDto + */ +export interface AssetFaceResponseDto { + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'boundingBoxX1': number; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'boundingBoxX2': number; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'boundingBoxY1': number; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'boundingBoxY2': number; + /** + * + * @type {string} + * @memberof AssetFaceResponseDto + */ + 'id': string; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'imageHeight': number; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'imageWidth': number; + /** + * + * @type {PersonResponseDto} + * @memberof AssetFaceResponseDto + */ + 'person': PersonResponseDto | null; +} +/** + * + * @export + * @interface AssetFaceUpdateDto + */ +export interface AssetFaceUpdateDto { + /** + * + * @type {Array} + * @memberof AssetFaceUpdateDto + */ + 'data': Array; +} +/** + * + * @export + * @interface AssetFaceUpdateItem + */ +export interface AssetFaceUpdateItem { + /** + * + * @type {string} + * @memberof AssetFaceUpdateItem + */ + 'assetId': string; + /** + * + * @type {string} + * @memberof AssetFaceUpdateItem + */ + 'personId': string; +} +/** + * + * @export + * @interface AssetFaceWithoutPersonResponseDto + */ +export interface AssetFaceWithoutPersonResponseDto { + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'boundingBoxX1': number; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'boundingBoxX2': number; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'boundingBoxY1': number; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'boundingBoxY2': number; + /** + * + * @type {string} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'id': string; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'imageHeight': number; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'imageWidth': number; +} /** * * @export @@ -842,10 +978,10 @@ export interface AssetResponseDto { 'ownerId': string; /** * - * @type {Array} + * @type {Array} * @memberof AssetResponseDto */ - 'people'?: Array; + 'people'?: Array; /** * * @type {boolean} @@ -1672,6 +1808,19 @@ export interface ExifResponseDto { */ 'timeZone'?: string | null; } +/** + * + * @export + * @interface FaceDto + */ +export interface FaceDto { + /** + * + * @type {string} + * @memberof FaceDto + */ + 'id': string; +} /** * * @export @@ -2564,6 +2713,49 @@ export interface PersonUpdateDto { */ 'name'?: string; } +/** + * + * @export + * @interface PersonWithFacesResponseDto + */ +export interface PersonWithFacesResponseDto { + /** + * + * @type {string} + * @memberof PersonWithFacesResponseDto + */ + 'birthDate': string | null; + /** + * + * @type {Array} + * @memberof PersonWithFacesResponseDto + */ + 'faces': Array; + /** + * + * @type {string} + * @memberof PersonWithFacesResponseDto + */ + 'id': string; + /** + * + * @type {boolean} + * @memberof PersonWithFacesResponseDto + */ + 'isHidden': boolean; + /** + * + * @type {string} + * @memberof PersonWithFacesResponseDto + */ + 'name': string; + /** + * + * @type {string} + * @memberof PersonWithFacesResponseDto + */ + 'thumbnailPath': string; +} /** * * @export @@ -11349,6 +11541,233 @@ export class AuthenticationApi extends BaseAPI { } +/** + * FaceApi - axios parameter creator + * @export + */ +export const FaceApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getFaces: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getFaces', 'id', id) + const localVarPath = `/face`; + // 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: 'GET', ...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) + + if (id !== undefined) { + localVarQueryParameter['id'] = id; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {FaceDto} faceDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reassignFacesById: async (id: string, faceDto: FaceDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('reassignFacesById', 'id', id) + // verify required parameter 'faceDto' is not null or undefined + assertParamExists('reassignFacesById', 'faceDto', faceDto) + const localVarPath = `/face/{id}` + .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: 'PUT', ...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(faceDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * FaceApi - functional programming interface + * @export + */ +export const FaceApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = FaceApiAxiosParamCreator(configuration) + return { + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getFaces(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getFaces(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {FaceDto} faceDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async reassignFacesById(id: string, faceDto: FaceDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFacesById(id, faceDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * FaceApi - factory interface + * @export + */ +export const FaceApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = FaceApiFp(configuration) + return { + /** + * + * @param {FaceApiGetFacesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getFaces(requestParameters: FaceApiGetFacesRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.getFaces(requestParameters.id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {FaceApiReassignFacesByIdRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for getFaces operation in FaceApi. + * @export + * @interface FaceApiGetFacesRequest + */ +export interface FaceApiGetFacesRequest { + /** + * + * @type {string} + * @memberof FaceApiGetFaces + */ + readonly id: string +} + +/** + * Request parameters for reassignFacesById operation in FaceApi. + * @export + * @interface FaceApiReassignFacesByIdRequest + */ +export interface FaceApiReassignFacesByIdRequest { + /** + * + * @type {string} + * @memberof FaceApiReassignFacesById + */ + readonly id: string + + /** + * + * @type {FaceDto} + * @memberof FaceApiReassignFacesById + */ + readonly faceDto: FaceDto +} + +/** + * FaceApi - object-oriented interface + * @export + * @class FaceApi + * @extends {BaseAPI} + */ +export class FaceApi extends BaseAPI { + /** + * + * @param {FaceApiGetFacesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof FaceApi + */ + public getFaces(requestParameters: FaceApiGetFacesRequest, options?: AxiosRequestConfig) { + return FaceApiFp(this.configuration).getFaces(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {FaceApiReassignFacesByIdRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof FaceApi + */ + public reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig) { + return FaceApiFp(this.configuration).reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * JobApi - axios parameter creator * @export @@ -13180,6 +13599,44 @@ export class PartnerApi extends BaseAPI { */ export const PersonApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createPerson: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/person`; + // 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) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {boolean} [withHidden] @@ -13439,6 +13896,54 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio options: localVarRequestOptions, }; }, + /** + * + * @param {string} id + * @param {AssetFaceUpdateDto} assetFaceUpdateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reassignFaces: async (id: string, assetFaceUpdateDto: AssetFaceUpdateDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('reassignFaces', 'id', id) + // verify required parameter 'assetFaceUpdateDto' is not null or undefined + assertParamExists('reassignFaces', 'assetFaceUpdateDto', assetFaceUpdateDto) + const localVarPath = `/person/{id}/reassign` + .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: 'PUT', ...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(assetFaceUpdateDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {PeopleUpdateDto} peopleUpdateDto @@ -13541,6 +14046,15 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio export const PersonApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = PersonApiAxiosParamCreator(configuration) return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createPerson(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createPerson(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {boolean} [withHidden] @@ -13602,6 +14116,17 @@ export const PersonApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id + * @param {AssetFaceUpdateDto} assetFaceUpdateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async reassignFaces(id: string, assetFaceUpdateDto: AssetFaceUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFaces(id, assetFaceUpdateDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {PeopleUpdateDto} peopleUpdateDto @@ -13633,6 +14158,14 @@ export const PersonApiFp = function(configuration?: Configuration) { export const PersonApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = PersonApiFp(configuration) return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createPerson(options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.createPerson(options).then((request) => request(axios, basePath)); + }, /** * * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters. @@ -13687,6 +14220,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig): AxiosPromise> { return localVarFp.mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {PersonApiReassignFacesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reassignFaces(requestParameters: PersonApiReassignFacesRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.reassignFaces(requestParameters.id, requestParameters.assetFaceUpdateDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters. @@ -13799,6 +14341,27 @@ export interface PersonApiMergePersonRequest { readonly mergePersonDto: MergePersonDto } +/** + * Request parameters for reassignFaces operation in PersonApi. + * @export + * @interface PersonApiReassignFacesRequest + */ +export interface PersonApiReassignFacesRequest { + /** + * + * @type {string} + * @memberof PersonApiReassignFaces + */ + readonly id: string + + /** + * + * @type {AssetFaceUpdateDto} + * @memberof PersonApiReassignFaces + */ + readonly assetFaceUpdateDto: AssetFaceUpdateDto +} + /** * Request parameters for updatePeople operation in PersonApi. * @export @@ -13841,6 +14404,16 @@ export interface PersonApiUpdatePersonRequest { * @extends {BaseAPI} */ export class PersonApi extends BaseAPI { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PersonApi + */ + public createPerson(options?: AxiosRequestConfig) { + return PersonApiFp(this.configuration).createPerson(options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters. @@ -13907,6 +14480,17 @@ export class PersonApi extends BaseAPI { return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {PersonApiReassignFacesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PersonApi + */ + public reassignFaces(requestParameters: PersonApiReassignFacesRequest, options?: AxiosRequestConfig) { + return PersonApiFp(this.configuration).reassignFaces(requestParameters.id, requestParameters.assetFaceUpdateDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters. diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index f54b788a4e..1d72f22499 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -24,6 +24,10 @@ doc/AssetBulkUploadCheckDto.md doc/AssetBulkUploadCheckItem.md doc/AssetBulkUploadCheckResponseDto.md doc/AssetBulkUploadCheckResult.md +doc/AssetFaceResponseDto.md +doc/AssetFaceUpdateDto.md +doc/AssetFaceUpdateItem.md +doc/AssetFaceWithoutPersonResponseDto.md doc/AssetFileUploadResponseDto.md doc/AssetIdsDto.md doc/AssetIdsResponseDto.md @@ -60,6 +64,8 @@ doc/DownloadInfoDto.md doc/DownloadResponseDto.md doc/EntityType.md doc/ExifResponseDto.md +doc/FaceApi.md +doc/FaceDto.md doc/FileChecksumDto.md doc/FileChecksumResponseDto.md doc/FileReportDto.md @@ -100,6 +106,7 @@ doc/PersonApi.md doc/PersonResponseDto.md doc/PersonStatisticsResponseDto.md doc/PersonUpdateDto.md +doc/PersonWithFacesResponseDto.md doc/QueueStatusDto.md doc/ReactionLevel.md doc/ReactionType.md @@ -177,6 +184,7 @@ lib/api/api_key_api.dart lib/api/asset_api.dart lib/api/audit_api.dart lib/api/authentication_api.dart +lib/api/face_api.dart lib/api/job_api.dart lib/api/library_api.dart lib/api/o_auth_api.dart @@ -213,6 +221,10 @@ lib/model/asset_bulk_upload_check_dto.dart lib/model/asset_bulk_upload_check_item.dart lib/model/asset_bulk_upload_check_response_dto.dart lib/model/asset_bulk_upload_check_result.dart +lib/model/asset_face_response_dto.dart +lib/model/asset_face_update_dto.dart +lib/model/asset_face_update_item.dart +lib/model/asset_face_without_person_response_dto.dart lib/model/asset_file_upload_response_dto.dart lib/model/asset_ids_dto.dart lib/model/asset_ids_response_dto.dart @@ -247,6 +259,7 @@ lib/model/download_info_dto.dart lib/model/download_response_dto.dart lib/model/entity_type.dart lib/model/exif_response_dto.dart +lib/model/face_dto.dart lib/model/file_checksum_dto.dart lib/model/file_checksum_response_dto.dart lib/model/file_report_dto.dart @@ -282,6 +295,7 @@ lib/model/people_update_item.dart lib/model/person_response_dto.dart lib/model/person_statistics_response_dto.dart lib/model/person_update_dto.dart +lib/model/person_with_faces_response_dto.dart lib/model/queue_status_dto.dart lib/model/reaction_level.dart lib/model/reaction_type.dart @@ -367,6 +381,10 @@ test/asset_bulk_upload_check_dto_test.dart test/asset_bulk_upload_check_item_test.dart test/asset_bulk_upload_check_response_dto_test.dart test/asset_bulk_upload_check_result_test.dart +test/asset_face_response_dto_test.dart +test/asset_face_update_dto_test.dart +test/asset_face_update_item_test.dart +test/asset_face_without_person_response_dto_test.dart test/asset_file_upload_response_dto_test.dart test/asset_ids_dto_test.dart test/asset_ids_response_dto_test.dart @@ -403,6 +421,8 @@ test/download_info_dto_test.dart test/download_response_dto_test.dart test/entity_type_test.dart test/exif_response_dto_test.dart +test/face_api_test.dart +test/face_dto_test.dart test/file_checksum_dto_test.dart test/file_checksum_response_dto_test.dart test/file_report_dto_test.dart @@ -443,6 +463,7 @@ test/person_api_test.dart test/person_response_dto_test.dart test/person_statistics_response_dto_test.dart test/person_update_dto_test.dart +test/person_with_faces_response_dto_test.dart test/queue_status_dto_test.dart test/reaction_level_test.dart test/reaction_type_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 42680e6797..7eb7f7f56a 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/AssetFaceResponseDto.md b/mobile/openapi/doc/AssetFaceResponseDto.md new file mode 100644 index 0000000000..00ded8b476 Binary files /dev/null and b/mobile/openapi/doc/AssetFaceResponseDto.md differ diff --git a/mobile/openapi/doc/AssetFaceUpdateDto.md b/mobile/openapi/doc/AssetFaceUpdateDto.md new file mode 100644 index 0000000000..eb4e4d3872 Binary files /dev/null and b/mobile/openapi/doc/AssetFaceUpdateDto.md differ diff --git a/mobile/openapi/doc/AssetFaceUpdateItem.md b/mobile/openapi/doc/AssetFaceUpdateItem.md new file mode 100644 index 0000000000..6a98288ed3 Binary files /dev/null and b/mobile/openapi/doc/AssetFaceUpdateItem.md differ diff --git a/mobile/openapi/doc/AssetFaceWithoutPersonResponseDto.md b/mobile/openapi/doc/AssetFaceWithoutPersonResponseDto.md new file mode 100644 index 0000000000..5d421dbeff Binary files /dev/null and b/mobile/openapi/doc/AssetFaceWithoutPersonResponseDto.md differ diff --git a/mobile/openapi/doc/AssetResponseDto.md b/mobile/openapi/doc/AssetResponseDto.md index 8c4d1db4a7..7c79f74183 100644 Binary files a/mobile/openapi/doc/AssetResponseDto.md and b/mobile/openapi/doc/AssetResponseDto.md differ diff --git a/mobile/openapi/doc/FaceApi.md b/mobile/openapi/doc/FaceApi.md new file mode 100644 index 0000000000..84793a5c90 Binary files /dev/null and b/mobile/openapi/doc/FaceApi.md differ diff --git a/mobile/openapi/doc/FaceDto.md b/mobile/openapi/doc/FaceDto.md new file mode 100644 index 0000000000..21144a34ed Binary files /dev/null and b/mobile/openapi/doc/FaceDto.md differ diff --git a/mobile/openapi/doc/PersonApi.md b/mobile/openapi/doc/PersonApi.md index 73a35f0f33..6ad2d19853 100644 Binary files a/mobile/openapi/doc/PersonApi.md and b/mobile/openapi/doc/PersonApi.md differ diff --git a/mobile/openapi/doc/PersonWithFacesResponseDto.md b/mobile/openapi/doc/PersonWithFacesResponseDto.md new file mode 100644 index 0000000000..ddef73618c Binary files /dev/null and b/mobile/openapi/doc/PersonWithFacesResponseDto.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8941626933..c0caf20e4e 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/face_api.dart b/mobile/openapi/lib/api/face_api.dart new file mode 100644 index 0000000000..bce6814071 Binary files /dev/null and b/mobile/openapi/lib/api/face_api.dart differ diff --git a/mobile/openapi/lib/api/person_api.dart b/mobile/openapi/lib/api/person_api.dart index e4ab011a6b..b603df6d3b 100644 Binary files a/mobile/openapi/lib/api/person_api.dart and b/mobile/openapi/lib/api/person_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 42a0e5cbb3..7d376949e7 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/asset_face_response_dto.dart b/mobile/openapi/lib/model/asset_face_response_dto.dart new file mode 100644 index 0000000000..642622999a Binary files /dev/null and b/mobile/openapi/lib/model/asset_face_response_dto.dart differ diff --git a/mobile/openapi/lib/model/asset_face_update_dto.dart b/mobile/openapi/lib/model/asset_face_update_dto.dart new file mode 100644 index 0000000000..89a11b0ab9 Binary files /dev/null and b/mobile/openapi/lib/model/asset_face_update_dto.dart differ diff --git a/mobile/openapi/lib/model/asset_face_update_item.dart b/mobile/openapi/lib/model/asset_face_update_item.dart new file mode 100644 index 0000000000..ca078683ae Binary files /dev/null and b/mobile/openapi/lib/model/asset_face_update_item.dart differ diff --git a/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart b/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart new file mode 100644 index 0000000000..8e1a9c66ad Binary files /dev/null and b/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart differ diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 7461186810..bb7330d3ec 100644 Binary files a/mobile/openapi/lib/model/asset_response_dto.dart and b/mobile/openapi/lib/model/asset_response_dto.dart differ diff --git a/mobile/openapi/lib/model/face_dto.dart b/mobile/openapi/lib/model/face_dto.dart new file mode 100644 index 0000000000..556fd0efa8 Binary files /dev/null and b/mobile/openapi/lib/model/face_dto.dart differ diff --git a/mobile/openapi/lib/model/person_with_faces_response_dto.dart b/mobile/openapi/lib/model/person_with_faces_response_dto.dart new file mode 100644 index 0000000000..7648194306 Binary files /dev/null and b/mobile/openapi/lib/model/person_with_faces_response_dto.dart differ diff --git a/mobile/openapi/test/asset_face_response_dto_test.dart b/mobile/openapi/test/asset_face_response_dto_test.dart new file mode 100644 index 0000000000..cfedbeca9b Binary files /dev/null and b/mobile/openapi/test/asset_face_response_dto_test.dart differ diff --git a/mobile/openapi/test/asset_face_update_dto_test.dart b/mobile/openapi/test/asset_face_update_dto_test.dart new file mode 100644 index 0000000000..5338a0bad9 Binary files /dev/null and b/mobile/openapi/test/asset_face_update_dto_test.dart differ diff --git a/mobile/openapi/test/asset_face_update_item_test.dart b/mobile/openapi/test/asset_face_update_item_test.dart new file mode 100644 index 0000000000..a3ef4c3357 Binary files /dev/null and b/mobile/openapi/test/asset_face_update_item_test.dart differ diff --git a/mobile/openapi/test/asset_face_without_person_response_dto_test.dart b/mobile/openapi/test/asset_face_without_person_response_dto_test.dart new file mode 100644 index 0000000000..5eb7e5d939 Binary files /dev/null and b/mobile/openapi/test/asset_face_without_person_response_dto_test.dart differ diff --git a/mobile/openapi/test/asset_response_dto_test.dart b/mobile/openapi/test/asset_response_dto_test.dart index 63668934a9..b8a64b6a24 100644 Binary files a/mobile/openapi/test/asset_response_dto_test.dart and b/mobile/openapi/test/asset_response_dto_test.dart differ diff --git a/mobile/openapi/test/face_api_test.dart b/mobile/openapi/test/face_api_test.dart new file mode 100644 index 0000000000..3bd4d982f4 Binary files /dev/null and b/mobile/openapi/test/face_api_test.dart differ diff --git a/mobile/openapi/test/face_dto_test.dart b/mobile/openapi/test/face_dto_test.dart new file mode 100644 index 0000000000..ea8091f2e3 Binary files /dev/null and b/mobile/openapi/test/face_dto_test.dart differ diff --git a/mobile/openapi/test/person_api_test.dart b/mobile/openapi/test/person_api_test.dart index b0feeb1160..dd112eeaae 100644 Binary files a/mobile/openapi/test/person_api_test.dart and b/mobile/openapi/test/person_api_test.dart differ diff --git a/mobile/openapi/test/person_with_faces_response_dto_test.dart b/mobile/openapi/test/person_with_faces_response_dto_test.dart new file mode 100644 index 0000000000..7f7e0f89ac Binary files /dev/null and b/mobile/openapi/test/person_with_faces_response_dto_test.dart differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 55b4f81abe..d6355e8944 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -3220,6 +3220,103 @@ ] } }, + "/face": { + "get": { + "operationId": "getFaces", + "parameters": [ + { + "name": "id", + "required": true, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetFaceResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Face" + ] + } + }, + "/face/{id}": { + "put": { + "operationId": "reassignFacesById", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FaceDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Face" + ] + } + }, "/jobs": { "get": { "operationId": "getAllJobsStatus", @@ -4022,6 +4119,36 @@ "Person" ] }, + "post": { + "operationId": "createPerson", + "parameters": [], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Person" + ] + }, "put": { "operationId": "updatePeople", "parameters": [], @@ -4258,6 +4385,61 @@ ] } }, + "/person/{id}/reassign": { + "put": { + "operationId": "reassignFaces", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetFaceUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PersonResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Person" + ] + } + }, "/person/{id}/statistics": { "get": { "operationId": "getPersonStatistics", @@ -6557,6 +6739,118 @@ ], "type": "object" }, + "AssetFaceResponseDto": { + "properties": { + "boundingBoxX1": { + "type": "integer" + }, + "boundingBoxX2": { + "type": "integer" + }, + "boundingBoxY1": { + "type": "integer" + }, + "boundingBoxY2": { + "type": "integer" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "imageHeight": { + "type": "integer" + }, + "imageWidth": { + "type": "integer" + }, + "person": { + "allOf": [ + { + "$ref": "#/components/schemas/PersonResponseDto" + } + ], + "nullable": true + } + }, + "required": [ + "id", + "imageHeight", + "imageWidth", + "boundingBoxX1", + "boundingBoxX2", + "boundingBoxY1", + "boundingBoxY2", + "person" + ], + "type": "object" + }, + "AssetFaceUpdateDto": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/AssetFaceUpdateItem" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "type": "object" + }, + "AssetFaceUpdateItem": { + "properties": { + "assetId": { + "format": "uuid", + "type": "string" + }, + "personId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "personId", + "assetId" + ], + "type": "object" + }, + "AssetFaceWithoutPersonResponseDto": { + "properties": { + "boundingBoxX1": { + "type": "integer" + }, + "boundingBoxX2": { + "type": "integer" + }, + "boundingBoxY1": { + "type": "integer" + }, + "boundingBoxY2": { + "type": "integer" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "imageHeight": { + "type": "integer" + }, + "imageWidth": { + "type": "integer" + } + }, + "required": [ + "id", + "imageHeight", + "imageWidth", + "boundingBoxX1", + "boundingBoxX2", + "boundingBoxY1", + "boundingBoxY2" + ], + "type": "object" + }, "AssetFileUploadResponseDto": { "properties": { "duplicate": { @@ -6719,7 +7013,7 @@ }, "people": { "items": { - "$ref": "#/components/schemas/PersonResponseDto" + "$ref": "#/components/schemas/PersonWithFacesResponseDto" }, "type": "array" }, @@ -7452,6 +7746,18 @@ }, "type": "object" }, + "FaceDto": { + "properties": { + "id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "FileChecksumDto": { "properties": { "filenames": { @@ -8147,6 +8453,42 @@ }, "type": "object" }, + "PersonWithFacesResponseDto": { + "properties": { + "birthDate": { + "format": "date", + "nullable": true, + "type": "string" + }, + "faces": { + "items": { + "$ref": "#/components/schemas/AssetFaceWithoutPersonResponseDto" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "isHidden": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "thumbnailPath": { + "type": "string" + } + }, + "required": [ + "birthDate", + "faces", + "id", + "name", + "thumbnailPath", + "isHidden" + ], + "type": "object" + }, "QueueStatusDto": { "properties": { "isActive": { diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index 862fafc322..f70d3d548c 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -41,6 +41,8 @@ export enum Permission { PERSON_READ = 'person.read', PERSON_WRITE = 'person.write', PERSON_MERGE = 'person.merge', + PERSON_CREATE = 'person.create', + PERSON_REASSIGN = 'person.reassign', PARTNER_UPDATE = 'partner.update', } @@ -247,6 +249,12 @@ export class AccessCore { case Permission.PERSON_MERGE: return await this.repository.person.checkOwnerAccess(authUser.id, ids); + case Permission.PERSON_CREATE: + return this.repository.person.hasFaceOwnerAccess(authUser.id, ids); + + case Permission.PERSON_REASSIGN: + return this.repository.person.hasFaceOwnerAccess(authUser.id, ids); + case Permission.PARTNER_UPDATE: return await this.repository.partner.checkUpdateAccess(authUser.id, ids); } diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index 72c2572563..c3a7491cfb 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-response.dto.ts @@ -1,6 +1,6 @@ -import { AssetEntity, AssetType } from '@app/infra/entities'; +import { AssetEntity, AssetFaceEntity, AssetType } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; -import { PersonResponseDto, mapFace } from '../../person/person.dto'; +import { PersonWithFacesResponseDto } from '../../person/person.dto'; import { TagResponseDto, mapTag } from '../../tag'; import { UserResponseDto, mapUser } from '../../user/response-dto/user-response.dto'; import { ExifResponseDto, mapExif } from './exif-response.dto'; @@ -39,7 +39,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { exifInfo?: ExifResponseDto; smartInfo?: SmartInfoResponseDto; tags?: TagResponseDto[]; - people?: PersonResponseDto[]; + people?: PersonWithFacesResponseDto[]; /**base64 encoded sha1 hash */ checksum!: string; stackParentId?: string | null; @@ -53,6 +53,24 @@ export type AssetMapOptions = { withStack?: boolean; }; +const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] => { + const result: PersonWithFacesResponseDto[] = []; + if (faces) { + faces.forEach((face) => { + if (face.person) { + const existingPersonEntry = result.find((item) => item.id === face.person!.id); + if (existingPersonEntry) { + existingPersonEntry.faces.push(face); + } else { + result.push({ ...face.person!, faces: [face] }); + } + } + }); + } + + return result; +}; + export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto { const { stripMetadata = false, withStack = false } = options; @@ -96,16 +114,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined, livePhotoVideoId: entity.livePhotoVideoId, tags: entity.tags?.map(mapTag), - people: entity.faces - ?.map(mapFace) - .filter((person): person is PersonResponseDto => person !== null) - .reduce((people, person) => { - const existingPerson = people.find((p) => p.id === person.id); - if (!existingPerson) { - people.push(person); - } - return people; - }, [] as PersonResponseDto[]), + people: peopleWithFaces(entity.faces), checksum: entity.checksum.toString('base64'), stackParentId: entity.stackParentId, stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined, diff --git a/server/src/domain/job/job.service.ts b/server/src/domain/job/job.service.ts index 4082d5e901..d313302eac 100644 --- a/server/src/domain/job/job.service.ts +++ b/server/src/domain/job/job.service.ts @@ -201,7 +201,7 @@ export class JobService { const { id } = item.data; const person = await this.personRepository.getById(id); if (person) { - this.communicationRepository.send(CommunicationEvent.PERSON_THUMBNAIL, person.ownerId, id); + this.communicationRepository.send(CommunicationEvent.PERSON_THUMBNAIL, person.ownerId, person.id); } break; diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts index b7acde73a1..ed46933939 100644 --- a/server/src/domain/person/person.dto.ts +++ b/server/src/domain/person/person.dto.ts @@ -2,6 +2,7 @@ import { AssetFaceEntity, PersonEntity } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { IsArray, IsBoolean, IsDate, IsNotEmpty, IsString, ValidateNested } from 'class-validator'; +import { AuthUserDto } from '../auth'; import { Optional, ValidateUUID, toBoolean } from '../domain.util'; export class PersonUpdateDto { @@ -73,6 +74,51 @@ export class PersonResponseDto { isHidden!: boolean; } +export class PersonWithFacesResponseDto extends PersonResponseDto { + faces!: AssetFaceWithoutPersonResponseDto[]; +} + +export class AssetFaceWithoutPersonResponseDto { + @ValidateUUID() + id!: string; + @ApiProperty({ type: 'integer' }) + imageHeight!: number; + @ApiProperty({ type: 'integer' }) + imageWidth!: number; + @ApiProperty({ type: 'integer' }) + boundingBoxX1!: number; + @ApiProperty({ type: 'integer' }) + boundingBoxX2!: number; + @ApiProperty({ type: 'integer' }) + boundingBoxY1!: number; + @ApiProperty({ type: 'integer' }) + boundingBoxY2!: number; +} + +export class AssetFaceResponseDto extends AssetFaceWithoutPersonResponseDto { + person!: PersonResponseDto | null; +} + +export class AssetFaceUpdateDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssetFaceUpdateItem) + data!: AssetFaceUpdateItem[]; +} + +export class FaceDto { + @ValidateUUID() + id!: string; +} + +export class AssetFaceUpdateItem { + @ValidateUUID() + personId!: string; + + @ValidateUUID() + assetId!: string; +} + export class PersonStatisticsResponseDto { @ApiProperty({ type: 'integer' }) assets!: number; @@ -98,10 +144,15 @@ export function mapPerson(person: PersonEntity): PersonResponseDto { }; } -export function mapFace(face: AssetFaceEntity): PersonResponseDto | null { - if (face.person) { - return mapPerson(face.person); - } - - return null; +export function mapFaces(face: AssetFaceEntity, authUser: AuthUserDto): AssetFaceResponseDto { + return { + id: face.id, + imageHeight: face.imageHeight, + imageWidth: face.imageWidth, + boundingBoxX1: face.boundingBoxX1, + boundingBoxX2: face.boundingBoxX2, + boundingBoxY1: face.boundingBoxY1, + boundingBoxY2: face.boundingBoxY2, + person: face.person?.ownerId === authUser.id ? mapPerson(face.person) : null, + }; } diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 44c20712bc..0ce15f5aec 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -31,7 +31,7 @@ import { ISystemConfigRepository, WithoutProperty, } from '../repositories'; -import { PersonResponseDto } from './person.dto'; +import { PersonResponseDto, mapFaces } from './person.dto'; import { PersonService } from './person.service'; const responseDto: PersonResponseDto = { @@ -339,7 +339,7 @@ describe(PersonService.name, () => { ).resolves.toEqual(responseDto); expect(personMock.getById).toHaveBeenCalledWith('person-1'); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.assetId }); + expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id }); expect(personMock.getFacesByIds).toHaveBeenCalledWith([ { assetId: faceStub.face1.assetId, @@ -375,6 +375,139 @@ describe(PersonService.name, () => { }); }); + describe('reassignFaces', () => { + it('should throw an error if user has no access to the person', async () => { + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set()); + + await expect( + sut.reassignFaces(authStub.admin, personStub.noName.id, { + data: [{ personId: 'asset-face-1', assetId: '' }], + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(jobMock.queue).not.toHaveBeenCalledWith(); + }); + it('should reassign a face', async () => { + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id])); + personMock.getById.mockResolvedValue(personStub.noName); + accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); + personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); + personMock.reassignFace.mockResolvedValue(1); + personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1); + await expect( + sut.reassignFaces(authStub.admin, personStub.noName.id, { + data: [{ personId: personStub.withName.id, assetId: assetStub.image.id }], + }), + ).resolves.toEqual([personStub.noName]); + + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.GENERATE_PERSON_THUMBNAIL, + data: { id: personStub.newThumbnail.id }, + }); + }); + }); + + describe('handlePersonMigration', () => { + it('should not move person files', async () => { + personMock.getById.mockResolvedValue(null); + await expect(sut.handlePersonMigration(personStub.noName)).resolves.toStrictEqual(false); + }); + }); + + describe('getFacesById', () => { + it('should get the bounding boxes for an asset', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId])); + personMock.getFaces.mockResolvedValue([faceStub.primaryFace1]); + await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([ + mapFaces(faceStub.primaryFace1, authStub.admin), + ]); + }); + it('should reject if the user has not access to the asset', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set()); + personMock.getFaces.mockResolvedValue([faceStub.primaryFace1]); + await expect(sut.getFacesById(authStub.admin, { id: faceStub.primaryFace1.assetId })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + }); + + describe('createNewFeaturePhoto', () => { + it('should change person feature photo', async () => { + personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1); + await sut.createNewFeaturePhoto([personStub.newThumbnail.id]); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.GENERATE_PERSON_THUMBNAIL, + data: { id: personStub.newThumbnail.id }, + }); + }); + }); + + describe('reassignFacesById', () => { + it('should create a new person', async () => { + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); + accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); + personMock.getFaceById.mockResolvedValue(faceStub.face1); + personMock.reassignFace.mockResolvedValue(1); + personMock.getById.mockResolvedValue(personStub.noName); + personMock.getRandomFace.mockResolvedValue(null); + await expect( + sut.reassignFacesById(authStub.admin, personStub.noName.id, { + id: faceStub.face1.id, + }), + ).resolves.toEqual({ + birthDate: personStub.noName.birthDate, + isHidden: personStub.noName.isHidden, + id: personStub.noName.id, + name: personStub.noName.name, + thumbnailPath: personStub.noName.thumbnailPath, + }); + + expect(jobMock.queue).not.toHaveBeenCalledWith(); + }); + + it('should fail if user has not the correct permissions on the asset', async () => { + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); + accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set()); + personMock.getFaceById.mockResolvedValue(faceStub.face1); + personMock.reassignFace.mockResolvedValue(1); + personMock.getById.mockResolvedValue(personStub.noName); + personMock.getRandomFace.mockResolvedValue(null); + await expect( + sut.reassignFacesById(authStub.admin, personStub.noName.id, { + id: faceStub.face1.id, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(jobMock.queue).not.toHaveBeenCalledWith(); + }); + }); + + describe('createPerson', () => { + it('should create a new person', async () => { + personMock.create.mockResolvedValue(personStub.primaryPerson); + personMock.getFaceById.mockResolvedValue(faceStub.face1); + accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); + + await expect(sut.createPerson(authStub.admin)).resolves.toBe(personStub.primaryPerson); + }); + }); + + describe('handlePersonDelete', () => { + it('should stop if a person has not be found', async () => { + personMock.getById.mockResolvedValue(null); + + await expect(sut.handlePersonDelete({ id: 'person-1' })).resolves.toBe(false); + expect(personMock.update).not.toHaveBeenCalled(); + expect(storageMock.unlink).not.toHaveBeenCalled(); + }); + it('should delete a person', async () => { + personMock.getById.mockResolvedValue(personStub.primaryPerson); + + await expect(sut.handlePersonDelete({ id: 'person-1' })).resolves.toBe(true); + expect(personMock.delete).toHaveBeenCalledWith(personStub.primaryPerson); + expect(storageMock.unlink).toHaveBeenCalledWith(personStub.primaryPerson.thumbnailPath); + }); + }); + describe('handlePersonCleanup', () => { it('should delete people without faces', async () => { personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]); @@ -515,6 +648,7 @@ describe(PersonService.name, () => { searchMock.searchFaces.mockResolvedValue(faceSearch.oneRemoteMatch); personMock.create.mockResolvedValue(personStub.noName); assetMock.getByIds.mockResolvedValue([assetStub.image]); + personMock.createFace.mockResolvedValue(faceStub.primaryFace1); await sut.handleRecognizeFaces({ id: assetStub.image.id }); @@ -557,16 +691,16 @@ describe(PersonService.name, () => { expect(mediaMock.crop).not.toHaveBeenCalled(); }); - it('should skip an person with a face asset id not found', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); - personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); + it('should skip a person with a face asset id not found', async () => { + personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.id }); + personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); expect(mediaMock.crop).not.toHaveBeenCalled(); }); it('should skip a person with a face asset id without a thumbnail', async () => { personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); - personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); + personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); expect(mediaMock.crop).not.toHaveBeenCalled(); @@ -574,7 +708,7 @@ describe(PersonService.name, () => { it('should generate a thumbnail', async () => { personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); - personMock.getFacesByIds.mockResolvedValue([faceStub.middle]); + personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle); assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); @@ -601,7 +735,7 @@ describe(PersonService.name, () => { it('should generate a thumbnail without going negative', async () => { personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId }); - personMock.getFacesByIds.mockResolvedValue([faceStub.start]); + personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.start); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); @@ -622,7 +756,7 @@ describe(PersonService.name, () => { it('should generate a thumbnail without overflowing', async () => { personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); - personMock.getFacesByIds.mockResolvedValue([faceStub.end]); + personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end); assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); @@ -646,15 +780,12 @@ describe(PersonService.name, () => { it('should require person.write and person.merge permission', 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'] })).rejects.toBeInstanceOf( BadRequestException, ); - expect(personMock.prepareReassignFaces).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.delete).not.toHaveBeenCalled(); @@ -664,7 +795,6 @@ describe(PersonService.name, () => { it('should merge two people', async () => { personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); personMock.getById.mockResolvedValueOnce(personStub.mergePerson); - personMock.prepareReassignFaces.mockResolvedValue([]); personMock.delete.mockResolvedValue(personStub.mergePerson); accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); @@ -673,11 +803,6 @@ describe(PersonService.name, () => { { 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, @@ -690,29 +815,6 @@ describe(PersonService.name, () => { expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); - it('should delete conflicting faces before merging', async () => { - personMock.getById.mockResolvedValue(personStub.primaryPerson); - personMock.getById.mockResolvedValue(personStub.mergePerson); - personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); - - 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: assetStub.image.id, personId: personStub.mergePerson.id }, - }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); - }); - it('should throw an error when the primary person is not found', async () => { personMock.getById.mockResolvedValue(null); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); @@ -735,7 +837,6 @@ describe(PersonService.name, () => { { id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, ]); - expect(personMock.prepareReassignFaces).not.toHaveBeenCalled(); expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.delete).not.toHaveBeenCalled(); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); @@ -744,7 +845,6 @@ describe(PersonService.name, () => { it('should handle an error reassigning faces', async () => { personMock.getById.mockResolvedValue(personStub.primaryPerson); personMock.getById.mockResolvedValue(personStub.mergePerson); - personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]); personMock.reassignFaces.mockRejectedValue(new Error('update failed')); accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 3452807f66..79fdcbafe6 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -28,6 +28,9 @@ import { import { StorageCore } from '../storage'; import { SystemConfigCore } from '../system-config'; import { + AssetFaceResponseDto, + AssetFaceUpdateDto, + FaceDto, MergePersonDto, PeopleResponseDto, PeopleUpdateDto, @@ -35,6 +38,7 @@ import { PersonSearchDto, PersonStatisticsResponseDto, PersonUpdateDto, + mapFaces, mapPerson, } from './person.dto'; @@ -80,6 +84,86 @@ export class PersonService { }; } + createPerson(authUser: AuthUserDto): Promise { + return this.repository.create({ ownerId: authUser.id }); + } + + async reassignFaces(authUser: AuthUserDto, personId: string, dto: AssetFaceUpdateDto): Promise { + await this.access.requirePermission(authUser, Permission.PERSON_WRITE, personId); + const person = await this.findOrFail(personId); + const result: PersonResponseDto[] = []; + const changeFeaturePhoto: string[] = []; + for (const data of dto.data) { + const faces = await this.repository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]); + + for (const face of faces) { + await this.access.requirePermission(authUser, Permission.PERSON_CREATE, face.id); + if (person.faceAssetId === null) { + changeFeaturePhoto.push(person.id); + } + if (face.person && face.person.faceAssetId === face.id) { + changeFeaturePhoto.push(face.person.id); + } + + await this.repository.reassignFace(face.id, personId); + } + + result.push(person); + } + if (changeFeaturePhoto.length > 0) { + // Remove duplicates + await this.createNewFeaturePhoto(Array.from(new Set(changeFeaturePhoto))); + } + return result; + } + + async reassignFacesById(authUser: AuthUserDto, personId: string, dto: FaceDto): Promise { + await this.access.requirePermission(authUser, Permission.PERSON_WRITE, personId); + + await this.access.requirePermission(authUser, Permission.PERSON_CREATE, dto.id); + const face = await this.repository.getFaceById(dto.id); + const person = await this.findOrFail(personId); + + await this.repository.reassignFace(face.id, personId); + if (person.faceAssetId === null) { + await this.createNewFeaturePhoto([person.id]); + } + if (face.person && face.person.faceAssetId === face.id) { + await this.createNewFeaturePhoto([face.person.id]); + } + + return await this.findOrFail(personId).then(mapPerson); + } + + async getFacesById(authUser: AuthUserDto, dto: FaceDto): Promise { + await this.access.requirePermission(authUser, Permission.ASSET_READ, dto.id); + const faces = await this.repository.getFaces(dto.id); + return faces.map((asset) => mapFaces(asset, authUser)); + } + + async createNewFeaturePhoto(changeFeaturePhoto: string[]) { + this.logger.debug( + `Changing feature photos for ${changeFeaturePhoto.length} ${changeFeaturePhoto.length > 1 ? 'people' : 'person'}`, + ); + for (const personId of changeFeaturePhoto) { + const assetFace = await this.repository.getRandomFace(personId); + + if (assetFace !== null) { + await this.repository.update({ + id: personId, + faceAssetId: assetFace.id, + }); + + await this.jobRepository.queue({ + name: JobName.GENERATE_PERSON_THUMBNAIL, + data: { + id: personId, + }, + }); + } + } + } + async getById(authUser: AuthUserDto, id: string): Promise { await this.access.requirePermission(authUser, Permission.PERSON_READ, id); return this.findOrFail(id).then(mapPerson); @@ -128,7 +212,7 @@ export class PersonService { throw new BadRequestException('Invalid assetId for feature face'); } - person = await this.repository.update({ id, faceAssetId: assetId }); + person = await this.repository.update({ id, faceAssetId: face.id }); await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } }); } @@ -255,9 +339,9 @@ export class PersonService { personId = newPerson.id; } - const faceId: AssetFaceId = { assetId: asset.id, personId }; - await this.repository.createFace({ - ...faceId, + const face = await this.repository.createFace({ + assetId: asset.id, + personId, embedding, imageHeight: rest.imageHeight, imageWidth: rest.imageWidth, @@ -266,10 +350,11 @@ export class PersonService { boundingBoxY1: rest.boundingBox.y1, boundingBoxY2: rest.boundingBox.y2, }); + const faceId: AssetFaceId = { assetId: asset.id, personId }; await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId }); if (newPerson) { - await this.repository.update({ id: personId, faceAssetId: asset.id }); + await this.repository.update({ id: personId, faceAssetId: face.id }); await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } }); } } @@ -304,14 +389,13 @@ export class PersonService { return false; } - const [face] = await this.repository.getFacesByIds([{ personId: person.id, assetId: person.faceAssetId }]); - if (!face) { + const face = await this.repository.getFaceByIdWithAssets(person.faceAssetId); + if (face === null) { return false; } const { assetId, - personId, boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, @@ -324,8 +408,7 @@ export class PersonService { if (!asset?.resizePath) { return false; } - - this.logger.verbose(`Cropping face for person: ${personId}`); + this.logger.verbose(`Cropping face for person: ${person.id}`); const thumbnailPath = StorageCore.getPersonThumbnailPath(person); this.storageCore.ensureFolders(thumbnailPath); @@ -395,10 +478,6 @@ export class PersonService { 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.jobRepository.queue({ name: JobName.PERSON_DELETE, data: { id: mergePerson.id } }); diff --git a/server/src/domain/repositories/access.repository.ts b/server/src/domain/repositories/access.repository.ts index b6a71daf87..a501e4d6d4 100644 --- a/server/src/domain/repositories/access.repository.ts +++ b/server/src/domain/repositories/access.repository.ts @@ -34,6 +34,7 @@ export interface IAccessRepository { }; person: { + hasFaceOwnerAccess(userId: string, assetFaceId: Set): Promise>; checkOwnerAccess(userId: string, personIds: Set): Promise>; }; diff --git a/server/src/domain/repositories/person.repository.ts b/server/src/domain/repositories/person.repository.ts index 2554a8a6f9..4c6dcdc9c9 100644 --- a/server/src/domain/repositories/person.repository.ts +++ b/server/src/domain/repositories/person.repository.ts @@ -34,7 +34,7 @@ export interface IPersonRepository { getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise; getAssets(personId: string): Promise; - prepareReassignFaces(data: UpdateFacesData): Promise; + reassignFaces(data: UpdateFacesData): Promise; create(entity: Partial): Promise; @@ -48,4 +48,8 @@ export interface IPersonRepository { getFacesByIds(ids: AssetFaceId[]): Promise; getRandomFace(personId: string): Promise; createFace(entity: Partial): Promise; + getFaces(assetId: string): Promise; + reassignFace(assetFaceId: string, newPersonId: string): Promise; + getFaceById(id: string): Promise; + getFaceByIdWithAssets(id: string): Promise; } diff --git a/server/src/immich/app.module.ts b/server/src/immich/app.module.ts index 7f9edfd426..cafaefb3f4 100644 --- a/server/src/immich/app.module.ts +++ b/server/src/immich/app.module.ts @@ -19,6 +19,7 @@ import { AssetsController, AuditController, AuthController, + FaceController, JobController, LibraryController, OAuthController, @@ -50,6 +51,7 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors'; APIKeyController, AuditController, AuthController, + FaceController, JobController, LibraryController, OAuthController, diff --git a/server/src/immich/controllers/face.controller.ts b/server/src/immich/controllers/face.controller.ts new file mode 100644 index 0000000000..5fd2dff276 --- /dev/null +++ b/server/src/immich/controllers/face.controller.ts @@ -0,0 +1,28 @@ +import { AssetFaceResponseDto, AuthUserDto, FaceDto, PersonResponseDto, PersonService } from '@app/domain'; +import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuthUser, Authenticated } from '../app.guard'; +import { UseValidation } from '../app.utils'; +import { UUIDParamDto } from './dto/uuid-param.dto'; + +@ApiTags('Face') +@Controller('face') +@Authenticated() +@UseValidation() +export class FaceController { + constructor(private service: PersonService) {} + + @Get() + getFaces(@AuthUser() authUser: AuthUserDto, @Query() dto: FaceDto): Promise { + return this.service.getFacesById(authUser, dto); + } + + @Put(':id') + reassignFacesById( + @AuthUser() authUser: AuthUserDto, + @Param() { id }: UUIDParamDto, + @Body() dto: FaceDto, + ): Promise { + return this.service.reassignFacesById(authUser, id, dto); + } +} diff --git a/server/src/immich/controllers/index.ts b/server/src/immich/controllers/index.ts index b54a63d860..c177144c3d 100644 --- a/server/src/immich/controllers/index.ts +++ b/server/src/immich/controllers/index.ts @@ -5,6 +5,7 @@ export * from './app.controller'; export * from './asset.controller'; export * from './audit.controller'; export * from './auth.controller'; +export * from './face.controller'; export * from './job.controller'; export * from './library.controller'; export * from './oauth.controller'; diff --git a/server/src/immich/controllers/person.controller.ts b/server/src/immich/controllers/person.controller.ts index e581fddb1a..51f222c7d6 100644 --- a/server/src/immich/controllers/person.controller.ts +++ b/server/src/immich/controllers/person.controller.ts @@ -1,4 +1,5 @@ import { + AssetFaceUpdateDto, AssetResponseDto, AuthUserDto, BulkIdResponseDto, @@ -34,6 +35,20 @@ export class PersonController { return this.service.getAll(authUser, withHidden); } + @Post() + createPerson(@AuthUser() authUser: AuthUserDto): Promise { + return this.service.createPerson(authUser); + } + + @Put(':id/reassign') + reassignFaces( + @AuthUser() authUser: AuthUserDto, + @Param() { id }: UUIDParamDto, + @Body() dto: AssetFaceUpdateDto, + ): Promise { + return this.service.reassignFaces(authUser, id, dto); + } + @Put() updatePeople(@AuthUser() authUser: AuthUserDto, @Body() dto: PeopleUpdateDto): Promise { return this.service.updatePeople(authUser, dto); diff --git a/server/src/infra/entities/person.entity.ts b/server/src/infra/entities/person.entity.ts index 2735204e64..3b4545e45c 100644 --- a/server/src/infra/entities/person.entity.ts +++ b/server/src/infra/entities/person.entity.ts @@ -8,7 +8,6 @@ import { UpdateDateColumn, } from 'typeorm'; import { AssetFaceEntity } from './asset-face.entity'; -import { AssetEntity } from './asset.entity'; import { UserEntity } from './user.entity'; @Entity('person') @@ -40,8 +39,8 @@ export class PersonEntity { @Column({ nullable: true }) faceAssetId!: string | null; - @ManyToOne(() => AssetEntity, { onDelete: 'SET NULL', nullable: true }) - faceAsset!: AssetEntity | null; + @ManyToOne(() => AssetFaceEntity, { onDelete: 'SET NULL', nullable: true }) + faceAsset!: AssetFaceEntity | null; @OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.person) faces!: AssetFaceEntity[]; diff --git a/server/src/infra/migrations/1699727044012-EditFaceAssetForeignKey.ts b/server/src/infra/migrations/1699727044012-EditFaceAssetForeignKey.ts new file mode 100644 index 0000000000..fdff7ea062 --- /dev/null +++ b/server/src/infra/migrations/1699727044012-EditFaceAssetForeignKey.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class EditFaceAssetForeignKey1699727044012 implements MigrationInterface { + name = 'EditFaceAssetForeignKey1699727044012' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" DROP CONSTRAINT "FK_2bbabe31656b6778c6b87b61023"`); + await queryRunner.query(`UPDATE person SET "faceAssetId" = asset_faces."id" FROM asset_faces WHERE person."faceAssetId" = asset_faces."assetId" AND person."id" = asset_faces."personId"`) + await queryRunner.query(`ALTER TABLE "person" ADD CONSTRAINT "FK_2bbabe31656b6778c6b87b61023" FOREIGN KEY ("faceAssetId") REFERENCES "asset_faces"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" DROP CONSTRAINT "FK_2bbabe31656b6778c6b87b61023"`); + await queryRunner.query(`UPDATE person SET "faceAssetId" = assets."id" FROM assets, asset_faces WHERE person."faceAssetId" = asset_faces."id" AND asset_faces."assetId" = assets."id"`); + await queryRunner.query(`ALTER TABLE "person" ADD CONSTRAINT "FK_2bbabe31656b6778c6b87b61023" FOREIGN KEY ("faceAssetId") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + } + +} diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts index 208b7095c4..5a3f9925b1 100644 --- a/server/src/infra/repositories/access.repository.ts +++ b/server/src/infra/repositories/access.repository.ts @@ -5,6 +5,7 @@ import { ActivityEntity, AlbumEntity, AssetEntity, + AssetFaceEntity, LibraryEntity, PartnerEntity, PersonEntity, @@ -20,6 +21,7 @@ export class AccessRepository implements IAccessRepository { @InjectRepository(LibraryEntity) private libraryRepository: Repository, @InjectRepository(PartnerEntity) private partnerRepository: Repository, @InjectRepository(PersonEntity) private personRepository: Repository, + @InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository, @InjectRepository(SharedLinkEntity) private sharedLinkRepository: Repository, @InjectRepository(UserTokenEntity) private tokenRepository: Repository, ) {} @@ -318,6 +320,22 @@ export class AccessRepository implements IAccessRepository { }) .then((persons) => new Set(persons.map((person) => person.id))); }, + hasFaceOwnerAccess: async (userId: string, assetFaceIds: Set): Promise> => { + if (assetFaceIds.size === 0) { + return new Set(); + } + return this.assetFaceRepository + .find({ + select: { id: true }, + where: { + id: In([...assetFaceIds]), + asset: { + ownerId: userId, + }, + }, + }) + .then((faces) => new Set(faces.map((face) => face.id))); + }, }; partner = { diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts index e3c0ac26a6..038b955a1a 100644 --- a/server/src/infra/repositories/person.repository.ts +++ b/server/src/infra/repositories/person.repository.ts @@ -107,6 +107,48 @@ export class PersonRepository implements IPersonRepository { } @GenerateSql({ params: [DummyValue.UUID] }) + getFaces(assetId: string): Promise { + return this.assetFaceRepository.find({ + where: { assetId }, + relations: { + person: true, + }, + }); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getFaceById(id: string): Promise { + return this.assetFaceRepository.findOneOrFail({ + where: { id }, + relations: { + person: true, + }, + }); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getFaceByIdWithAssets(id: string): Promise { + return this.assetFaceRepository.findOne({ + where: { id }, + relations: { + person: true, + asset: true, + }, + }); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) + async reassignFace(assetFaceId: string, newPersonId: string): Promise { + const result = await this.assetFaceRepository + .createQueryBuilder() + .update() + .set({ personId: newPersonId }) + .where({ id: assetFaceId }) + .execute(); + + return result.affected ?? 0; + } + getById(personId: string): Promise { return this.personRepository.findOne({ where: { id: personId } }); } diff --git a/server/src/infra/sql/person.repository.sql b/server/src/infra/sql/person.repository.sql index 9332837460..68d049ae0a 100644 --- a/server/src/infra/sql/person.repository.sql +++ b/server/src/infra/sql/person.repository.sql @@ -133,24 +133,145 @@ GROUP BY HAVING COUNT("face"."assetId") = 0 --- PersonRepository.getById +-- PersonRepository.getFaces SELECT - "PersonEntity"."id" AS "PersonEntity_id", - "PersonEntity"."createdAt" AS "PersonEntity_createdAt", - "PersonEntity"."updatedAt" AS "PersonEntity_updatedAt", - "PersonEntity"."ownerId" AS "PersonEntity_ownerId", - "PersonEntity"."name" AS "PersonEntity_name", - "PersonEntity"."birthDate" AS "PersonEntity_birthDate", - "PersonEntity"."thumbnailPath" AS "PersonEntity_thumbnailPath", - "PersonEntity"."faceAssetId" AS "PersonEntity_faceAssetId", - "PersonEntity"."isHidden" AS "PersonEntity_isHidden" + "AssetFaceEntity"."id" AS "AssetFaceEntity_id", + "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", + "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", + "AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding", + "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", + "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", + "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", + "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", + "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", + "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", + "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", + "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", + "AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId", + "AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name", + "AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate", + "AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath", + "AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId", + "AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden" FROM - "person" "PersonEntity" + "asset_faces" "AssetFaceEntity" + LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId" WHERE - ("PersonEntity"."id" = $1) + ("AssetFaceEntity"."assetId" = $1) + +-- PersonRepository.getFaceById +SELECT DISTINCT + "distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id" +FROM + ( + SELECT + "AssetFaceEntity"."id" AS "AssetFaceEntity_id", + "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", + "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", + "AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding", + "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", + "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", + "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", + "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", + "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", + "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", + "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", + "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", + "AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId", + "AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name", + "AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate", + "AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath", + "AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId", + "AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden" + FROM + "asset_faces" "AssetFaceEntity" + LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId" + WHERE + ("AssetFaceEntity"."id" = $1) + ) "distinctAlias" +ORDER BY + "AssetFaceEntity_id" ASC LIMIT 1 +-- PersonRepository.getFaceByIdWithAssets +SELECT DISTINCT + "distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id" +FROM + ( + SELECT + "AssetFaceEntity"."id" AS "AssetFaceEntity_id", + "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", + "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", + "AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding", + "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", + "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", + "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", + "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", + "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", + "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", + "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", + "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", + "AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId", + "AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name", + "AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate", + "AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath", + "AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId", + "AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden", + "AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id", + "AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId", + "AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId", + "AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId", + "AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId", + "AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type", + "AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath", + "AssetFaceEntity__AssetFaceEntity_asset"."resizePath" AS "AssetFaceEntity__AssetFaceEntity_asset_resizePath", + "AssetFaceEntity__AssetFaceEntity_asset"."webpPath" AS "AssetFaceEntity__AssetFaceEntity_asset_webpPath", + "AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash", + "AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath", + "AssetFaceEntity__AssetFaceEntity_asset"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_asset_createdAt", + "AssetFaceEntity__AssetFaceEntity_asset"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_updatedAt", + "AssetFaceEntity__AssetFaceEntity_asset"."deletedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_deletedAt", + "AssetFaceEntity__AssetFaceEntity_asset"."fileCreatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileCreatedAt", + "AssetFaceEntity__AssetFaceEntity_asset"."localDateTime" AS "AssetFaceEntity__AssetFaceEntity_asset_localDateTime", + "AssetFaceEntity__AssetFaceEntity_asset"."fileModifiedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileModifiedAt", + "AssetFaceEntity__AssetFaceEntity_asset"."isFavorite" AS "AssetFaceEntity__AssetFaceEntity_asset_isFavorite", + "AssetFaceEntity__AssetFaceEntity_asset"."isArchived" AS "AssetFaceEntity__AssetFaceEntity_asset_isArchived", + "AssetFaceEntity__AssetFaceEntity_asset"."isExternal" AS "AssetFaceEntity__AssetFaceEntity_asset_isExternal", + "AssetFaceEntity__AssetFaceEntity_asset"."isReadOnly" AS "AssetFaceEntity__AssetFaceEntity_asset_isReadOnly", + "AssetFaceEntity__AssetFaceEntity_asset"."isOffline" AS "AssetFaceEntity__AssetFaceEntity_asset_isOffline", + "AssetFaceEntity__AssetFaceEntity_asset"."checksum" AS "AssetFaceEntity__AssetFaceEntity_asset_checksum", + "AssetFaceEntity__AssetFaceEntity_asset"."duration" AS "AssetFaceEntity__AssetFaceEntity_asset_duration", + "AssetFaceEntity__AssetFaceEntity_asset"."isVisible" AS "AssetFaceEntity__AssetFaceEntity_asset_isVisible", + "AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId", + "AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName", + "AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath", + "AssetFaceEntity__AssetFaceEntity_asset"."stackParentId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackParentId" + FROM + "asset_faces" "AssetFaceEntity" + LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId" + LEFT JOIN "assets" "AssetFaceEntity__AssetFaceEntity_asset" ON "AssetFaceEntity__AssetFaceEntity_asset"."id" = "AssetFaceEntity"."assetId" + AND ( + "AssetFaceEntity__AssetFaceEntity_asset"."deletedAt" IS NULL + ) + WHERE + ("AssetFaceEntity"."id" = $1) + ) "distinctAlias" +ORDER BY + "AssetFaceEntity_id" ASC +LIMIT + 1 + +-- PersonRepository.reassignFace +UPDATE "asset_faces" +SET + "personId" = $1 +WHERE + "id" = $2 + -- PersonRepository.getByName SELECT "person"."id" AS "person_id", diff --git a/server/test/fixtures/person.stub.ts b/server/test/fixtures/person.stub.ts index d6e5b741df..76e2d14f72 100644 --- a/server/test/fixtures/person.stub.ts +++ b/server/test/fixtures/person.stub.ts @@ -1,5 +1,4 @@ import { PersonEntity } from '@app/infra/entities'; -import { assetStub } from '@test/fixtures/asset.stub'; import { userStub } from './user.stub'; export const personStub = { @@ -41,7 +40,7 @@ export const personStub = { birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', faces: [], - faceAssetId: null, + faceAssetId: 'assetFaceId', faceAsset: null, isHidden: false, }), @@ -97,8 +96,8 @@ export const personStub = { birthDate: null, thumbnailPath: '/new/path/to/thumbnail.jpg', faces: [], - faceAssetId: assetStub.image.id, - faceAsset: assetStub.image, + faceAssetId: 'asset-id', + faceAsset: null, isHidden: false, }), primaryPerson: Object.freeze({ diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 4c2a5ed8d9..52fa21252f 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -50,6 +50,7 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => }, person: { + hasFaceOwnerAccess: jest.fn(), checkOwnerAccess: jest.fn().mockResolvedValue(new Set()), }, diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index 90a15221d5..4b805fa655 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -20,8 +20,12 @@ export const newPersonRepositoryMock = (): jest.Mocked => { getAllFaces: jest.fn(), getFacesByIds: jest.fn(), getRandomFace: jest.fn(), - prepareReassignFaces: jest.fn(), + reassignFaces: jest.fn(), createFace: jest.fn(), + getFaces: jest.fn(), + reassignFace: jest.fn(), + getFaceById: jest.fn(), + getFaceByIdWithAssets: jest.fn(), }; }; diff --git a/web/src/api/api.ts b/web/src/api/api.ts index fc6f49f021..aecf28e12e 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -21,6 +21,7 @@ import { UserApiFp, AuditApi, ActivityApi, + FaceApi, } from './open-api'; import { BASE_PATH } from './open-api/base'; import { DUMMY_BASE_URL, toPathString } from './open-api/common'; @@ -33,6 +34,7 @@ class ImmichApi { public assetApi: AssetApi; public auditApi: AuditApi; public authenticationApi: AuthenticationApi; + public faceApi: FaceApi; public jobApi: JobApi; public keyApi: APIKeyApi; public oauthApi: OAuthApi; @@ -60,6 +62,7 @@ class ImmichApi { this.libraryApi = new LibraryApi(this.config); this.assetApi = new AssetApi(this.config); this.authenticationApi = new AuthenticationApi(this.config); + this.faceApi = new FaceApi(this.config); this.jobApi = new JobApi(this.config); this.keyApi = new APIKeyApi(this.config); this.oauthApi = new OAuthApi(this.config); diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 54cc921499..f8b8188ac9 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -586,6 +586,142 @@ export const AssetBulkUploadCheckResultReasonEnum = { export type AssetBulkUploadCheckResultReasonEnum = typeof AssetBulkUploadCheckResultReasonEnum[keyof typeof AssetBulkUploadCheckResultReasonEnum]; +/** + * + * @export + * @interface AssetFaceResponseDto + */ +export interface AssetFaceResponseDto { + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'boundingBoxX1': number; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'boundingBoxX2': number; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'boundingBoxY1': number; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'boundingBoxY2': number; + /** + * + * @type {string} + * @memberof AssetFaceResponseDto + */ + 'id': string; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'imageHeight': number; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'imageWidth': number; + /** + * + * @type {PersonResponseDto} + * @memberof AssetFaceResponseDto + */ + 'person': PersonResponseDto | null; +} +/** + * + * @export + * @interface AssetFaceUpdateDto + */ +export interface AssetFaceUpdateDto { + /** + * + * @type {Array} + * @memberof AssetFaceUpdateDto + */ + 'data': Array; +} +/** + * + * @export + * @interface AssetFaceUpdateItem + */ +export interface AssetFaceUpdateItem { + /** + * + * @type {string} + * @memberof AssetFaceUpdateItem + */ + 'assetId': string; + /** + * + * @type {string} + * @memberof AssetFaceUpdateItem + */ + 'personId': string; +} +/** + * + * @export + * @interface AssetFaceWithoutPersonResponseDto + */ +export interface AssetFaceWithoutPersonResponseDto { + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'boundingBoxX1': number; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'boundingBoxX2': number; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'boundingBoxY1': number; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'boundingBoxY2': number; + /** + * + * @type {string} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'id': string; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'imageHeight': number; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'imageWidth': number; +} /** * * @export @@ -842,10 +978,10 @@ export interface AssetResponseDto { 'ownerId': string; /** * - * @type {Array} + * @type {Array} * @memberof AssetResponseDto */ - 'people'?: Array; + 'people'?: Array; /** * * @type {boolean} @@ -1672,6 +1808,19 @@ export interface ExifResponseDto { */ 'timeZone'?: string | null; } +/** + * + * @export + * @interface FaceDto + */ +export interface FaceDto { + /** + * + * @type {string} + * @memberof FaceDto + */ + 'id': string; +} /** * * @export @@ -2564,6 +2713,49 @@ export interface PersonUpdateDto { */ 'name'?: string; } +/** + * + * @export + * @interface PersonWithFacesResponseDto + */ +export interface PersonWithFacesResponseDto { + /** + * + * @type {string} + * @memberof PersonWithFacesResponseDto + */ + 'birthDate': string | null; + /** + * + * @type {Array} + * @memberof PersonWithFacesResponseDto + */ + 'faces': Array; + /** + * + * @type {string} + * @memberof PersonWithFacesResponseDto + */ + 'id': string; + /** + * + * @type {boolean} + * @memberof PersonWithFacesResponseDto + */ + 'isHidden': boolean; + /** + * + * @type {string} + * @memberof PersonWithFacesResponseDto + */ + 'name': string; + /** + * + * @type {string} + * @memberof PersonWithFacesResponseDto + */ + 'thumbnailPath': string; +} /** * * @export @@ -11349,6 +11541,233 @@ export class AuthenticationApi extends BaseAPI { } +/** + * FaceApi - axios parameter creator + * @export + */ +export const FaceApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getFaces: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getFaces', 'id', id) + const localVarPath = `/face`; + // 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: 'GET', ...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) + + if (id !== undefined) { + localVarQueryParameter['id'] = id; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {FaceDto} faceDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reassignFacesById: async (id: string, faceDto: FaceDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('reassignFacesById', 'id', id) + // verify required parameter 'faceDto' is not null or undefined + assertParamExists('reassignFacesById', 'faceDto', faceDto) + const localVarPath = `/face/{id}` + .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: 'PUT', ...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(faceDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * FaceApi - functional programming interface + * @export + */ +export const FaceApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = FaceApiAxiosParamCreator(configuration) + return { + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getFaces(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getFaces(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {FaceDto} faceDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async reassignFacesById(id: string, faceDto: FaceDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFacesById(id, faceDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * FaceApi - factory interface + * @export + */ +export const FaceApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = FaceApiFp(configuration) + return { + /** + * + * @param {FaceApiGetFacesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getFaces(requestParameters: FaceApiGetFacesRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.getFaces(requestParameters.id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {FaceApiReassignFacesByIdRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for getFaces operation in FaceApi. + * @export + * @interface FaceApiGetFacesRequest + */ +export interface FaceApiGetFacesRequest { + /** + * + * @type {string} + * @memberof FaceApiGetFaces + */ + readonly id: string +} + +/** + * Request parameters for reassignFacesById operation in FaceApi. + * @export + * @interface FaceApiReassignFacesByIdRequest + */ +export interface FaceApiReassignFacesByIdRequest { + /** + * + * @type {string} + * @memberof FaceApiReassignFacesById + */ + readonly id: string + + /** + * + * @type {FaceDto} + * @memberof FaceApiReassignFacesById + */ + readonly faceDto: FaceDto +} + +/** + * FaceApi - object-oriented interface + * @export + * @class FaceApi + * @extends {BaseAPI} + */ +export class FaceApi extends BaseAPI { + /** + * + * @param {FaceApiGetFacesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof FaceApi + */ + public getFaces(requestParameters: FaceApiGetFacesRequest, options?: AxiosRequestConfig) { + return FaceApiFp(this.configuration).getFaces(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {FaceApiReassignFacesByIdRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof FaceApi + */ + public reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig) { + return FaceApiFp(this.configuration).reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * JobApi - axios parameter creator * @export @@ -13180,6 +13599,44 @@ export class PartnerApi extends BaseAPI { */ export const PersonApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createPerson: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/person`; + // 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) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {boolean} [withHidden] @@ -13439,6 +13896,54 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio options: localVarRequestOptions, }; }, + /** + * + * @param {string} id + * @param {AssetFaceUpdateDto} assetFaceUpdateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reassignFaces: async (id: string, assetFaceUpdateDto: AssetFaceUpdateDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('reassignFaces', 'id', id) + // verify required parameter 'assetFaceUpdateDto' is not null or undefined + assertParamExists('reassignFaces', 'assetFaceUpdateDto', assetFaceUpdateDto) + const localVarPath = `/person/{id}/reassign` + .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: 'PUT', ...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(assetFaceUpdateDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {PeopleUpdateDto} peopleUpdateDto @@ -13541,6 +14046,15 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio export const PersonApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = PersonApiAxiosParamCreator(configuration) return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createPerson(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createPerson(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {boolean} [withHidden] @@ -13602,6 +14116,17 @@ export const PersonApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id + * @param {AssetFaceUpdateDto} assetFaceUpdateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async reassignFaces(id: string, assetFaceUpdateDto: AssetFaceUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFaces(id, assetFaceUpdateDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {PeopleUpdateDto} peopleUpdateDto @@ -13633,6 +14158,14 @@ export const PersonApiFp = function(configuration?: Configuration) { export const PersonApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = PersonApiFp(configuration) return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createPerson(options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.createPerson(options).then((request) => request(axios, basePath)); + }, /** * * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters. @@ -13687,6 +14220,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig): AxiosPromise> { return localVarFp.mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {PersonApiReassignFacesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reassignFaces(requestParameters: PersonApiReassignFacesRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.reassignFaces(requestParameters.id, requestParameters.assetFaceUpdateDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters. @@ -13799,6 +14341,27 @@ export interface PersonApiMergePersonRequest { readonly mergePersonDto: MergePersonDto } +/** + * Request parameters for reassignFaces operation in PersonApi. + * @export + * @interface PersonApiReassignFacesRequest + */ +export interface PersonApiReassignFacesRequest { + /** + * + * @type {string} + * @memberof PersonApiReassignFaces + */ + readonly id: string + + /** + * + * @type {AssetFaceUpdateDto} + * @memberof PersonApiReassignFaces + */ + readonly assetFaceUpdateDto: AssetFaceUpdateDto +} + /** * Request parameters for updatePeople operation in PersonApi. * @export @@ -13841,6 +14404,16 @@ export interface PersonApiUpdatePersonRequest { * @extends {BaseAPI} */ export class PersonApi extends BaseAPI { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PersonApi + */ + public createPerson(options?: AxiosRequestConfig) { + return PersonApiFp(this.configuration).createPerson(options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters. @@ -13907,6 +14480,17 @@ export class PersonApi extends BaseAPI { return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {PersonApiReassignFacesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PersonApi + */ + public reassignFaces(requestParameters: PersonApiReassignFacesRequest, options?: AxiosRequestConfig) { + return PersonApiFp(this.configuration).reassignFaces(requestParameters.id, requestParameters.assetFaceUpdateDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters. diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index b93cc1379d..f2ebd5c8dc 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -560,7 +560,7 @@
{#if $slideshowState === SlideshowState.None} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 415aa2a3ce..aa36f9195e 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -15,16 +15,18 @@ mdiCalendar, mdiCameraIris, mdiClose, - mdiPencil, + mdiEye, + mdiEyeOff, mdiImageOutline, mdiMapMarkerOutline, mdiInformationOutline, - mdiEye, - mdiEyeOff, + mdiPencil, } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; + import PersonSidePanel from '../faces-page/person-side-panel.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import Map from '../shared-components/map/map.svelte'; + import { boundingBoxesArray } from '$lib/stores/people.store'; import { websocketStore } from '$lib/stores/websocket'; import { AppRoute } from '$lib/constants'; import ChangeLocation from '../shared-components/change-location.svelte'; @@ -35,8 +37,21 @@ export let albums: AlbumResponseDto[] = []; export let albumId: string | null = null; + let showAssetPath = false; let textarea: HTMLTextAreaElement; let description: string; + let showEditFaces = false; + let previousId: string; + + $: { + if (!previousId) { + previousId = asset.id; + } + if (asset.id !== previousId) { + showEditFaces = false; + previousId = asset.id; + } + } $: isOwner = $page?.data?.user?.id === asset.ownerId; @@ -84,6 +99,14 @@ return undefined; }; + const handleRefreshPeople = async () => { + await api.assetApi.getAssetById({ id: asset.id }).then((res) => { + people = res.data?.people || []; + textarea.value = res.data?.exifInfo?.description || ''; + }); + showEditFaces = false; + }; + const autoGrowHeight = (e: Event) => { const target = e.target as HTMLTextAreaElement; target.style.height = 'auto'; @@ -106,7 +129,6 @@ } }; - let showAssetPath = false; const toggleAssetPath = () => (showAssetPath = !showAssetPath); let isShowChangeDate = false; @@ -139,7 +161,7 @@ } -
+
@@ -589,3 +628,13 @@ {/each}
{/if} + +{#if showEditFaces} + { + showEditFaces = false; + }} + on:refresh={handleRefreshPeople} + /> +{/if} diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 9b34823322..68be9c12b2 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -8,6 +8,9 @@ import { photoZoomState } from '$lib/stores/zoom-image.store'; import { isWebCompatibleImage } from '$lib/utils/asset-utils'; import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; + import { photoViewer } from '$lib/stores/assets.store'; + import { getBoundingBox } from '$lib/utils/people-utils'; + import { boundingBoxesArray } from '$lib/stores/people.store'; export let asset: AssetResponseDto; export let element: HTMLDivElement | undefined = undefined; @@ -20,6 +23,13 @@ let copyImageToClipboard: (src: string) => Promise; let canCopyImagesToClipboard: () => boolean; + $: if (imgElement) { + createZoomImageWheel(imgElement, { + maxZoom: 10, + wheelZoomRatio: 0.2, + }); + } + onMount(async () => { // Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295 // TODO: Move to regular import once the package correctly supports ESM. @@ -29,6 +39,7 @@ }); onDestroy(() => { + $boundingBoxesArray = []; abortController?.abort(); }); @@ -105,16 +116,10 @@ if (state.currentZoom > 1 && isWebCompatibleImage(asset) && !hasZoomed) { hasZoomed = true; + loadAssetData({ loadOriginal: true }); } }); - - $: if (imgElement) { - createZoomImageWheel(imgElement, { - maxZoom: 10, - wheelZoomRatio: 0.2, - }); - } @@ -129,12 +134,19 @@ {:then}
{asset.id} + {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox} +
+ {/each}
{/await}
diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 5e57cb02e8..1be7e8ad21 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -52,7 +52,7 @@ {#if hidden}
- +
{/if} diff --git a/web/src/lib/components/elements/icon.svelte b/web/src/lib/components/elements/icon.svelte index 94fe5ec93e..b15043449f 100644 --- a/web/src/lib/components/elements/icon.svelte +++ b/web/src/lib/components/elements/icon.svelte @@ -4,7 +4,7 @@ export let size: string | number = '1em'; export let color = 'currentColor'; export let path: string; - export let title = ''; + export let title: string | null = null; export let desc = ''; export let flipped = false; let className = ''; diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte new file mode 100644 index 0000000000..9b391e9389 --- /dev/null +++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte @@ -0,0 +1,246 @@ + + +
+
+ {#if !searchFaces} +
+ +

Select face

+
+
+ + {#if !isShowLoadingNewPerson} + + {:else} +
+ +
+ {/if} +
+ {:else} + +
+ + {#if isShowLoadingSearch} +
+ +
+ {/if} +
+ + {/if} +
+
+

All people

+
+ {#if searchName == ''} + {#each allPeople as person (person.id)} + {#if person.id !== peopleWithFaces[editedPersonIndex].person?.id} +
+ +
+ {/if} + {/each} + {:else} + {#each searchedPeople as person (person.id)} + {#if person.id !== peopleWithFaces[editedPersonIndex].person?.id} +
+ +
+ {/if} + {/each} + {/if} +
+
+
diff --git a/web/src/lib/components/faces-page/merge-face-selector.svelte b/web/src/lib/components/faces-page/merge-face-selector.svelte index a8a6a204c0..d21f0de98b 100644 --- a/web/src/lib/components/faces-page/merge-face-selector.svelte +++ b/web/src/lib/components/faces-page/merge-face-selector.svelte @@ -12,23 +12,18 @@ import { handleError } from '$lib/utils/handle-error'; import { goto } from '$app/navigation'; import { AppRoute } from '$lib/constants'; - import { mdiCallMerge, mdiClose, mdiMagnify, mdiMerge, mdiSwapHorizontal } from '@mdi/js'; + import { mdiCallMerge, mdiMerge, mdiSwapHorizontal } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; - import { cloneDeep } from 'lodash-es'; - import LoadingSpinner from '../shared-components/loading-spinner.svelte'; - import { searchNameLocal } from '$lib/utils/person'; + import PeopleList from './people-list.svelte'; import { page } from '$app/stores'; export let person: PersonResponseDto; let people: PersonResponseDto[] = []; - let peopleCopy: PersonResponseDto[] = []; let selectedPeople: PersonResponseDto[] = []; let screenHeight: number; let isShowConfirmation = false; - let name = ''; - let searchWord: string; - let isSearchingPeople = false; + let dispatch = createEventDispatcher(); $: hasSelection = selectedPeople.length > 0; @@ -39,44 +34,12 @@ onMount(async () => { const { data } = await api.personApi.getAllPeople({ withHidden: false }); people = data.people; - peopleCopy = cloneDeep(people); }); const onClose = () => { dispatch('go-back'); }; - const resetSearch = () => { - name = ''; - people = peopleCopy; - }; - - const searchPeople = async (force: boolean) => { - if (name === '') { - people = peopleCopy; - return; - } - if (!force) { - if (people.length < 20 && name.startsWith(searchWord)) { - people = searchNameLocal(name, peopleCopy, 10); - return; - } - } - - const timeout = setTimeout(() => (isSearchingPeople = true), 100); - try { - const { data } = await api.searchApi.searchPerson({ name }); - people = data; - searchWord = name; - } catch (error) { - handleError(error, "Can't search people"); - } finally { - clearTimeout(timeout); - } - - isSearchingPeople = false; - }; - const handleSwapPeople = () => { [person, selectedPeople[0]] = [selectedPeople[0], person]; $page.url.searchParams.set('action', 'merge'); @@ -113,7 +76,7 @@ }); dispatch('merge'); } catch (error) { - handleError(error, 'Cannot merge faces'); + handleError(error, 'Cannot merge people'); } finally { isShowConfirmation = false; } @@ -131,7 +94,7 @@ {#if hasSelection} Selected {selectedPeople.length} {:else} - Merge faces + Merge people {/if}
@@ -151,7 +114,7 @@
-

Choose matching faces to merge

+

Choose matching people to merge

{#each selectedPeople as person (person.id)} @@ -178,57 +141,25 @@
-
- - - searchPeople(false)} - /> - {#if name} - - {/if} - {#if isSearchingPeople} -
- -
- {/if} -
- -
-
- {#each unselectedPeople as person (person.id)} - onSelect(person)} circle border selectable /> - {/each} -
-
+ onSelect(detail)} + />
{#if isShowConfirmation} (isShowConfirmation = false)} > -

Are you sure you want merge these faces?
This action is irreversible.

-
+

Are you sure you want merge these people ?

{/if}
diff --git a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte index 4300708ae3..cb5022d23d 100644 --- a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte +++ b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte @@ -36,7 +36,7 @@ >

- Merge faces - {title} + Merge People - {title}

dispatch('close')} /> @@ -108,7 +108,7 @@
-

Are these the same face?

+

Are these the same person?

They will be merged together

diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index 92d339d02a..a14630a466 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -14,12 +14,12 @@ export let person: PersonResponseDto; export let preload = false; - type MenuItemEvent = 'change-name' | 'set-birth-date' | 'merge-faces' | 'hide-face'; + type MenuItemEvent = 'change-name' | 'set-birth-date' | 'merge-people' | 'hide-person'; let dispatch = createEventDispatcher<{ 'change-name': void; 'set-birth-date': void; - 'merge-faces': void; - 'hide-face': void; + 'merge-people': void; + 'hide-person': void; }>(); let showVerticalDots = false; @@ -82,10 +82,10 @@ {#if showContextMenu} onMenuExit()}> - onMenuClick('hide-face')} text="Hide face" /> + onMenuClick('hide-person')} text="Hide Person" /> onMenuClick('change-name')} text="Change name" /> onMenuClick('set-birth-date')} text="Set date of birth" /> - onMenuClick('merge-faces')} text="Merge faces" /> + onMenuClick('merge-people')} text="Merge People" /> {/if} diff --git a/web/src/lib/components/faces-page/people-list.svelte b/web/src/lib/components/faces-page/people-list.svelte new file mode 100644 index 0000000000..adf59c40c1 --- /dev/null +++ b/web/src/lib/components/faces-page/people-list.svelte @@ -0,0 +1,106 @@ + + +
+ + + searchPeople(false)} + /> + {#if name} + + {/if} + {#if isSearchingPeople} +
+ +
+ {/if} +
+ +
+
+ {#each people as person (person.id)} + { + dispatch('select', person); + }} + circle + border + selectable + /> + {/each} +
+
diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte new file mode 100644 index 0000000000..ecd98fbfbc --- /dev/null +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -0,0 +1,278 @@ + + +
+
+
+ +

Edit faces

+
+ {#if !isShowLoadingDone} + + {:else} + + {/if} +
+ +
+
+ {#if isShowLoadingPeople} +
+ +
+ {:else} + {#each peopleWithFaces as face, index} + {#if face.person} +
+
($boundingBoxesArray = [peopleWithFaces[index]])} + on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} + on:mouseleave={() => ($boundingBoxesArray = [])} + > +
+
+ {#if !selectedPersonToCreate[index]} +

+ {#if selectedPersonToReassign[index]?.id} + {selectedPersonToReassign[index]?.name} + {:else} + {face.person?.name} + {/if} +

+ {/if} + +
+ {#if selectedPersonToCreate[index] || selectedPersonToReassign[index]} + + {:else} + + {/if} +
+
+
+ {/if} + {/each} + {/if} +
+
+
+ +{#if showSeletecFaces} + (showSeletecFaces = false)} + on:createPerson={(event) => handleCreatePerson(event.detail)} + on:reassign={(event) => handleReassignFace(event.detail)} + /> +{/if} diff --git a/web/src/lib/components/faces-page/show-hide.svelte b/web/src/lib/components/faces-page/show-hide.svelte index 93fd26c769..8210c710b4 100644 --- a/web/src/lib/components/faces-page/show-hide.svelte +++ b/web/src/lib/components/faces-page/show-hide.svelte @@ -22,12 +22,12 @@ >
dispatch('closeClick')} /> - +
dispatch('reset-visibility')} /> diff --git a/web/src/lib/components/faces-page/unmerge-face-selector.svelte b/web/src/lib/components/faces-page/unmerge-face-selector.svelte new file mode 100644 index 0000000000..8a7f49cdc3 --- /dev/null +++ b/web/src/lib/components/faces-page/unmerge-face-selector.svelte @@ -0,0 +1,190 @@ + + + + +
+ + + +
+ + +
+ + +
+
+ + +
+
+ {#if selectedPerson !== null} +
+

Choose matching faces to re assign

+ +
+ +
+
+ {/if} + handleSelectedPerson(detail)} + /> +
+
+
diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index 1134aa7101..7b1ff8f7a1 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -58,6 +58,8 @@ interface TrashAsset { value: string; } +export const photoViewer = writable(null); + type PendingChange = AddAsset | DeleteAsset | TrashAsset; export class AssetStore { diff --git a/web/src/lib/stores/people.store.ts b/web/src/lib/stores/people.store.ts new file mode 100644 index 0000000000..4aeb044b1c --- /dev/null +++ b/web/src/lib/stores/people.store.ts @@ -0,0 +1,12 @@ +import { writable } from 'svelte/store'; + +export interface Faces { + imageHeight: number; + imageWidth: number; + boundingBoxX1: number; + boundingBoxX2: number; + boundingBoxY1: number; + boundingBoxY2: number; +} + +export const boundingBoxesArray = writable([]); diff --git a/web/src/lib/utils/people-utils.ts b/web/src/lib/utils/people-utils.ts new file mode 100644 index 0000000000..1d630c8c32 --- /dev/null +++ b/web/src/lib/utils/people-utils.ts @@ -0,0 +1,71 @@ +import type { Faces } from '$lib/stores/people.store'; +import type { ZoomImageWheelState } from '@zoom-image/core'; + +const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => { + const ratio = img.naturalWidth / img.naturalHeight; + let width = img.height * ratio; + let height = img.height; + if (width > img.width) { + width = img.width; + height = img.width / ratio; + } + return { width, height }; +}; + +export interface boundingBox { + top: number; + left: number; + width: number; + height: number; +} + +export const getBoundingBox = ( + faces: Faces[], + zoom: ZoomImageWheelState, + photoViewer: HTMLImageElement | null, +): boundingBox[] => { + const boxes: boundingBox[] = []; + + if (photoViewer === null) { + return boxes; + } + const clientHeight = photoViewer.clientHeight; + const clientWidth = photoViewer.clientWidth; + + const { width, height } = getContainedSize(photoViewer); + + for (const face of faces) { + /* + * + * Create the coordinates of the box based on the displayed image. + * The coordinates must take into account margins due to the 'object-fit: contain;' css property of the photo-viewer. + * + */ + const coordinates = { + x1: + (width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX1 + + ((clientWidth - width) / 2) * zoom.currentZoom + + zoom.currentPositionX, + x2: + (width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX2 + + ((clientWidth - width) / 2) * zoom.currentZoom + + zoom.currentPositionX, + y1: + (height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY1 + + ((clientHeight - height) / 2) * zoom.currentZoom + + zoom.currentPositionY, + y2: + (height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY2 + + ((clientHeight - height) / 2) * zoom.currentZoom + + zoom.currentPositionY, + }; + + boxes.push({ + top: Math.round(coordinates.y1), + left: Math.round(coordinates.x1), + width: Math.round(coordinates.x2 - coordinates.x1), + height: Math.round(coordinates.y2 - coordinates.y1), + }); + } + return boxes; +}; diff --git a/web/src/lib/utils/person.ts b/web/src/lib/utils/person.ts index f769ccf376..e26d6c6936 100644 --- a/web/src/lib/utils/person.ts +++ b/web/src/lib/utils/person.ts @@ -30,3 +30,7 @@ export const searchNameLocal = ( }) .slice(0, slice); }; + +export const getPersonNameWithHiddenValue = (name: string, isHidden: boolean) => { + return `${name ? name + (isHidden ? ' ' : '') : ''}${isHidden ? '(hidden)' : ''}`; +}; diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 6a2c709468..2738eeabb6 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -79,7 +79,7 @@ // trigger reactivity people = people; - // Reset variables used on the "Show & hide faces" modal + // Reset variables used on the "Show & hide people" modal showLoadingSpinner = false; selectHidden = false; toggleVisibility = false; @@ -145,13 +145,13 @@ `Unable to change the visibility for ${changed.length} ${changed.length <= 1 ? 'person' : 'people'}`, ); } - // Reset variables used on the "Show & hide faces" modal + // Reset variables used on the "Show & hide people" modal showLoadingSpinner = false; selectHidden = false; toggleVisibility = false; }; - const handleMergeSameFace = async (response: [PersonResponseDto, PersonResponseDto]) => { + const handleMergeSamePerson = async (response: [PersonResponseDto, PersonResponseDto]) => { const [personToMerge, personToBeMergedIn] = response; showMergeModal = false; @@ -167,7 +167,7 @@ people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id); notificationController.show({ - message: 'Merge faces succesfully', + message: 'Merge people succesfully', type: NotificationType.Info, }); } catch (error) { @@ -213,7 +213,7 @@ edittingPerson = detail; }; - const handleHideFace = async (detail: PersonResponseDto) => { + const handleHidePerson = async (detail: PersonResponseDto) => { try { const { data: updatedPerson } = await api.personApi.updatePerson({ id: detail.id, @@ -244,7 +244,7 @@ } }; - const handleMergeFaces = (detail: PersonResponseDto) => { + const handleMergePeople = (detail: PersonResponseDto) => { goto(`${AppRoute.PEOPLE}/${detail.id}?action=merge&previousRoute=${AppRoute.PEOPLE}`); }; @@ -352,7 +352,7 @@ {potentialMergePeople} on:close={() => (showMergeModal = false)} on:reject={() => changeName()} - on:confirm={(event) => handleMergeSameFace(event.detail)} + on:confirm={(event) => handleMergeSamePerson(event.detail)} /> {/if} @@ -363,7 +363,7 @@ (selectHidden = !selectHidden)}>
-

Show & hide faces

+

Show & hide people

{/if} @@ -379,8 +379,8 @@ preload={idx < 20} on:change-name={() => handleChangeName(person)} on:set-birth-date={() => handleSetBirthDate(person)} - on:merge-faces={() => handleMergeFaces(person)} - on:hide-face={() => handleHideFace(person)} + on:merge-people={() => handleMergePeople(person)} + on:hide-person={() => handleHidePerson(person)} /> {/if} {/each} diff --git a/web/src/routes/(user)/people/[personId]/+page.svelte b/web/src/routes/(user)/people/[personId]/+page.svelte index ca2ba98138..6eb3a0a3eb 100644 --- a/web/src/routes/(user)/people/[personId]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/+page.svelte @@ -4,6 +4,7 @@ import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte'; import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte'; import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte'; + import UnMergeFaceSelector from '$lib/components/faces-page/unmerge-face-selector.svelte'; import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte'; import SetBirthDateModal from '$lib/components/faces-page/set-birth-date-modal.svelte'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; @@ -46,10 +47,11 @@ enum ViewMode { VIEW_ASSETS = 'view-assets', - SELECT_FACE = 'select-face', - MERGE_FACES = 'merge-faces', + SELECT_PERSON = 'select-person', + MERGE_PEOPLE = 'merge-people', SUGGEST_MERGE = 'suggest-merge', BIRTH_DATE = 'birth-date', + UNASSIGN_ASSETS = 'unassign-faces', } let assetStore = new AssetStore({ @@ -124,7 +126,7 @@ previousRoute = getPreviousRoute; } if (action == 'merge') { - viewMode = ViewMode.MERGE_FACES; + viewMode = ViewMode.MERGE_PEOPLE; } }); const handleEscape = () => { @@ -155,7 +157,17 @@ } }); - const toggleHideFace = async () => { + const handleUnmerge = () => { + $assetStore.removeAssets(Array.from($selectedAssets).map((a) => a.id)); + assetInteractionStore.clearMultiselect(); + viewMode = ViewMode.VIEW_ASSETS; + }; + + const handleReassignAssets = () => { + viewMode = ViewMode.UNASSIGN_ASSETS; + }; + + const toggleHidePerson = async () => { try { await api.personApi.updatePerson({ id: data.person.id, @@ -179,7 +191,7 @@ }; const handleSelectFeaturePhoto = async (asset: AssetResponseDto) => { - if (viewMode !== ViewMode.SELECT_FACE) { + if (viewMode !== ViewMode.SELECT_PERSON) { return; } @@ -202,7 +214,7 @@ } }; - const handleMergeSameFace = async (response: [PersonResponseDto, PersonResponseDto]) => { + const handleMergeSamePerson = async (response: [PersonResponseDto, PersonResponseDto]) => { const [personToMerge, personToBeMergedIn] = response; viewMode = ViewMode.VIEW_ASSETS; isEditingName = false; @@ -212,7 +224,7 @@ mergePersonDto: { ids: [personToMerge.id] }, }); notificationController.show({ - message: 'Merge faces succesfully', + message: 'Merge people succesfully', type: NotificationType.Info, }); people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id); @@ -333,6 +345,15 @@ }; +{#if viewMode === ViewMode.UNASSIGN_ASSETS} + a.id)} + personAssets={data.person} + on:close={() => (viewMode = ViewMode.VIEW_ASSETS)} + on:confirm={handleUnmerge} + /> +{/if} + {#if viewMode === ViewMode.SUGGEST_MERGE} (viewMode = ViewMode.VIEW_ASSETS)} on:reject={() => changeName()} - on:confirm={(event) => handleMergeSameFace(event.detail)} + on:confirm={(event) => handleMergeSamePerson(event.detail)} /> {/if} @@ -352,7 +373,7 @@ /> {/if} -{#if viewMode === ViewMode.MERGE_FACES} +{#if viewMode === ViewMode.MERGE_PEOPLE} {/if} @@ -370,6 +391,7 @@ $assetStore.removeAssets(ids)} /> + @@ -379,16 +401,19 @@ goto(previousRoute)}> - (viewMode = ViewMode.SELECT_FACE)} /> + (viewMode = ViewMode.SELECT_PERSON)} /> (viewMode = ViewMode.BIRTH_DATE)} /> - (viewMode = ViewMode.MERGE_FACES)} /> - toggleHideFace()} /> + (viewMode = ViewMode.MERGE_PEOPLE)} /> + toggleHidePerson()} + /> {/if} - {#if viewMode === ViewMode.SELECT_FACE} + {#if viewMode === ViewMode.SELECT_PERSON} (viewMode = ViewMode.VIEW_ASSETS)}> Select feature photo @@ -401,13 +426,13 @@ handleSelectFeaturePhoto(asset)} on:escape={handleEscape} > {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE} - +