From f28fc8fa5cd6c2d2f6b5da363081aee1fbce75b2 Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Tue, 18 Jul 2023 20:09:43 +0200 Subject: [PATCH] feat(server,web): hide faces (#3262) * feat: hide faces * fix: types * pr feedback * fix: svelte checks * feat: new server endpoint * refactor: rename person count dto * fix(server): linter * fix: remove duplicate button * docs: add comments * pr feedback * fix: get unhidden faces * fix: do not use PersonCountResponseDto * fix: transition * pr feedback * pr feedback * fix: remove unused check * add server tests * rename persons to people * feat: add exit button * pr feedback * add server tests * pr feedback * pr feedback * fix: show & hide faces * simplify * fix: close button * pr feeback * pr feeback --------- Co-authored-by: Alex Tran --- cli/src/api/open-api/api.ts | 73 +++++++- .../search/services/person.service.dart | 3 +- mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | Bin 18132 -> 18182 bytes mobile/openapi/doc/PeopleResponseDto.md | Bin 0 -> 529 bytes mobile/openapi/doc/PersonApi.md | Bin 12459 -> 12619 bytes mobile/openapi/doc/PersonResponseDto.md | Bin 474 -> 504 bytes mobile/openapi/doc/PersonUpdateDto.md | Bin 533 -> 591 bytes mobile/openapi/lib/api.dart | Bin 5864 -> 5903 bytes mobile/openapi/lib/api/person_api.dart | Bin 10224 -> 10410 bytes mobile/openapi/lib/api_client.dart | Bin 18435 -> 18521 bytes .../lib/model/people_response_dto.dart | Bin 0 -> 3258 bytes .../lib/model/person_response_dto.dart | Bin 3268 -> 3512 bytes .../openapi/lib/model/person_update_dto.dart | Bin 4070 -> 4772 bytes .../test/people_response_dto_test.dart | Bin 0 -> 802 bytes mobile/openapi/test/person_api_test.dart | Bin 1289 -> 1302 bytes .../test/person_response_dto_test.dart | Bin 767 -> 866 bytes .../openapi/test/person_update_dto_test.dart | Bin 758 -> 882 bytes server/immich-openapi-specs.json | 49 +++++- .../asset/response-dto/asset-response.dto.ts | 2 +- server/src/domain/person/person.dto.ts | 26 ++- .../src/domain/person/person.service.spec.ts | 48 +++++- server/src/domain/person/person.service.ts | 32 ++-- .../immich/controllers/person.controller.ts | 8 +- server/src/infra/entities/person.entity.ts | 3 + .../1689281196844-AddHiddenFaces.ts | 14 ++ .../repositories/typesense.repository.ts | 3 +- server/test/fixtures.ts | 17 ++ web/src/api/open-api/api.ts | 73 +++++++- .../assets/thumbnail/image-thumbnail.svelte | 11 +- .../faces-page/merge-face-selector.svelte | 4 +- .../components/faces-page/people-card.svelte | 8 +- .../components/faces-page/show-hide.svelte | 30 ++++ .../layouts/user-page-layout.svelte | 1 - web/src/routes/(user)/explore/+page.server.ts | 4 +- web/src/routes/(user)/explore/+page.svelte | 18 +- web/src/routes/(user)/people/+page.server.ts | 3 +- web/src/routes/(user)/people/+page.svelte | 157 +++++++++++++++--- 38 files changed, 501 insertions(+), 89 deletions(-) create mode 100644 mobile/openapi/doc/PeopleResponseDto.md create mode 100644 mobile/openapi/lib/model/people_response_dto.dart create mode 100644 mobile/openapi/test/people_response_dto_test.dart create mode 100644 server/src/infra/migrations/1689281196844-AddHiddenFaces.ts create mode 100644 web/src/lib/components/faces-page/show-hide.svelte diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index f6a1127a58..5201db9e12 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -1777,6 +1777,31 @@ export interface OAuthConfigResponseDto { */ 'autoLaunch'?: boolean; } +/** + * + * @export + * @interface PeopleResponseDto + */ +export interface PeopleResponseDto { + /** + * + * @type {number} + * @memberof PeopleResponseDto + */ + 'total': number; + /** + * + * @type {number} + * @memberof PeopleResponseDto + */ + 'visible': number; + /** + * + * @type {Array} + * @memberof PeopleResponseDto + */ + 'people': Array; +} /** * * @export @@ -1801,6 +1826,12 @@ export interface PersonResponseDto { * @memberof PersonResponseDto */ 'thumbnailPath': string; + /** + * + * @type {boolean} + * @memberof PersonResponseDto + */ + 'isHidden': boolean; } /** * @@ -1820,6 +1851,12 @@ export interface PersonUpdateDto { * @memberof PersonUpdateDto */ 'featureFaceAssetId'?: string; + /** + * Person visibility + * @type {boolean} + * @memberof PersonUpdateDto + */ + 'isHidden'?: boolean; } /** * @@ -8644,10 +8681,11 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio return { /** * + * @param {boolean} [withHidden] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAllPeople: async (options: AxiosRequestConfig = {}): Promise => { + getAllPeople: async (withHidden?: boolean, 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); @@ -8669,6 +8707,10 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) + if (withHidden !== undefined) { + localVarQueryParameter['withHidden'] = withHidden; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -8914,11 +8956,12 @@ export const PersonApiFp = function(configuration?: Configuration) { return { /** * + * @param {boolean} [withHidden] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getAllPeople(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(options); + async getAllPeople(withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(withHidden, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -8985,11 +9028,12 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat return { /** * + * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAllPeople(options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.getAllPeople(options).then((request) => request(axios, basePath)); + getAllPeople(requestParameters: PersonApiGetAllPeopleRequest = {}, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getAllPeople(requestParameters.withHidden, options).then((request) => request(axios, basePath)); }, /** * @@ -9039,6 +9083,20 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat }; }; +/** + * Request parameters for getAllPeople operation in PersonApi. + * @export + * @interface PersonApiGetAllPeopleRequest + */ +export interface PersonApiGetAllPeopleRequest { + /** + * + * @type {boolean} + * @memberof PersonApiGetAllPeople + */ + readonly withHidden?: boolean +} + /** * Request parameters for getPerson operation in PersonApi. * @export @@ -9132,12 +9190,13 @@ export interface PersonApiUpdatePersonRequest { export class PersonApi extends BaseAPI { /** * + * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof PersonApi */ - public getAllPeople(options?: AxiosRequestConfig) { - return PersonApiFp(this.configuration).getAllPeople(options).then((request) => request(this.axios, this.basePath)); + public getAllPeople(requestParameters: PersonApiGetAllPeopleRequest = {}, options?: AxiosRequestConfig) { + return PersonApiFp(this.configuration).getAllPeople(requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/mobile/lib/modules/search/services/person.service.dart b/mobile/lib/modules/search/services/person.service.dart index 5813653cc7..8314ed1096 100644 --- a/mobile/lib/modules/search/services/person.service.dart +++ b/mobile/lib/modules/search/services/person.service.dart @@ -18,7 +18,8 @@ class PersonService { Future?> getCuratedPeople() async { try { - return await _apiService.personApi.getAllPeople(); + final peopleResponseDto = await _apiService.personApi.getAllPeople(); + return peopleResponseDto?.people; } catch (e) { debugPrint("Error [getCuratedPeople] ${e.toString()}"); return null; diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 5c610108b5..5e8a7ee704 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -71,6 +71,7 @@ doc/OAuthCallbackDto.md doc/OAuthConfigDto.md doc/OAuthConfigResponseDto.md doc/PartnerApi.md +doc/PeopleResponseDto.md doc/PersonApi.md doc/PersonResponseDto.md doc/PersonUpdateDto.md @@ -208,6 +209,7 @@ lib/model/merge_person_dto.dart lib/model/o_auth_callback_dto.dart lib/model/o_auth_config_dto.dart lib/model/o_auth_config_response_dto.dart +lib/model/people_response_dto.dart lib/model/person_response_dto.dart lib/model/person_update_dto.dart lib/model/queue_status_dto.dart @@ -322,6 +324,7 @@ test/o_auth_callback_dto_test.dart test/o_auth_config_dto_test.dart test/o_auth_config_response_dto_test.dart test/partner_api_test.dart +test/people_response_dto_test.dart test/person_api_test.dart test/person_response_dto_test.dart test/person_update_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b8fa7a4d2484185e9e330fecb61af8f96cc9500e..5bb42236eaa3d6639751601bb394069a7b328bcf 100644 GIT binary patch delta 36 pcmcc8%h=Y(xWUhcIlmxh@%P9|j&5nNZ!Z1 zz)JxX(3{|@fh_h_jTCy^VG86EE}4`n3Rsh|z|RPy34-Zf%oD2pes4`=<%U3!VRGWH zPLcB*n+=oeY#o#8C}L!XNo~!5@D2}eAs5~$e)#Rt93j5#a4rzNxtmH;=vqy1}5mF zNQS0um1>$hX)cqSTsHI)-nP@Kn(tPNHtW{PFMf{ delta 143 zcmX?|v^sIa4klHf%;FN8fYhSm{Jfyl;)49V;#8NCd^?5o)Dp*>oPgB)f}B*1%@3L6 zSSEjz)tcdEVchJ`^+yc=R;4wa diff --git a/mobile/openapi/doc/PersonResponseDto.md b/mobile/openapi/doc/PersonResponseDto.md index 05927762a94f7679588a2da0be56ffb4e0007969..6660b063cf84d137f5282c9e17a6be409c746470 100644 GIT binary patch delta 33 ocmcb`{DXOeDWj;CR%WqBW=cwGo|cwEje?d|Qht8UWF5wJ0K0Vx&Hw-a delta 11 Scmeyte2aO5DdXf|#-#upk_1Zt diff --git a/mobile/openapi/doc/PersonUpdateDto.md b/mobile/openapi/doc/PersonUpdateDto.md index 7496b2af62d42bf5cf78e35083ad58145f172647..9a59c852bb8174825c71f859b3ea2f2218c6a7ac 100644 GIT binary patch delta 55 zcmbQra-L;F0i(8-R%WqBW=cwGo|cwEje?d|Qht68m>G~-RGgouP?lMonUtB6SyDMU Hk#P$E^b-=@ delta 11 ScmX@lGL>aR0psLVj7tF;+XQg{ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 32420b9c295e514e37b68f35ea7a6d3fed313e1d..819f3092ad4c8372dcb56556410c0b1959c8824d 100644 GIT binary patch delta 24 gcmaE%+po7Fn~yoaAZPLh7NyA(_}DiW@KtaC0CoxpoB#j- delta 12 TcmeCzd!f4_n{V?ZzFH0dBNGJF diff --git a/mobile/openapi/lib/api/person_api.dart b/mobile/openapi/lib/api/person_api.dart index 3a53bd5ebfa8e6c906f405bb5122a50caec2e69a..7ced3bf7af7a18e18b6805457be1d0914ed7ab84 100644 GIT binary patch delta 349 zcmez1zbbIUN5;u>+2sY2^7C_I70NS9GCVRnwfQ|eqXGbZ(Rcv> delta 118 zcmZ1#_`!d}N5;u2Oj?reHppQf0)nexs{fb7Ny$wWEPj$1f&)f=jR2b z78m5_6{otCCIOgO8q~;goq-spIWVPEof%P8arapX0NOqbiU0rr delta 14 VcmcaPfw6f4d4GF;1{WVbpZDNw2v@^fxE!8+xcKV@ zijm~IsW2{klm7aqLrZmEY9sTRHu+2y@&uOU(#mNr3%QV`>&3NNRNAbJsSK)@Vu61vVer3`#-ed!535gfX`LutuBPZv&6G5(ZVx&uR4Hv<=<*)L++39T zZ@=V|xiGfVL3a-H40I(+QK=MvPdlASA*_XKsjEWXNL!&hdFC`cb^!WVfg9)u{tadp|qYXIINi@ehh(iki(8oSfFpwbxOw1F&3hIilO1PkWo z(i}rJ01v=K6bVJviznZ|`!7(3%J}jnr|Cs};%MB54ZDXScsh=sy8^Km*;6^F(!srS zFc)_Ihn`A|n!VRVCjORkBCQo>P5eVj&81bA#S$)N8nut%mER80v=4kh+x=Eizc{1H zd(75^M8}yolLnN5OF~4l-=@lPb9>&6$%@BVm{KXlwW%x!!5-=X=6iN z{%@@YqMU-CITn<&@F0q%v@kPz5sZy^3%hfM0$u9g5f*IR!>%ci=)~%kvfQ){dQ?Wv zaB^_7ndJmq!RFK?InE%bCOM9)xtUE+T^QBpe#`3q}<80d}xr zk#r)khxai~eE0jYLc^&YM1leOA4c?qMn#Hrw=(G-JrW(ZU~js|e%_Em0Q0ay4gHRa zNA?7R*c&xzffMKg5#eG4r;!)6PfUg!Z}@Lu20>IaBNRG}v%N7rfE_2X;^eFck37@v zja~^Hsz~aYD6H&lV#$5MjkL?cQF~nP_vK(T_R$e&WRozXKyY#~eN$zxv^!r^ZXJyx zjyOM)U|X;7dSKD*c|_W?AZw$S5{I;KX&4JXm6Gv0C^62@8g=d5^ z56n}YgM%hKYEGbjf8_WCz?TlaX#7pE10wF4b%Zgcm6A55L%8^qo&hIYN?EW0&QD;) zQjNUg-*Qc(ne{y{$@>qmMb)~EFmGJa9ON=-f4i@go8uNW5j~AsIK1|9s1{}{UN9(6 zk3=%Jor9N+%ZQkCpqU1wL1zc~@!WPr8%^ds+ZH$7SvIrB~#kmE> z?m%sjsaJRFwr_hi!#qKDnjU>Mh|!1JxvV)*KUI-#7T*B^kzOJ_?j+#tXwfb8f`E@- shFA3D4P(K-PowfD(yd<{gGa7McwYG1rWixkGxx6-EgKg`(8L(#)dN6orzE%woOFVvo#}l+?V*7Z{y*lk)R(6yPGfOvjWJ z)YP~XKp?*)Bee*wTEW&9q3$x%Bmo6&urdv}=4j?(5goW1TNSv(Ip#yk3JO`p`FYVr z>M&9DSOr^D_iXNBQD8DufSX{YkegT#mY7qT>Yo-;S&(W2a;u#N$Q&I7gh89P6c1Co=_18I{@1j4g81sVdgjt3M527NmUeF_ST CX%7|v diff --git a/mobile/openapi/lib/model/person_update_dto.dart b/mobile/openapi/lib/model/person_update_dto.dart index 3d15c71565b738921653e3d9e9abf0681fbf8c24..4b1b9967e517415718e0640e3f358a35991c6ee4 100644 GIT binary patch delta 326 zcmaDRzeIJzYDRtqg_4ZSV!g~_kIa;m)V$5h8SR-x_4V}?0#b{L^YawSGK({lGIKIZ zDmSMyCou9Q<>%+vE5MXbe#o+2SwT&WO92G(OEOZ6;6^Ff+9K4=VBM*%pbb{00n-dp zkXM?MqhPOKpkRei>&8|hq66pJs=(B5-o=*A=#rVHpn>9K1x1K4nhMnrXDDO=9Ufhz z4%4R|t6+=j%gJ}xr+XY4N5Gnuw diff --git a/mobile/openapi/test/people_response_dto_test.dart b/mobile/openapi/test/people_response_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..c48f0099adf2ed306891df900bf8c2dffae7f178 GIT binary patch literal 802 zcmb7?(M#h%5XRs0SBy_>p_cb(jj>)85ZnL|$JE;`$e|NG~IcPzB z$R-2d_nU7fo99`c!`bbu+`X7wPo`(HNdf1VSCcUmC0v&?_+1v4=YMuY)+>M282IF1 zZ~q|cOFg$PkZN2|jW%=yo$VskiVU@6dHi1M*1N!49cZ3#6_xK;1^Ugken$c7|bz Zrjr|&E&$x!k3J2v27E1xz|zbw*(=(D0H6Q> literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/person_api_test.dart b/mobile/openapi/test/person_api_test.dart index 95482f63d34b6e9dff1d5beaf86694ad4bbeeaae..a49f40aa6b71aaf82a989de3b9ef2ae7588a8f5d 100644 GIT binary patch delta 68 zcmeC=n#Q%^45M*CYJNdZYEWu%L4ICws!K_}okDtQiDOO !person.isHidden), checksum: entity.checksum.toString('base64'), }; } diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts index b8efa65c99..41430afaf2 100644 --- a/server/src/domain/person/person.dto.ts +++ b/server/src/domain/person/person.dto.ts @@ -1,6 +1,7 @@ import { AssetFaceEntity, PersonEntity } from '@app/infra/entities'; -import { IsOptional, IsString } from 'class-validator'; -import { ValidateUUID } from '../domain.util'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { toBoolean, ValidateUUID } from '../domain.util'; export class PersonUpdateDto { /** @@ -16,6 +17,13 @@ export class PersonUpdateDto { @IsOptional() @IsString() featureFaceAssetId?: string; + + /** + * Person visibility + */ + @IsOptional() + @IsBoolean() + isHidden?: boolean; } export class MergePersonDto { @@ -23,10 +31,23 @@ export class MergePersonDto { ids!: string[]; } +export class PersonSearchDto { + @IsBoolean() + @Transform(toBoolean) + withHidden?: boolean = false; +} + export class PersonResponseDto { id!: string; name!: string; thumbnailPath!: string; + isHidden!: boolean; +} + +export class PeopleResponseDto { + total!: number; + visible!: number; + people!: PersonResponseDto[]; } export function mapPerson(person: PersonEntity): PersonResponseDto { @@ -34,6 +55,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto { id: person.id, name: person.name, thumbnailPath: person.thumbnailPath, + isHidden: person.isHidden, }; } diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index c7eb08bfdd..52a8d0f5dc 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -19,6 +19,7 @@ const responseDto: PersonResponseDto = { id: 'person-1', name: 'Person 1', thumbnailPath: '/path/to/thumbnail.jpg', + isHidden: false, }; describe(PersonService.name, () => { @@ -41,7 +42,37 @@ describe(PersonService.name, () => { describe('getAll', () => { it('should get all people with thumbnails', async () => { personMock.getAll.mockResolvedValue([personStub.withName, personStub.noThumbnail]); - await expect(sut.getAll(authStub.admin)).resolves.toEqual([responseDto]); + await expect(sut.getAll(authStub.admin, { withHidden: undefined })).resolves.toEqual({ + total: 1, + visible: 1, + people: [responseDto], + }); + expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 }); + }); + it('should get all visible people with thumbnails', async () => { + personMock.getAll.mockResolvedValue([personStub.withName, personStub.hidden]); + await expect(sut.getAll(authStub.admin, { withHidden: false })).resolves.toEqual({ + total: 2, + visible: 1, + people: [responseDto], + }); + expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 }); + }); + it('should get all hidden and visible people with thumbnails', async () => { + personMock.getAll.mockResolvedValue([personStub.withName, personStub.hidden]); + await expect(sut.getAll(authStub.admin, { withHidden: true })).resolves.toEqual({ + total: 2, + visible: 1, + people: [ + responseDto, + { + id: 'person-1', + name: '', + thumbnailPath: '/path/to/thumbnail.jpg', + isHidden: true, + }, + ], + }); expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 }); }); }); @@ -111,6 +142,21 @@ describe(PersonService.name, () => { }); }); + it('should update a person visibility', async () => { + personMock.getById.mockResolvedValue(personStub.hidden); + personMock.update.mockResolvedValue(personStub.withName); + personMock.getAssets.mockResolvedValue([assetEntityStub.image]); + + await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto); + + expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1'); + expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false }); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.SEARCH_INDEX_ASSET, + data: { ids: [assetEntityStub.image.id] }, + }); + }); + it("should update a person's thumbnailPath", async () => { personMock.getById.mockResolvedValue(personStub.withName); personMock.getFaceById.mockResolvedValue(faceStub.face1); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 4310ef431b..3ec8356601 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -4,7 +4,14 @@ import { AuthUserDto } from '../auth'; import { mimeTypes } from '../domain.constant'; import { IJobRepository, JobName } from '../job'; import { ImmichReadStream, IStorageRepository } from '../storage'; -import { mapPerson, MergePersonDto, PersonResponseDto, PersonUpdateDto } from './person.dto'; +import { + mapPerson, + MergePersonDto, + PeopleResponseDto, + PersonResponseDto, + PersonSearchDto, + PersonUpdateDto, +} from './person.dto'; import { IPersonRepository, UpdateFacesData } from './person.repository'; @Injectable() @@ -17,16 +24,21 @@ export class PersonService { @Inject(IJobRepository) private jobRepository: IJobRepository, ) {} - async getAll(authUser: AuthUserDto): Promise { + async getAll(authUser: AuthUserDto, dto: PersonSearchDto): Promise { const people = await this.repository.getAll(authUser.id, { minimumFaceCount: 1 }); const named = people.filter((person) => !!person.name); const unnamed = people.filter((person) => !person.name); - return ( - [...named, ...unnamed] - // with thumbnails - .filter((person) => !!person.thumbnailPath) - .map((person) => mapPerson(person)) - ); + + const persons: PersonResponseDto[] = [...named, ...unnamed] + // with thumbnails + .filter((person) => !!person.thumbnailPath) + .map((person) => mapPerson(person)); + + return { + people: persons.filter((person) => dto.withHidden || !person.isHidden), + total: persons.length, + visible: persons.filter((person: PersonResponseDto) => !person.isHidden).length, + }; } getById(authUser: AuthUserDto, id: string): Promise { @@ -50,8 +62,8 @@ export class PersonService { async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise { let person = await this.findOrFail(authUser, id); - if (dto.name !== undefined) { - person = await this.repository.update({ id, name: dto.name }); + if (dto.name != undefined || dto.isHidden !== undefined) { + person = await this.repository.update({ id, name: dto.name, isHidden: dto.isHidden }); const assets = await this.repository.getAssets(authUser.id, id); const ids = assets.map((asset) => asset.id); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); diff --git a/server/src/immich/controllers/person.controller.ts b/server/src/immich/controllers/person.controller.ts index 106961aa89..7620145128 100644 --- a/server/src/immich/controllers/person.controller.ts +++ b/server/src/immich/controllers/person.controller.ts @@ -4,11 +4,13 @@ import { BulkIdResponseDto, ImmichReadStream, MergePersonDto, + PeopleResponseDto, PersonResponseDto, + PersonSearchDto, PersonService, PersonUpdateDto, } from '@app/domain'; -import { Body, Controller, Get, Param, Post, Put, StreamableFile } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Put, Query, StreamableFile } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { Authenticated, AuthUser } from '../app.guard'; import { UseValidation } from '../app.utils'; @@ -26,8 +28,8 @@ export class PersonController { constructor(private service: PersonService) {} @Get() - getAllPeople(@AuthUser() authUser: AuthUserDto): Promise { - return this.service.getAll(authUser); + getAllPeople(@AuthUser() authUser: AuthUserDto, @Query() withHidden: PersonSearchDto): Promise { + return this.service.getAll(authUser, withHidden); } @Get(':id') diff --git a/server/src/infra/entities/person.entity.ts b/server/src/infra/entities/person.entity.ts index 40ad593e93..b93c4bbf9d 100644 --- a/server/src/infra/entities/person.entity.ts +++ b/server/src/infra/entities/person.entity.ts @@ -35,4 +35,7 @@ export class PersonEntity { @OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.person) faces!: AssetFaceEntity[]; + + @Column({ default: false }) + isHidden!: boolean; } diff --git a/server/src/infra/migrations/1689281196844-AddHiddenFaces.ts b/server/src/infra/migrations/1689281196844-AddHiddenFaces.ts new file mode 100644 index 0000000000..234b77dd34 --- /dev/null +++ b/server/src/infra/migrations/1689281196844-AddHiddenFaces.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Infra1689281196844 implements MigrationInterface { + name = 'Infra1689281196844' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" ADD "isHidden" boolean NOT NULL DEFAULT false`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "isHidden"`); + } + +} diff --git a/server/src/infra/repositories/typesense.repository.ts b/server/src/infra/repositories/typesense.repository.ts index 40bd71ddb3..2ca81f19c4 100644 --- a/server/src/infra/repositories/typesense.repository.ts +++ b/server/src/infra/repositories/typesense.repository.ts @@ -385,7 +385,8 @@ export class TypesenseRepository implements ISearchRepository { custom = { ...custom, geo: [lat, lng] }; } - const people = asset.faces?.map((face) => face.person.name).filter((name) => name) || []; + const people = + asset.faces?.filter((face) => !face.person.isHidden && face.person.name).map((face) => face.person.name) || []; if (people.length) { custom = { ...custom, people }; } diff --git a/server/test/fixtures.ts b/server/test/fixtures.ts index 6d096772a7..b4abca792e 100644 --- a/server/test/fixtures.ts +++ b/server/test/fixtures.ts @@ -1094,6 +1094,18 @@ export const personStub = { name: '', thumbnailPath: '/path/to/thumbnail.jpg', faces: [], + isHidden: false, + }), + hidden: Object.freeze({ + id: 'person-1', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + ownerId: userEntityStub.admin.id, + owner: userEntityStub.admin, + name: '', + thumbnailPath: '/path/to/thumbnail.jpg', + faces: [], + isHidden: true, }), withName: Object.freeze({ id: 'person-1', @@ -1104,6 +1116,7 @@ export const personStub = { name: 'Person 1', thumbnailPath: '/path/to/thumbnail.jpg', faces: [], + isHidden: false, }), noThumbnail: Object.freeze({ id: 'person-1', @@ -1114,6 +1127,7 @@ export const personStub = { name: '', thumbnailPath: '', faces: [], + isHidden: false, }), newThumbnail: Object.freeze({ id: 'person-1', @@ -1124,6 +1138,7 @@ export const personStub = { name: '', thumbnailPath: '/new/path/to/thumbnail.jpg', faces: [], + isHidden: false, }), primaryPerson: Object.freeze({ id: 'person-1', @@ -1134,6 +1149,7 @@ export const personStub = { name: 'Person 1', thumbnailPath: '/path/to/thumbnail', faces: [], + isHidden: false, }), mergePerson: Object.freeze({ id: 'person-2', @@ -1144,6 +1160,7 @@ export const personStub = { name: 'Person 2', thumbnailPath: '/path/to/thumbnail', faces: [], + isHidden: false, }), }; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 414f7b147d..24f3b33379 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1777,6 +1777,31 @@ export interface OAuthConfigResponseDto { */ 'autoLaunch'?: boolean; } +/** + * + * @export + * @interface PeopleResponseDto + */ +export interface PeopleResponseDto { + /** + * + * @type {number} + * @memberof PeopleResponseDto + */ + 'total': number; + /** + * + * @type {number} + * @memberof PeopleResponseDto + */ + 'visible': number; + /** + * + * @type {Array} + * @memberof PeopleResponseDto + */ + 'people': Array; +} /** * * @export @@ -1801,6 +1826,12 @@ export interface PersonResponseDto { * @memberof PersonResponseDto */ 'thumbnailPath': string; + /** + * + * @type {boolean} + * @memberof PersonResponseDto + */ + 'isHidden': boolean; } /** * @@ -1820,6 +1851,12 @@ export interface PersonUpdateDto { * @memberof PersonUpdateDto */ 'featureFaceAssetId'?: string; + /** + * Person visibility + * @type {boolean} + * @memberof PersonUpdateDto + */ + 'isHidden'?: boolean; } /** * @@ -8688,10 +8725,11 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio return { /** * + * @param {boolean} [withHidden] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAllPeople: async (options: AxiosRequestConfig = {}): Promise => { + getAllPeople: async (withHidden?: boolean, 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); @@ -8713,6 +8751,10 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) + if (withHidden !== undefined) { + localVarQueryParameter['withHidden'] = withHidden; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -8958,11 +9000,12 @@ export const PersonApiFp = function(configuration?: Configuration) { return { /** * + * @param {boolean} [withHidden] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getAllPeople(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(options); + async getAllPeople(withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(withHidden, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -9029,11 +9072,12 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat return { /** * + * @param {boolean} [withHidden] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAllPeople(options?: any): AxiosPromise> { - return localVarFp.getAllPeople(options).then((request) => request(axios, basePath)); + getAllPeople(withHidden?: boolean, options?: any): AxiosPromise { + return localVarFp.getAllPeople(withHidden, options).then((request) => request(axios, basePath)); }, /** * @@ -9085,6 +9129,20 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat }; }; +/** + * Request parameters for getAllPeople operation in PersonApi. + * @export + * @interface PersonApiGetAllPeopleRequest + */ +export interface PersonApiGetAllPeopleRequest { + /** + * + * @type {boolean} + * @memberof PersonApiGetAllPeople + */ + readonly withHidden?: boolean +} + /** * Request parameters for getPerson operation in PersonApi. * @export @@ -9178,12 +9236,13 @@ export interface PersonApiUpdatePersonRequest { export class PersonApi extends BaseAPI { /** * + * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof PersonApi */ - public getAllPeople(options?: AxiosRequestConfig) { - return PersonApiFp(this.configuration).getAllPeople(options).then((request) => request(this.axios, this.basePath)); + public getAllPeople(requestParameters: PersonApiGetAllPeopleRequest = {}, options?: AxiosRequestConfig) { + return PersonApiFp(this.configuration).getAllPeople(requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 4b1eea5e52..3b78b17ceb 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -3,6 +3,7 @@ import { fade } from 'svelte/transition'; import { thumbHashToDataURL } from 'thumbhash'; import { Buffer } from 'buffer'; + import EyeOffOutline from 'svelte-material-icons/EyeOffOutline.svelte'; export let url: string; export let altText: string; @@ -12,16 +13,17 @@ export let curve = false; export let shadow = false; export let circle = false; - + export let hidden = false; let complete = false; {altText} (complete = true)} /> +{#if hidden} +
+ +
+{/if} {#if thumbhash && !complete} !selectedPeople.includes(source) && source.id !== person.id); onMount(async () => { - const { data } = await api.personApi.getAllPeople(); - people = data; + const { data } = await api.personApi.getAllPeople({ withHidden: true }); + people = data.people; }); const onClose = () => { diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index 6b967e832f..c91e28c006 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -24,12 +24,12 @@
-
+
{#if person.name} {person.name} @@ -37,7 +37,7 @@
+
+ {#if person.name} + + {person.name} + + {/if} +
+ {/each} + + + +{/if}