From 0aaeab124dd1ea1ac1d3fe234cb177560f699893 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 2 Mar 2023 21:47:08 -0500 Subject: [PATCH] feat(server)!: search via typesense (#1778) * build: add typesense to docker * feat(server): typesense search * feat(web): search * fix(web): show api error response message * chore: search tests * chore: regenerate open api * fix: disable typesense on e2e * fix: number properties for open api (dart) * fix: e2e test * fix: change lat/lng from floats to typesense geopoint * dev: Add smartInfo relation to findAssetById to be able to query against it --------- Co-authored-by: Alex Tran --- docker/.env.test | 2 + docker/docker-compose.dev.yml | 12 + docker/docker-compose.test.yml | 5 +- docker/docker-compose.yml | 18 +- docker/example.env | 9 +- mobile/openapi/.openapi-generator/FILES | 21 + mobile/openapi/README.md | Bin 14376 -> 14911 bytes mobile/openapi/doc/SearchAlbumResponseDto.md | Bin 0 -> 631 bytes mobile/openapi/doc/SearchApi.md | Bin 0 -> 4826 bytes mobile/openapi/doc/SearchAssetResponseDto.md | Bin 0 -> 631 bytes mobile/openapi/doc/SearchConfigResponseDto.md | Bin 0 -> 418 bytes .../doc/SearchFacetCountResponseDto.md | Bin 0 -> 448 bytes mobile/openapi/doc/SearchFacetResponseDto.md | Bin 0 -> 533 bytes mobile/openapi/doc/SearchResponseDto.md | Bin 0 -> 533 bytes mobile/openapi/lib/api.dart | Bin 4694 -> 4993 bytes mobile/openapi/lib/api/search_api.dart | Bin 0 -> 6113 bytes mobile/openapi/lib/api_client.dart | Bin 16268 -> 16846 bytes .../lib/model/search_album_response_dto.dart | Bin 0 -> 4162 bytes .../lib/model/search_asset_response_dto.dart | Bin 0 -> 4162 bytes .../lib/model/search_config_response_dto.dart | Bin 0 -> 3504 bytes .../search_facet_count_response_dto.dart | Bin 0 -> 3770 bytes .../lib/model/search_facet_response_dto.dart | Bin 0 -> 3783 bytes .../lib/model/search_response_dto.dart | Bin 0 -> 3650 bytes .../test/search_album_response_dto_test.dart | Bin 0 -> 955 bytes mobile/openapi/test/search_api_test.dart | Bin 0 -> 976 bytes .../test/search_asset_response_dto_test.dart | Bin 0 -> 955 bytes .../test/search_config_response_dto_test.dart | Bin 0 -> 589 bytes .../search_facet_count_response_dto_test.dart | Bin 0 -> 691 bytes .../test/search_facet_response_dto_test.dart | Bin 0 -> 742 bytes .../test/search_response_dto_test.dart | Bin 0 -> 700 bytes .../src/api-v1/album/album.service.spec.ts | 13 +- .../immich/src/api-v1/album/album.service.ts | 8 +- .../src/api-v1/asset/asset-repository.ts | 2 +- .../src/api-v1/asset/asset.service.spec.ts | 2 + .../immich/src/api-v1/asset/asset.service.ts | 3 + server/apps/immich/src/app.module.ts | 14 +- server/apps/immich/src/controllers/index.ts | 1 + .../src/controllers/search.controller.ts | 27 ++ server/apps/immich/src/main.ts | 5 +- .../microservices/src/microservices.module.ts | 2 + server/apps/microservices/src/processors.ts | 38 ++ .../metadata-extraction.processor.ts | 54 +-- server/immich-openapi-specs.json | 296 +++++++++++++- server/libs/common/src/config/app.config.ts | 5 + .../libs/domain/src/album/album.repository.ts | 4 + server/libs/domain/src/asset/asset.core.ts | 21 + .../libs/domain/src/asset/asset.repository.ts | 6 +- .../domain/src/asset/asset.service.spec.ts | 23 +- server/libs/domain/src/asset/asset.service.ts | 19 +- server/libs/domain/src/asset/index.ts | 1 + server/libs/domain/src/domain.module.ts | 2 + server/libs/domain/src/index.ts | 1 + server/libs/domain/src/job/job.constants.ts | 7 + server/libs/domain/src/job/job.interface.ts | 10 +- server/libs/domain/src/job/job.repository.ts | 18 +- server/libs/domain/src/search/dto/index.ts | 1 + .../libs/domain/src/search/dto/search.dto.ts | 57 +++ server/libs/domain/src/search/index.ts | 4 + .../domain/src/search/response-dto/index.ts | 2 + .../search-config-response.dto.ts | 3 + .../response-dto/search-response.dto.ts | 37 ++ .../domain/src/search/search.repository.ts | 60 +++ .../domain/src/search/search.service.spec.ts | 317 +++++++++++++++ .../libs/domain/src/search/search.service.ts | 154 ++++++++ .../libs/domain/test/album.repository.mock.ts | 2 + server/libs/domain/test/fixtures.ts | 16 + server/libs/domain/test/index.ts | 1 + .../domain/test/search.repository.mock.ts | 12 + .../src/db/repository/album.repository.ts | 9 + .../src/db/repository/asset.repository.ts | 27 +- server/libs/infra/src/infra.module.ts | 4 +- server/libs/infra/src/job/job.repository.ts | 13 + server/libs/infra/src/search/index.ts | 1 + .../infra/src/search/schemas/album.schema.ts | 13 + .../infra/src/search/schemas/asset.schema.ts | 37 ++ .../infra/src/search/typesense.repository.ts | 325 +++++++++++++++ server/package-lock.json | 73 +++- server/package.json | 4 +- web/src/api/api.ts | 3 + web/src/api/open-api/api.ts | 374 ++++++++++++++++++ web/src/app.d.ts | 2 +- web/src/hooks.server.ts | 21 +- .../gallery-viewer/gallery-viewer.svelte | 2 +- .../navigation-bar/navigation-bar.svelte | 15 +- web/src/routes/(user)/search/+page.server.ts | 26 ++ web/src/routes/(user)/search/+page.svelte | 27 ++ web/src/routes/+error.svelte | 2 +- 87 files changed, 2216 insertions(+), 77 deletions(-) create mode 100644 mobile/openapi/doc/SearchAlbumResponseDto.md create mode 100644 mobile/openapi/doc/SearchApi.md create mode 100644 mobile/openapi/doc/SearchAssetResponseDto.md create mode 100644 mobile/openapi/doc/SearchConfigResponseDto.md create mode 100644 mobile/openapi/doc/SearchFacetCountResponseDto.md create mode 100644 mobile/openapi/doc/SearchFacetResponseDto.md create mode 100644 mobile/openapi/doc/SearchResponseDto.md create mode 100644 mobile/openapi/lib/api/search_api.dart create mode 100644 mobile/openapi/lib/model/search_album_response_dto.dart create mode 100644 mobile/openapi/lib/model/search_asset_response_dto.dart create mode 100644 mobile/openapi/lib/model/search_config_response_dto.dart create mode 100644 mobile/openapi/lib/model/search_facet_count_response_dto.dart create mode 100644 mobile/openapi/lib/model/search_facet_response_dto.dart create mode 100644 mobile/openapi/lib/model/search_response_dto.dart create mode 100644 mobile/openapi/test/search_album_response_dto_test.dart create mode 100644 mobile/openapi/test/search_api_test.dart create mode 100644 mobile/openapi/test/search_asset_response_dto_test.dart create mode 100644 mobile/openapi/test/search_config_response_dto_test.dart create mode 100644 mobile/openapi/test/search_facet_count_response_dto_test.dart create mode 100644 mobile/openapi/test/search_facet_response_dto_test.dart create mode 100644 mobile/openapi/test/search_response_dto_test.dart create mode 100644 server/apps/immich/src/controllers/search.controller.ts create mode 100644 server/libs/domain/src/asset/asset.core.ts create mode 100644 server/libs/domain/src/search/dto/index.ts create mode 100644 server/libs/domain/src/search/dto/search.dto.ts create mode 100644 server/libs/domain/src/search/index.ts create mode 100644 server/libs/domain/src/search/response-dto/index.ts create mode 100644 server/libs/domain/src/search/response-dto/search-config-response.dto.ts create mode 100644 server/libs/domain/src/search/response-dto/search-response.dto.ts create mode 100644 server/libs/domain/src/search/search.repository.ts create mode 100644 server/libs/domain/src/search/search.service.spec.ts create mode 100644 server/libs/domain/src/search/search.service.ts create mode 100644 server/libs/domain/test/search.repository.mock.ts create mode 100644 server/libs/infra/src/search/index.ts create mode 100644 server/libs/infra/src/search/schemas/album.schema.ts create mode 100644 server/libs/infra/src/search/schemas/asset.schema.ts create mode 100644 server/libs/infra/src/search/typesense.repository.ts create mode 100644 web/src/routes/(user)/search/+page.server.ts create mode 100644 web/src/routes/(user)/search/+page.svelte diff --git a/docker/.env.test b/docker/.env.test index 23f58fe80..82311b7d5 100644 --- a/docker/.env.test +++ b/docker/.env.test @@ -17,3 +17,5 @@ ENABLE_MAPBOX=false # WEB MAPBOX_KEY= VITE_SERVER_ENDPOINT=http://localhost:2283/api + +TYPESENSE_ENABLED=false diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index a0e3c078c..eebde63e7 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -23,6 +23,7 @@ services: depends_on: - redis - database + - typesense immich-machine-learning: container_name: immich_machine_learning @@ -64,6 +65,7 @@ services: depends_on: - database - immich-server + - typesense immich-web: container_name: immich_web @@ -89,6 +91,15 @@ services: depends_on: - immich-server + typesense: + container_name: immich_typesense + image: typesense/typesense:0.24.0 + environment: + - TYPESENSE_API_KEY=${TYPESENSE_API_KEY} + - TYPESENSE_DATA_DIR=/data + volumes: + - tsdata:/data + redis: container_name: immich_redis image: redis:6.2 @@ -129,3 +140,4 @@ services: volumes: pgdata: model-cache: + tsdata: diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml index 343374e5c..51d397b02 100644 --- a/docker/docker-compose.test.yml +++ b/docker/docker-compose.test.yml @@ -1,4 +1,4 @@ -version: '3.8' +version: "3.8" services: immich-server-test: @@ -9,7 +9,7 @@ services: target: builder command: npm run test:e2e expose: - - '3000' + - "3000" volumes: - ../server:/usr/src/app - /usr/src/app/node_modules @@ -17,6 +17,7 @@ services: - .env.test environment: - NODE_ENV=development + - TYPESENSE_ENABLED=false depends_on: - immich-redis-test - immich-database-test diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 5076aebc6..279a3a9ed 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -4,7 +4,7 @@ services: immich-server: container_name: immich_server image: altran1502/immich-server:release - entrypoint: [ "/bin/sh", "./start-server.sh" ] + entrypoint: ["/bin/sh", "./start-server.sh"] volumes: - ${UPLOAD_LOCATION}:/usr/src/app/upload env_file: @@ -14,12 +14,13 @@ services: depends_on: - redis - database + - typesense restart: always immich-microservices: container_name: immich_microservices image: altran1502/immich-server:release - entrypoint: [ "/bin/sh", "./start-microservices.sh" ] + entrypoint: ["/bin/sh", "./start-microservices.sh"] volumes: - ${UPLOAD_LOCATION}:/usr/src/app/upload env_file: @@ -29,6 +30,7 @@ services: depends_on: - redis - database + - typesense restart: always immich-machine-learning: @@ -46,11 +48,20 @@ services: immich-web: container_name: immich_web image: altran1502/immich-web:release - entrypoint: [ "/bin/sh", "./entrypoint.sh" ] + entrypoint: ["/bin/sh", "./entrypoint.sh"] env_file: - .env restart: always + typesense: + container_name: immich_typesense + image: typesense/typesense:0.24.0 + environment: + - TYPESENSE_API_KEY=${TYPESENSE_API_KEY} + - TYPESENSE_DATA_DIR=/data + volumes: + - tsdata:/data + redis: container_name: immich_redis image: redis:6.2 @@ -88,3 +99,4 @@ services: volumes: pgdata: model-cache: + tsdata: diff --git a/docker/example.env b/docker/example.env index 2cfb1e735..2f77e8072 100644 --- a/docker/example.env +++ b/docker/example.env @@ -30,6 +30,13 @@ REDIS_HOSTNAME=immich_redis UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup + +################################################################################### +# Typesense +################################################################################### +TYPESENSE_API_KEY=some-random-text +# TYPESENSE_ENABLED=false + ################################################################################### # Reverse Geocoding # @@ -76,4 +83,4 @@ IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003 # Examples: http://localhost:3001, http://immich-api.example.com, etc #################################################################################### -#IMMICH_API_URL_EXTERNAL=http://localhost:3001 \ No newline at end of file +#IMMICH_API_URL_EXTERNAL=http://localhost:3001 diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index dd3c2b3a6..7a37ef888 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -61,7 +61,14 @@ doc/OAuthCallbackDto.md doc/OAuthConfigDto.md doc/OAuthConfigResponseDto.md doc/RemoveAssetsDto.md +doc/SearchAlbumResponseDto.md +doc/SearchApi.md doc/SearchAssetDto.md +doc/SearchAssetResponseDto.md +doc/SearchConfigResponseDto.md +doc/SearchFacetCountResponseDto.md +doc/SearchFacetResponseDto.md +doc/SearchResponseDto.md doc/ServerInfoApi.md doc/ServerInfoResponseDto.md doc/ServerPingResponse.md @@ -103,6 +110,7 @@ lib/api/authentication_api.dart lib/api/device_info_api.dart lib/api/job_api.dart lib/api/o_auth_api.dart +lib/api/search_api.dart lib/api/server_info_api.dart lib/api/share_api.dart lib/api/system_config_api.dart @@ -167,7 +175,13 @@ lib/model/o_auth_callback_dto.dart lib/model/o_auth_config_dto.dart lib/model/o_auth_config_response_dto.dart lib/model/remove_assets_dto.dart +lib/model/search_album_response_dto.dart lib/model/search_asset_dto.dart +lib/model/search_asset_response_dto.dart +lib/model/search_config_response_dto.dart +lib/model/search_facet_count_response_dto.dart +lib/model/search_facet_response_dto.dart +lib/model/search_response_dto.dart lib/model/server_info_response_dto.dart lib/model/server_ping_response.dart lib/model/server_stats_response_dto.dart @@ -254,7 +268,14 @@ test/o_auth_callback_dto_test.dart test/o_auth_config_dto_test.dart test/o_auth_config_response_dto_test.dart test/remove_assets_dto_test.dart +test/search_album_response_dto_test.dart +test/search_api_test.dart test/search_asset_dto_test.dart +test/search_asset_response_dto_test.dart +test/search_config_response_dto_test.dart +test/search_facet_count_response_dto_test.dart +test/search_facet_response_dto_test.dart +test/search_response_dto_test.dart test/server_info_api_test.dart test/server_info_response_dto_test.dart test/server_ping_response_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 987f0d36e09e70f245508d6fc027fa1d8e14e843..4717cf9704c9fc1709c9f8405f25a1b7891934e1 100644 GIT binary patch delta 413 zcmZ2cu)k!(6q)+OqT~$6f=n%i8ii;rt@PBA;8c*1bADc0X1bPEtVT+Hvc5h<5U5Ts zH$@q!s2Hp$8LUVXs8vhL-8Do@OFf92OQ%julOY=%_sLcSYm^?v4QVmH59>tRlwZ-HhN^z;0JW*3*@_$3N&8H1x F*#O?{kzfD- delta 25 hcmdmAvZ7$a6q(7(WTiL9$bJ-=+-an~`JQni8vvR$3hn>^ diff --git a/mobile/openapi/doc/SearchAlbumResponseDto.md b/mobile/openapi/doc/SearchAlbumResponseDto.md new file mode 100644 index 0000000000000000000000000000000000000000..f78a51a0f9d1a8cf83f0bcdf5717e2f2f9a1e8ca GIT binary patch literal 631 zcma)3L2AP=5WMRZ0w0nH?A+T@NO1~>`weLS&g-p>8A{K@LjkwfWQ^AolxxqXP-+ zJO+}J^Zf4YJ5Sz8Fu70C!D$yW(@JH_KTy}xqL}ZNi*mhWgRDW* pJLpKKs`CSed{}Cn$=}6{~&%3mtZT|axv+D+9 z(j4e=52s6{2$-Guc6MgI+1Wi(xy(eKg#15{qKIalJn_7}J^G+TOlBsj(?6kHM7Lrp zz3c1iSQN%f=D8{iHA?wG3Dag6R%jfus^@jmlrAq$G>Jk|AyZ+J&ytLyfK>y}JC$ap zV){<+hQmHI{#;0H@bg&es7P{?sLX3s|FBm5*{c8cMnN!@#+{|BvPm)xg0cT_ws0Qr zVO`s^Ms~dkj39X18wLTj-9p=~V1epvMCMKt<10%%ZXT|2KCWR6PcM14tIcquSsNqUcECWcRTRHk>7B@jb^B! z8%0uUi{>{zo?l*Eb-LZ&U~n}&f8RT6G!Gy4|IO|HW|2kgAAOl6(G2m)WFZWf4;%_v zyYz6W7KO!q>r_o5<~4L)_oXWf@mz zh`549kkao|Mznu&^}hEJ^h0zIJ~f{sJ;-mWzA){OhDA5>$}b1q1y?*d6Og)DZ%%vB-2J@k;DlcFK|pBA@oeKq;DSK&nXvD zqxhg!wc--2(upnEBuC*I-4%rE2JieSv%w8JT4+ zJ?=Es!&G|1s`YHN4fHcK3U-4_R7GIw}ZmRPJvy-~Z7a<1QAyTE{1%^4N$&`j7e>|zTB0Xyt3u)1AluCIpbXe=e=Vk!iP2rRKtgGARu>oSG zJD_x3x7olc^ijO&gwGEie&xLNM?Z+qg$~?H5*(<`A PX`LTNYxDNMGp+vx$H7tq literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/SearchAssetResponseDto.md b/mobile/openapi/doc/SearchAssetResponseDto.md new file mode 100644 index 0000000000000000000000000000000000000000..871a70693a44031442c60c7f2ca77090080ac11e GIT binary patch literal 631 zcma)3(Q3jl6n)QE1p1&2w7zd+py*(rB5rS`U`VbrtnCfS?ZaRnzndyd%fOXFbM84g zhn|~B^w4SV$`&m&1kZZvr_eRJSxKx1* literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/SearchFacetCountResponseDto.md b/mobile/openapi/doc/SearchFacetCountResponseDto.md new file mode 100644 index 0000000000000000000000000000000000000000..b9188b003c73b9488f755d7468bc142fcb5079c1 GIT binary patch literal 448 zcma)2O-lnY5WVMD4D6vcknLSh)vX}dQd)1NupybY!QD(qW(C0?Z?Y~}y=X3zH}B1x zj|wQD*TL02S?uaP>S!*H+8{sB;CY8BkWbh!2~kz>Oxglp5Jou!ZC>>mMccMkM;5LR z6d7hy{&q57lmXqGDfJf? zQkt@glvydIn|>K&=1q44PRyR8^XCy1QXCHVc|Bl)b-4|6F7ferQ`d{#YT2w;b#E(6 qX6VtgF5{{bMKvBK&EdOxxqbR)Z9bDiZv*8ae^`7q{MCFeg!lv)o`*s|zAsy$OAl>BAk4hU z44D+*13DFK)q8_obwm~QwR7olZtY61ERM z5=?IV-OYENy|Z9)pQVG-KJqY<#UysBNBDr(_mGbjCLL`c>m8$=CsvfB#pDQ;h($ literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/SearchResponseDto.md b/mobile/openapi/doc/SearchResponseDto.md new file mode 100644 index 0000000000000000000000000000000000000000..3b8ce07fdd4bd37325a3638369a8f6f7a2068828 GIT binary patch literal 533 zcma)(!D_=W42JK13PBES3{LOcDI+}$)>69NLLgqOt!A-hZ1*tO!_Q6&%R*r@1|#X$ zC-l1ja%iQ~l_7;=^^DRFry~YOHeeHpv7&${k_zGpVG@Cp!-srF*7v=VJ~C|_`3S3P z{_cw0WNa3!?z40v9egVKs4=j;Gzeet^6B!^U>Ll1QIk#4o?=pn*-6P+B*b4a<6Mrf zXA?M2(w6hfW_8ZZ*5kZe->y_fm8mn`j0yqc5|wkeQH#n9#oG2Exd)M8mkozehuC%N zs@fd4b+g+hZJ}s58?;eoH^duxG5H=!`01_>kN@mVzaY>mN7}|;h|9pm@RBk14O;=K A1^@s6 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 2f426ca69922c6b8b4a0b7b8ef159152d5b38a6a..3f0c9efe45d5670befaf369cf8f6f97d2584e602 100644 GIT binary patch delta 107 zcmcbn(x|>+12aovQF6v)0T!{%>zF@rOkT&TI(Y_<+T>Y0+>_hc6`y#N^bH_~iW3ypqWeSrvJZg+T&r5CIT#vH&mp=EFRbSpg<&CZ+%Y delta 21 dcmZovzoxQb1M}uH%pW)=zvPkI{E~MDD*$WZ32XoW diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart new file mode 100644 index 0000000000000000000000000000000000000000..652270ed9b40fe329cd2587079f2c555d7bb4815 GIT binary patch literal 6113 zcmeHL-ESH>5P#>dnD(KN(;oDqKAdP0juO&FYCbNZs!kE24r{>I#opQ`gp2aO-;BM> zF1t{2Nu`sjJS5AG$G;iRx3|%#G#b$U)I0n2efP9`(e8B*;pq5dH-N)4I6dpZ$=TuY z(O)l+jX3`ZnYR18_1|_Y^eV52vMLI39j9CvsPFnBsYUd$uY9o z36q20Uk`>8rcI@S;w-2UsF+I@i#q z!T;>(A6tawNu|g`Iq`Ha?akRoek( z7INs0N>URyHzC%1goblAAA}rDqnQ;b1SOk#)6UoXr}@jRLCfAnVZF(a|SE z7I~br>%aLB?HMa$->Gns?DQu3HsgA6PLW|y%HE&L*MqD6+ngE`&O)v;#!IV(90%Z^ z2Y2^*W{ltIcUc{Qc+^+aa6W(@yG4wkmhP)#e%qPpsF1_KK2VfiI!gVTg%?;d zxUoA)tOCZrR&z?E?(LAr6rAgTfD# z7^z4TCV($gZ=2{a6lcGn0EF>JWD;I}lKeKtT;U;b8PaYcv5>w@p^q@GnO#}}=5z?F z7Y<*gx*!sF0b*Q^c*RycK!?LH!m8sV>D4Pdy00#I@4Q&?j#M3=Dw%V9kxFd8ESL3= zqdf(S#6P@Eg{S7@WV3SusBVbKYCy#+DbOe%D^D(BeQ-JJc*iCRQC<{d{|Uu5+vL!>TD(>&>Vq&x>_%a)(a+JmD(ncmC}FK< zr38C0teQ!TsRtsTu&)mVKEXtZO0XY=v3p+YgFcuk=0|o!-#W~H+tg!Yx1-b}eFIx* zjdw`{X?ee;{iFrtr~s>gids3(Wb0*DY+7@+-?Z5A#@@6{*4?;9<_sWjzt#r(ZQ&w= z1#tgE$Aq6?KA@86vKg=tQc1}QU#_v&Ss1pXs5Z#aYSk*1G)KT6?z!542$~}7k;t?a zf$34k&FaV_(K7&)k-d~9m2=>c5>uf15K9^gP2aORw@tW9vZqR?K2LUC74M+8$xrh5 zsTvG@IVkCnz;Dxn%EePvm?B0=J^^nPPW9-?(g~z2o~UK{>zlp}RL5^e^KOX*1lbN6SbMrjjK>dtXd!Ea1z+XF;ZMcN5nQfD+NIKclw9riE+N`TLa~;0e z`P>Dg5LHAg^0^M#EHFA^ku&LIMc#y5{K)5Y|r>fPK>)l2;3f)ca$ mOY%iwq`KXbjZ~vTeU-=i-_S7Q) literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 8fe18d2f7964f8857577410d950279b1902a06e2..676e09dd6f64495610d65fb934eecd55f697dee9 100644 GIT binary patch delta 184 zcmeCFKiAB-K~i_}2U%tA;MByT-{hKq8X^6;xo7#l@*5AW0sG zV(0w4w9NF$j?(h1V1dbfIz|EzDYwMr)Dq|X(!3IoG*mxWaPodx0~9{GJkWeLh#?@Y RFeb=J2XxssXXvU30RU55KVJX< delta 17 YcmX@t%-B=EK~i^erLOnpW%?RI074`Nng9R* diff --git a/mobile/openapi/lib/model/search_album_response_dto.dart b/mobile/openapi/lib/model/search_album_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..8a68fbe49f20e87efe7a72fd217261ca15880626 GIT binary patch literal 4162 zcmds4ZExE)5dQ98aVdgY!Bl1Kr@@Wf7K=0VEe6tTz+e~xEz=P@S<*`YSCHqr9WT4_`-#1{XrgvL)RwZY4^8CFlr!dOw*M6J=F+9;`6-3%JbRUu81m&FZ= z*-RA4Pd_GUCbSui(42*G17#%(QK<<3UXDg-E{uUsQs^{$ldtOSl{6LFl;^gDyAi-C z!f+*RtqTJJ3V92QVd*6(b~Tj|stwBobR&QV0&J#CWJ@dZaNr;W{-Zp6vRV~4h>iOaF!bZ$%3QEZ@v$pL2U&c~ z4uuY*_?Z0&8%*M3XMZ_nyB|e$=FEh!-eH5Qbc&YWnX*8;uIx9e5IKCtEiB?k(s%bS zU20ikp~vjXc!{!Io9v0Of1L;~_e9v~PK1|xA})|l#;u)@=GJGIXycw5>R(U-h+++n zB@M!9;ETv>X<(zvZFA#-w~$3PC@?0nSsS7w!#8{|5s1YM`t za3-NvOprCLN)sg5tXxysxs3zF^ol*&)b!|?^}HB0~#s*T^MTjrbVT_w+ud#3>b7=q`BlOs0@$v$(yx0M`g^p%(u zCa^yUqa4TM1%k_sK{k<=8bax-JHnVWy>+oSCkJowoYP&?;CIjAHwY2tE$uW7%p-dk zy6G!;;IVIIht?g>N;6VQ|Fcmdhk&+T`XNri4y2yDn#-L zXDHKTqzpFcY+Ko0?Nd_*je+u(8jDoDWy+g7boCV7(}n$I(BLbyniwE zCWRD<_1bY*Pt&1IyiPtmz`oJ4B?T9F*e2k=@hv?ZKU-P83}*$lU+ zyXZH>a7LavVbpErJ3fF=iGEViomgT6l|t2ZHhkZ7N@^r9STiB~vyR3)`qlJJga5ml zqKviZ_3A2Y`?%}q@INTFQ@FiHJNbJx2q@j mgU~C#C*77lrguzj&1>4t$+4S&&B6_|X3@+gTH$*f>FqD2jX5p= literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/search_asset_response_dto.dart b/mobile/openapi/lib/model/search_asset_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..fd1b8b545f1c0d2aa2ad88c2df952567944b8140 GIT binary patch literal 4162 zcmds4QE%He5PtWsxD-LHV5+kAX>cRA#o`Q2+Zaf*0fS)(j7&#tWl1He21c6yzPqC+ zDURH&dl|3-sZH{ZclX`hcl7b`==d1k{C+im_S@`YcKPONb_!>2-_Jrgox{ca3O>wF z-=6(>fMO*1dL^`tUqsJejA*HDic-sDUFu}5ayfyzsEu4DGM5`!*wC-lrYf}^)DZct z*qWrOZKCCuT4_`-#0LMbgvL)RwZY4^8CFlr!dOw*M6J-ES}Unp-3%JbRUu81m&FZ= z*-RA4FFz+~CbSui(42*G4P_+@QK<<3UXDg-E{uVXQs^{$V~n(y(o|?up4t-bMgYeM z!=<#fE({1LIl-)jEj7e(Fu(pJQv}~;QXfH#_j58+kp~o5=uf_VbKgMiP07=zjE9qR(4hqv zA3QwdDCy9Bd3e}h8M{z-G(DQ}z6c4@8)+dECi}HqNsNizYFmanrI4vKM(8b7Iu>sx zjWR42a5^HdeFQ(bZFcnWHv9T`+j-gTKgz>Lt5tD>*tjnNLq8s_%mup?AG%_6kj01W zQ0Op<5802f!6ZI(_LpO}`$1GE&P)iaEjGAHr)c@ylm*&#WxrO1$l()iVG%!)zPo?v zQp*wxJ!V(NOO)-}WJiSk>qL0DBf?I1BD~xYae;I)Zta9Lw?4Z>8~4;u|AG=g6f1Bn zX%J2WUqxO^18ZGwnj06qg)Fj0fiaQI0zqAdP}DiL6ZOumGE6SLGOM&)BiAt{=sLxQ zGYPd~f~;v(njpbu<%-JAZR{bYSL_LAtI3}8(5DWa3fMntO?1oe13XA7i$tkFQrRtr zz>yKrAG$v)Osyp*Rvt5kthol9ZRK|DKX z+`x$;wGG<3CfB^b@5o(rVJ6Zn2+;WsDH3Z`sRtq-FRHL$2;<9ULINtnlRce!h=9Xr z6jCHsE5~6yO@}t|I{EMb`$o%_6kOn8n}Gkux9~h@5`(nZvfiHjP%XFh)X$E0*r7md z*qe}hF7pGkp9u8fyLsS*#V$|#Xqx!hJ^UxozR?rp65OO$zk9=A#=4y1uHO2rMzm7W z_?Uh?yL?^5wb0Ae-((pZJ29HZ3JjSYYPuE*zGu)!? zqTdk18F}V}QMZ}z_y9sB`bkB1Vu=k@3RTz1@O{%Msgb~7&4lpJIvVfjSJQV5{_kpv zGS;HktE;f>HW9+JSy<=)?Uej()j@<-o7H*(5i)Jp-3g6>MZ+`*&AUb#e literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/search_config_response_dto.dart b/mobile/openapi/lib/model/search_config_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..72faed25dcdb91151deb1b07d8d26e34186c34c6 GIT binary patch literal 3504 zcmds3TW{Mo6n^)wI4O!)0TgA;Q<2nei^Umwi-9B?Fc^kF%e2i_CN+|(VWj@=JBJrp zS*&jB%YYfMEs^JbzVDD{XT7sCc>C-9`1F_2&FJ>+{pbp=-+dgVa5aXT@jZMRU%k8j z{RqWK^39xSJ2=l?ocHKeETz&sUnrd~M9D9pmbKyYoR@sfrA_0zSXWBhgBq;du&v3f z+UA=7Q45XglCAN#VjBM~*9MJib6CAl(pV;KF6QV^ECkn1-5hjQ3dv1gD!D{4yJ9kb z`BPr3m^Qs0y0f4bpeim|B{KY8_IgFhjDb7Obg}wb$%RMUx0SLcUB9z1grc*;!^B) z6nGNs4W5UGY2Ov_6RTc%{S@X~grF#fX!5;L5)JyUEQDkwe8wdhz!T|*-K8&$lbGm! zaL}Kkbi0pUOgqpxSz6dpTYm&hz~me}!wd*(;43R@ZeXF+I+&ceCB)wX1^R^338p(u zLDpsIJGl|Y!LrqrO7jKs#8FJw1!BqMgt8nNXGM`CIag{kYI;##dUy3^>!jz!Hj)U`NU&_h=cKuq^UK>iZhEp9M-G?Ou{mxpId0i^@VQ@ zaWN>~D9ho+g_8+IBrx_Z6f0hQ;d8)AN3n~!uv?h%t-{jJIDt zI0=aX1@fso8hg=kAdb~FkBhtHWDkL`-;rU`*a`H!Ab|WvoEb0fw-tXyvvrtI8B^Gk z_~eMjFMUENJNFu#QTPpu106mJg6iR;WB3|M+`h)zJ|c$q`ryXGGP$&qjhClP$G;c!GqcmPDwSorULj) z->g9w>odZQ%MH_Lb#Kr1fS0yq!a60DvH9H3gqEf+I890l;!*j7mQ|Ep5({?$@yS_n z6E^e)$4oOtx>`iIIM|>Vv9AM-m)2>}Wi6f#?OtFM-))8K*}%7|Yw*#;^Y^d|Xs%2- zk`ou{66Q})Y}&r9xT?3Yv3P{zNa7|Y^#cud_A-ZuR=fVD(O5P-iZPJP4Ud5*eAGhP<-$z%Y>o>Qf3%GpuaTLSF7_P>*@M(PU z?((k#G$YH`b7B49G9_jc1=_hUM_Nv_~))LMU%0iikkO&6{uE z!Z%ReMSA|60SDu-1xyp*!NZq~kY4VGO1rv~9&+LGw`ML89EWb%NF>xUlgOIYS)+vP_Us>|n1_!4nZ`W?NSaH7FsXjjZy`yBq0C|`zX zG0fMYaHkXw_m$BM;ip4=C`$~b zGzv~3g+qY&?x2Q&GAON&T4oZo2JZ)gix5U4%cBUvZ!1M+wJWh0M?dTrUDz;&{&g+; zfR6C2Yord*V)~5GE|qh%JK__(zZqX8eJnL43qLz4C*%YN1LoCJ(xFz6s1qw|Ht5GG zNpqXzmQMokwvx@_COX*ytrML@+FGkFrs?K&_$UI-;|t+@lx z=}os7yxS}*d^MARcQuFjY5GJsF}L*M3ta2`fqYwW8Q!z{)wkJjto6fb#sA)%_fY2k E0hT<)WdHyG literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/search_facet_response_dto.dart b/mobile/openapi/lib/model/search_facet_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..78a4756af2f416acaa2b2570a319b64b91b80c00 GIT binary patch literal 3783 zcmds4U2oeq6n*!vxG92K!4!GT)8N!@i^dt6wlR=q0|vtoXqk@L%A`h84UE+PefN^0 z#3W9)?q$FN#2?iC;<@J@(t|;7Fn~9|T~CjHom@_?-ds=4;QZZ(NeX9ExSU?Y$LZO- z^FI#Ij4WR-nYN>o?8QlszKR>EG|yK`=POb2A=I)qe3|o-Z@9E+_%1e;(sr*0D{tA> z0Mt8|J_*yZI@0M$W!L`}%UMOiSlQtJiOej`@Yv*qECM$*HCNGt|K{LB% zGXLf0yjU}BdOb{ML9IYlT(U}J_A`>p37T&V@m&~3j>LJlM=%b7Bk&%Q}*X*f1hU$OnoVxT>#!Qm6-2>lGl#-_b(;_m|TJv2?GL&{gst9H?Y!b z(?p!PB^;s^8uV$_W(dnX1zDF!P-5e)FmAS@R#%#@aMazD=(<1(o1Dm=LF}{zRHu zb8HK<_`+esDw=f5P(s{7s}NuK){vewj+e@EcrkP~4TePUzJ+4Vi!Xc$sMS=vSPHv^ z1>Y(}e!)pFq>{%3=EHC&6b|=Q&@|z@!P2V=LkWpWRdQyqKzw%4l!1cDwM|-PQfZ;y z_f#&HFk!_yNwD}1DKcwZiN#siFSIl$0-@m`4 z4bzVBkofUCP0Vz4y{&jdu5m&*rVtPN(Sbiis1!fP!7pk%d*w4Zik;#kXsxI3X>j{+%GbpAXMEF4F1Bd~_=_Q5r5tl{{j*E05^=CYGtm`SR>TT$1M&ATCVNXBaT^BBMT!?!0H(f^1X5P`s(8%jZ(1y_5 z@P4n==-N&hZXUmq7&)wzzo^{`yT&D&F1p_C@5r$?M&IV_#p?w+(QTh%BE|MFh1eC} zDI>a%9bd>`FK1HtXA_O7V>W#^;s1_P)Ug)5h*xPl$K6Dy{{h(saE(_Js*-pWPIx+- z>5hUomrae2798*rX0U!5GUiXlj!*vgOK^TqzO8(W@0_$ncU6CQg%imA<~i-5ko^Ux C;m242 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/search_response_dto.dart b/mobile/openapi/lib/model/search_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..c0e5c1a99e7aafad2203468660915fe0a59801ae GIT binary patch literal 3650 zcmds3ZExE)5dQ98aVd&g!4!GTry-rw28%QFEe4Y8!(bQ!Ez=QOnbb(ChLQTe?~X6B z61iLZX}|)+lE}N`!@K7>ax@x@MsV@R-Sp)5$@S#s;%;&YS8qQ|Qn;ML_4E!tPA}hH z{dt6DWcg;vv>l&jFHZ;bDek4xJYOlDuSCVqpplK?%bZty&81D_w^-Lo+k+mg+OVz3 z>&E7q|J4YM?vkzXw`LljmTQB-wK?owC}}K{HWy1=P^<*k&fOebtP+x&yi)QW&1}hJ z{>#sKQ8H}?16M{hczH~?|yU`t^#RxO&fNe|5!!>yay9V56H@PZt==zN5(1vx&l zaFLwF8})^2jrqfP3#BT`fM!-{7>|?b;u9}0FSg|R7{=$|^IWhhA*^Yf`TEtrfjE?{ z7cbmqyh+#}MhA}*o=iJvZu&4tAuZf<3neqhOFTkql-vm8 z%o4w97Tu5Gr!el8+mH9Mb8EGb_h6NONnApPy`s+i0^{fYnF3M>%ZA_XIUl;fA6fmY zkCVc3i}hXxt&vQzHkWW8}WQ zk#=l$;b>q-6#Frj1CvYeayKBXfiJ9TxPg^c>kw?_hENzQG#HX}W|)h43bLt?e8jYk zFitO>iq@L1P_$0(x+##JCMV+ND1R%896Lp+mQ-Os(AU~)2g2ny`Rxo3G1xdUpNzsUWB2A+?R)Sgl;jm^kAbiWPBe;Q9^`H6HP;jGoI46Ih^pEqEfWb^#QUD$B7_Mm$|OPX`%IBpa3$7ealh!|3{x21gf0*0 z2+#WBd5+`rXM}MrjG;Xdci`Ra^g0`2f@ul=*I8&rPH;4K9Qyu6umw=(Cfd&)kbXO@ zy&ob7ejg%iXy-r$$drUu^Fcx%1d;sd*?0aLDxA2ANTZktaS9La2)Wm4!xj~`W>~Sx z@N^eB;r9z2sF2CJwjJV;B9ahWZHU$68W*Lk z`AJ{qalbq4(}vJH72kJ-z^-mg(gbZJW6P!Q9UV`fUh1N>{G((CFRN%@Nlexm#5Whk zb>C2l?qSg{2(^}j<%n4Q-l+j~j*)(xc%XDMk1PLU;$4NtzA)cviqsALRq+hkTV0Mc z#zDF#`g0jev@ar#>TN7zMqh%PxSby=r;C)ix_656FJ;A4&Ag*)EMwmuqU2*W2!b<#H0gSJtq&E zJIIgkl`F>RGUr=s{h#-?U4+Xiv|;QCg)RQls*=+B-Zec|mBTh!m=B$x*P literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/search_album_response_dto_test.dart b/mobile/openapi/test/search_album_response_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..7de4a859bf6fda5171f9eb9d2301c6754eb33a52 GIT binary patch literal 955 zcmbu7(Qnf*5XRs0R~%1mrLw@D(8eYzrA3p*B8~EZ5b}=X(pornu+Kvk+W)?@!=?#3 zjfsaix#;_S_nqysEXooVpVsxu_sfgr=e+8)p(BVk25J=<>dEr_uGanFJntpY{KQ>QLuBRYN7P<$i@mG^ zdU-SXAm%>o#~Wt@qh+OAX{b9*KI36;Govj9Wt`oJjJIgjn^&sYq7OweW`xOr&3D|; zksHY3pi6lU($tgVza&h7%e34HQqxoikS&3agudA>%(vKIQWzvDRqTpur(5Vz+vyB` z6#&9?+M)rSM`;L79zmzG<3fOqcX60bQlm$NlR5l0&-U6fuwyg=Lk=jk%OAj7g2BtM zo_BDSRaYa9VP~71Fj10nT~~E>xEXWYdp9mxe$qRup5=Y61Hb)q|4u3Aa7hMq4?^!0HFCTxuFgEj>4EJzZh#SA*79 zA?y5b>kiX1t(>5elyfi01(WUQ?x0x3id7B4~FU_)uU@XtU+WaPXozhZuJU3)l;kJ{Np%5Om z_93|mzG%zhH#S)M$;DI^`kpN7{Dx&m+&`KN9;fL>$s5Ui<_w>X>CJRbRS382YQ$jZY literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/search_asset_response_dto_test.dart b/mobile/openapi/test/search_asset_response_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..b1452681ca8b6f4391442d76b960e1749b1392ad GIT binary patch literal 955 zcmbu7(QDf<5XRsASKOYGKvr*k8tqyda54&A4PEvyMo|y@oQTPiCY?LN=>NWxybiXd zw51Q0edhap_noA&EXooVpV#&456jEt)ndJ@V0C`6oI_Q^Wxa-vb#=b_@=9b$dDdd! zlf#3zheay&#=1bNb3t|5&=CxFh}0@F)RX1;eXV=%0`Dcz{J>pQeqa^o`=Ep17JFGe z26;1uDCQyV#~WuOqh+OAX{b9*A>(mxGovjHG6uU)@cT>X}nr~+z`wo9}8~Wd@{bD1b!8jR0dh8 z3@b&0Q|M%8v0&I>i_(qGyJ)pC?jnOXKYVYQ?i@4t)d@p%$y=(L8|n^?C2?af({m-Q z9UIEh2hje|0$f z2XSa!PO literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/search_facet_count_response_dto_test.dart b/mobile/openapi/test/search_facet_count_response_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..fb3caee62769d0e46ec35bd447c572732e8713c4 GIT binary patch literal 691 zcma)&K}*9x5QXpg72~NbRHL4xrC>3w2x_3lgQs;!r|Dw4yY5buBK~)0w|cTv583R5 z_r3Qfd7kAtOdqTA_v=DM?K^IRavdvAubuXS8{Ek0(94_22Ql|y zKVDlC7!51Elt8`G}uL+!&-92yNIjNp^ qIEJmMQ`bmI->NLi zA&$@beL5-2qAcO;dRc$|FLvWFtMmDoEq*jrkHCdiM&wA}$;GGOKe{d6(A6NzYJ?J2Gi)&du26;1u zDB>aRrZ>(;M$1aKVyHV!A=BedGovj%MAM7-CUuGs4JYbB{e8I!_k8 zF6G%vtw%?1AWV_Vv^)cnX__O*mB1BYXjXG`K&h8e!?}(K-sZ z?z}zM%6XwYmm31gLZXd!cNSJjg^pKRl_c|9q4?bm&$q%l#@de1F(UsECEja;N*I~Y zGYDIEMkf-F&L`nG2m&I(_(yjG--gQoT3z5gtUiaFhkHC57qu0e*{MENyK44%V$~ p-DV26Q+X^KY*cuarCE&Fx4n*m&Y}MY3>OywqwF#up(A+DegV5u { let sharedLinkRepositoryMock: jest.Mocked; let downloadServiceMock: jest.Mocked>; let cryptoMock: jest.Mocked; + let jobMock: jest.Mocked; const authUser: AuthUserDto = Object.freeze({ id: '1111', @@ -139,12 +141,14 @@ describe('Album service', () => { }; cryptoMock = newCryptoRepositoryMock(); + jobMock = newJobRepositoryMock(); sut = new AlbumService( albumRepositoryMock, sharedLinkRepositoryMock, downloadServiceMock as DownloadService, cryptoMock, + jobMock, ); }); @@ -158,6 +162,7 @@ describe('Album service', () => { expect(result.id).toEqual(albumEntity.id); expect(result.albumName).toEqual(albumEntity.albumName); + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: albumEntity } }); }); it('gets list of albums for auth user', async () => { @@ -291,9 +296,8 @@ describe('Album service', () => { const updatedAlbumName = 'new album name'; const updatedAlbumThumbnailAssetId = '69d2f917-0b31-48d8-9d7d-673b523f1aac'; albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - albumRepositoryMock.updateAlbum.mockImplementation(() => - Promise.resolve({ ...albumEntity, albumName: updatedAlbumName }), - ); + const updatedAlbum = { ...albumEntity, albumName: updatedAlbumName }; + albumRepositoryMock.updateAlbum.mockResolvedValue(updatedAlbum); const result = await sut.updateAlbumInfo( authUser, @@ -311,6 +315,7 @@ describe('Album service', () => { albumName: updatedAlbumName, albumThumbnailAssetId: updatedAlbumThumbnailAssetId, }); + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: updatedAlbum } }); }); it('prevents updating a not owned album (shared with auth user)', async () => { diff --git a/server/apps/immich/src/api-v1/album/album.service.ts b/server/apps/immich/src/api-v1/album/album.service.ts index 525cf5065..3fa51b9a4 100644 --- a/server/apps/immich/src/api-v1/album/album.service.ts +++ b/server/apps/immich/src/api-v1/album/album.service.ts @@ -6,7 +6,7 @@ import { AddUsersDto } from './dto/add-users.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { UpdateAlbumDto } from './dto/update-album.dto'; import { GetAlbumsDto } from './dto/get-albums.dto'; -import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from '@app/domain'; +import { AlbumResponseDto, IJobRepository, JobName, mapAlbum, mapAlbumExcludeAssetInfo } from '@app/domain'; import { IAlbumRepository } from './album-repository'; import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; @@ -27,6 +27,7 @@ export class AlbumService { @Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository, private downloadService: DownloadService, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, ) { this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository); } @@ -56,6 +57,7 @@ export class AlbumService { async create(authUser: AuthUserDto, createAlbumDto: CreateAlbumDto): Promise { const albumEntity = await this.albumRepository.create(authUser.id, createAlbumDto); + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: albumEntity } }); return mapAlbum(albumEntity); } @@ -105,6 +107,7 @@ export class AlbumService { } await this.albumRepository.delete(album); + await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { id: albumId } }); } async removeUserFromAlbum(authUser: AuthUserDto, albumId: string, userId: string | 'me'): Promise { @@ -171,6 +174,9 @@ export class AlbumService { } const updatedAlbum = await this.albumRepository.updateAlbum(album, updateAlbumDto); + + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: updatedAlbum } }); + return mapAlbum(updatedAlbum); } diff --git a/server/apps/immich/src/api-v1/asset/asset-repository.ts b/server/apps/immich/src/api-v1/asset/asset-repository.ts index 85599761e..9c36da9ae 100644 --- a/server/apps/immich/src/api-v1/asset/asset-repository.ts +++ b/server/apps/immich/src/api-v1/asset/asset-repository.ts @@ -252,7 +252,7 @@ export class AssetRepository implements IAssetRepository { where: { id: assetId, }, - relations: ['exifInfo', 'tags', 'sharedLinks'], + relations: ['exifInfo', 'tags', 'sharedLinks', 'smartInfo'], }); } diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index 741ab1ac9..e7d876059 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -445,6 +445,8 @@ describe('AssetService', () => { ]); expect(jobMock.queue.mock.calls).toEqual([ + [{ name: JobName.SEARCH_REMOVE_ASSET, data: { id: 'asset1' } }], + [{ name: JobName.SEARCH_REMOVE_ASSET, data: { id: 'asset2' } }], [ { name: JobName.DELETE_FILES, diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index a7dc920e0..a04ec8a64 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -170,6 +170,8 @@ export class AssetService { const updatedAsset = await this._assetRepository.update(authUser.id, asset, dto); + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { asset: updatedAsset } }); + return mapAsset(updatedAsset); } @@ -425,6 +427,7 @@ export class AssetService { try { await this._assetRepository.remove(asset); + await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { id } }); result.push({ id, status: DeleteAssetStatusEnum.SUCCESS }); deleteQueue.push(asset.originalPath, asset.webpPath, asset.resizePath); diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index 5d0776504..ec8bc4aae 100644 --- a/server/apps/immich/src/app.module.ts +++ b/server/apps/immich/src/app.module.ts @@ -1,5 +1,5 @@ import { immichAppConfig } from '@app/common/config'; -import { Module } from '@nestjs/common'; +import { Module, OnModuleInit } from '@nestjs/common'; import { AssetModule } from './api-v1/asset/asset.module'; import { ConfigModule } from '@nestjs/config'; import { ServerInfoModule } from './api-v1/server-info/server-info.module'; @@ -9,13 +9,14 @@ import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module'; import { JobModule } from './api-v1/job/job.module'; import { TagModule } from './api-v1/tag/tag.module'; -import { DomainModule } from '@app/domain'; +import { DomainModule, SearchService } from '@app/domain'; import { InfraModule } from '@app/infra'; import { APIKeyController, AuthController, DeviceInfoController, OAuthController, + SearchController, ShareController, SystemConfigController, UserController, @@ -46,16 +47,21 @@ import { AuthGuard } from './middlewares/auth.guard'; TagModule, ], controllers: [ - // AppController, APIKeyController, AuthController, DeviceInfoController, OAuthController, + SearchController, ShareController, SystemConfigController, UserController, ], providers: [{ provide: APP_GUARD, useExisting: AuthGuard }, AuthGuard], }) -export class AppModule {} +export class AppModule implements OnModuleInit { + constructor(private searchService: SearchService) {} + async onModuleInit() { + await this.searchService.bootstrap(); + } +} diff --git a/server/apps/immich/src/controllers/index.ts b/server/apps/immich/src/controllers/index.ts index d09e5687d..171a0debb 100644 --- a/server/apps/immich/src/controllers/index.ts +++ b/server/apps/immich/src/controllers/index.ts @@ -2,6 +2,7 @@ export * from './api-key.controller'; export * from './auth.controller'; export * from './device-info.controller'; export * from './oauth.controller'; +export * from './search.controller'; export * from './share.controller'; export * from './system-config.controller'; export * from './user.controller'; diff --git a/server/apps/immich/src/controllers/search.controller.ts b/server/apps/immich/src/controllers/search.controller.ts new file mode 100644 index 000000000..7f67927cf --- /dev/null +++ b/server/apps/immich/src/controllers/search.controller.ts @@ -0,0 +1,27 @@ +import { AuthUserDto, SearchConfigResponseDto, SearchDto, SearchResponseDto, SearchService } from '@app/domain'; +import { Controller, Get, Query, ValidationPipe } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { GetAuthUser } from '../decorators/auth-user.decorator'; +import { Authenticated } from '../decorators/authenticated.decorator'; + +@ApiTags('Search') +@Authenticated() +@Controller('search') +export class SearchController { + constructor(private readonly searchService: SearchService) {} + + @Authenticated() + @Get() + async search( + @GetAuthUser() authUser: AuthUserDto, + @Query(new ValidationPipe({ transform: true })) dto: SearchDto, + ): Promise { + return this.searchService.search(authUser, dto); + } + + @Authenticated() + @Get('config') + getSearchConfig(): SearchConfigResponseDto { + return this.searchService.getConfig(); + } +} diff --git a/server/apps/immich/src/main.ts b/server/apps/immich/src/main.ts index 185ae2b18..b90df5e23 100644 --- a/server/apps/immich/src/main.ts +++ b/server/apps/immich/src/main.ts @@ -11,7 +11,7 @@ import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware'; import { json } from 'body-parser'; import { patchOpenAPI } from './utils/patch-open-api.util'; import { getLogLevels, MACHINE_LEARNING_ENABLED } from '@app/common'; -import { IMMICH_ACCESS_COOKIE } from '@app/domain'; +import { IMMICH_ACCESS_COOKIE, SearchService } from '@app/domain'; const logger = new Logger('ImmichServer'); @@ -73,6 +73,9 @@ async function bootstrap() { ); }); + const searchService = app.get(SearchService); + logger.warn(`Machine learning is ${MACHINE_LEARNING_ENABLED ? 'enabled' : 'disabled'}`); + logger.warn(`Search is ${searchService.isEnabled() ? 'enabled' : 'disabled'}`); } bootstrap(); diff --git a/server/apps/microservices/src/microservices.module.ts b/server/apps/microservices/src/microservices.module.ts index 845f6c158..68b755af6 100644 --- a/server/apps/microservices/src/microservices.module.ts +++ b/server/apps/microservices/src/microservices.module.ts @@ -7,6 +7,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { BackgroundTaskProcessor, MachineLearningProcessor, + SearchIndexProcessor, StorageTemplateMigrationProcessor, ThumbnailGeneratorProcessor, } from './processors'; @@ -26,6 +27,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor' MachineLearningProcessor, StorageTemplateMigrationProcessor, BackgroundTaskProcessor, + SearchIndexProcessor, ], }) export class MicroservicesModule {} diff --git a/server/apps/microservices/src/processors.ts b/server/apps/microservices/src/processors.ts index 00e88b90d..63f05aed1 100644 --- a/server/apps/microservices/src/processors.ts +++ b/server/apps/microservices/src/processors.ts @@ -1,12 +1,15 @@ import { AssetService, + IAlbumJob, IAssetJob, IAssetUploadedJob, IDeleteFilesJob, + IDeleteJob, IUserDeletionJob, JobName, MediaService, QueueName, + SearchService, SmartInfoService, StorageService, StorageTemplateService, @@ -61,6 +64,41 @@ export class MachineLearningProcessor { } } +@Processor(QueueName.SEARCH) +export class SearchIndexProcessor { + constructor(private searchService: SearchService) {} + + @Process(JobName.SEARCH_INDEX_ALBUMS) + async onIndexAlbums() { + await this.searchService.handleIndexAlbums(); + } + + @Process(JobName.SEARCH_INDEX_ASSETS) + async onIndexAssets() { + await this.searchService.handleIndexAssets(); + } + + @Process(JobName.SEARCH_INDEX_ALBUM) + async onIndexAlbum(job: Job) { + await this.searchService.handleIndexAlbum(job.data); + } + + @Process(JobName.SEARCH_INDEX_ASSET) + async onIndexAsset(job: Job) { + await this.searchService.handleIndexAsset(job.data); + } + + @Process(JobName.SEARCH_REMOVE_ALBUM) + async onRemoveAlbum(job: Job) { + await this.searchService.handleRemoveAlbum(job.data); + } + + @Process(JobName.SEARCH_REMOVE_ASSET) + async onRemoveAsset(job: Job) { + await this.searchService.handleRemoveAsset(job.data); + } +} + @Processor(QueueName.STORAGE_TEMPLATE_MIGRATION) export class StorageTemplateMigrationProcessor { constructor(private storageTemplateService: StorageTemplateService) {} diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index fb907538b..d6ac7a36d 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -1,18 +1,26 @@ +import { + AssetCore, + IAssetRepository, + IAssetUploadedJob, + IReverseGeocodingJob, + ISearchRepository, + JobName, + QueueName, +} from '@app/domain'; import { AssetEntity, AssetType, ExifEntity } from '@app/infra'; -import { IReverseGeocodingJob, IAssetUploadedJob, QueueName, JobName, IAssetRepository } from '@app/domain'; import { Process, Processor } from '@nestjs/bull'; import { Inject, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Job } from 'bull'; +import { ExifDateTime, exiftool, Tags } from 'exiftool-vendored'; import ffmpeg from 'fluent-ffmpeg'; +import { getName } from 'i18n-iso-countries'; +import geocoder, { InitOptions } from 'local-reverse-geocoder'; +import fs from 'node:fs'; import path from 'path'; import sharp from 'sharp'; import { Repository } from 'typeorm/repository/Repository'; -import geocoder, { InitOptions } from 'local-reverse-geocoder'; -import { getName } from 'i18n-iso-countries'; -import fs from 'node:fs'; -import { ExifDateTime, exiftool, Tags } from 'exiftool-vendored'; interface ImmichTags extends Tags { ContentIdentifier?: string; @@ -71,13 +79,19 @@ export type GeoData = { export class MetadataExtractionProcessor { private logger = new Logger(MetadataExtractionProcessor.name); private isGeocodeInitialized = false; + private assetCore: AssetCore; + constructor( - @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(IAssetRepository) assetRepository: IAssetRepository, + @Inject(ISearchRepository) searchRepository: ISearchRepository, + @InjectRepository(ExifEntity) private exifRepository: Repository, configService: ConfigService, ) { + this.assetCore = new AssetCore(assetRepository, searchRepository); + if (!configService.get('DISABLE_REVERSE_GEOCODING')) { this.logger.log('Initializing Reverse Geocoding'); geocoderInit({ @@ -175,20 +189,11 @@ export class MetadataExtractionProcessor { newExif.longitude = exifData?.GPSLongitude || null; newExif.livePhotoCID = exifData?.MediaGroupUUID || null; - await this.assetRepository.save({ - id: asset.id, - fileCreatedAt: fileCreatedAt?.toISOString(), - }); - if (newExif.livePhotoCID && !asset.livePhotoVideoId) { - const motionAsset = await this.assetRepository.findLivePhotoMatch( - newExif.livePhotoCID, - asset.id, - AssetType.VIDEO, - ); + const motionAsset = await this.assetCore.findLivePhotoMatch(newExif.livePhotoCID, asset.id, AssetType.VIDEO); if (motionAsset) { - await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id }); - await this.assetRepository.save({ id: motionAsset.id, isVisible: false }); + await this.assetCore.save({ id: asset.id, livePhotoVideoId: motionAsset.id }); + await this.assetCore.save({ id: motionAsset.id, isVisible: false }); } } @@ -226,6 +231,7 @@ export class MetadataExtractionProcessor { } await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] }); + await this.assetCore.save({ id: asset.id, fileCreatedAt: fileCreatedAt?.toISOString() }); } catch (error: any) { this.logger.error(`Error extracting EXIF ${error}`, error?.stack); } @@ -292,14 +298,10 @@ export class MetadataExtractionProcessor { newExif.livePhotoCID = exifData?.ContentIdentifier || null; if (newExif.livePhotoCID) { - const photoAsset = await this.assetRepository.findLivePhotoMatch( - newExif.livePhotoCID, - asset.id, - AssetType.IMAGE, - ); + const photoAsset = await this.assetCore.findLivePhotoMatch(newExif.livePhotoCID, asset.id, AssetType.IMAGE); if (photoAsset) { - await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: asset.id }); - await this.assetRepository.save({ id: asset.id, isVisible: false }); + await this.assetCore.save({ id: photoAsset.id, livePhotoVideoId: asset.id }); + await this.assetCore.save({ id: asset.id, isVisible: false }); } } @@ -355,7 +357,7 @@ export class MetadataExtractionProcessor { } await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] }); - await this.assetRepository.save({ id: asset.id, duration: durationString, fileCreatedAt }); + await this.assetCore.save({ id: asset.id, duration: durationString, fileCreatedAt }); } catch (err) { ``; // do nothing diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 4cd07f69f..ee8bc8d1d 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -544,6 +544,171 @@ ] } }, + "/search": { + "get": { + "operationId": "search", + "description": "", + "parameters": [ + { + "name": "query", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "enum": [ + "IMAGE", + "VIDEO", + "AUDIO", + "OTHER" + ], + "type": "string" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "exifInfo.city", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "exifInfo.state", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "exifInfo.country", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "exifInfo.make", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "exifInfo.model", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "smartInfo.objects", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "smartInfo.tags", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponseDto" + } + } + } + } + }, + "tags": [ + "Search" + ], + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "bearer": [] + }, + { + "cookie": [] + } + ] + } + }, + "/search/config": { + "get": { + "operationId": "getSearchConfig", + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchConfigResponseDto" + } + } + } + } + }, + "tags": [ + "Search" + ], + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "bearer": [] + }, + { + "cookie": [] + } + ] + } + }, "/share": { "get": { "operationId": "getAllSharedLinks", @@ -3554,13 +3719,6 @@ "url" ] }, - "SharedLinkType": { - "type": "string", - "enum": [ - "ALBUM", - "INDIVIDUAL" - ] - }, "AssetTypeEnum": { "type": "string", "enum": [ @@ -3871,6 +4029,130 @@ "owner" ] }, + "SearchFacetCountResponseDto": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "value": { + "type": "string" + } + }, + "required": [ + "count", + "value" + ] + }, + "SearchFacetResponseDto": { + "type": "object", + "properties": { + "fieldName": { + "type": "string" + }, + "counts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchFacetCountResponseDto" + } + } + }, + "required": [ + "fieldName", + "counts" + ] + }, + "SearchAlbumResponseDto": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AlbumResponseDto" + } + }, + "facets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchFacetResponseDto" + } + } + }, + "required": [ + "total", + "count", + "items", + "facets" + ] + }, + "SearchAssetResponseDto": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + } + }, + "facets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchFacetResponseDto" + } + } + }, + "required": [ + "total", + "count", + "items", + "facets" + ] + }, + "SearchResponseDto": { + "type": "object", + "properties": { + "albums": { + "$ref": "#/components/schemas/SearchAlbumResponseDto" + }, + "assets": { + "$ref": "#/components/schemas/SearchAssetResponseDto" + } + }, + "required": [ + "albums", + "assets" + ] + }, + "SearchConfigResponseDto": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ] + }, + "SharedLinkType": { + "type": "string", + "enum": [ + "ALBUM", + "INDIVIDUAL" + ] + }, "SharedLinkResponseDto": { "type": "object", "properties": { diff --git a/server/libs/common/src/config/app.config.ts b/server/libs/common/src/config/app.config.ts index db619b32d..a32d90f9f 100644 --- a/server/libs/common/src/config/app.config.ts +++ b/server/libs/common/src/config/app.config.ts @@ -16,6 +16,11 @@ export const immichAppConfig: ConfigModuleOptions = { DB_PASSWORD: WHEN_DB_URL_SET, DB_DATABASE_NAME: WHEN_DB_URL_SET, DB_URL: Joi.string().optional(), + TYPESENSE_API_KEY: Joi.when('TYPESENSE_ENABLED', { + is: 'false', + then: Joi.string().optional(), + otherwise: Joi.string().required(), + }), DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false), REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3), LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose', 'debug', 'log', 'warn', 'error').default('log'), diff --git a/server/libs/domain/src/album/album.repository.ts b/server/libs/domain/src/album/album.repository.ts index 92137b13d..adc62ea97 100644 --- a/server/libs/domain/src/album/album.repository.ts +++ b/server/libs/domain/src/album/album.repository.ts @@ -1,5 +1,9 @@ +import { AlbumEntity } from '@app/infra/db/entities'; + export const IAlbumRepository = 'IAlbumRepository'; export interface IAlbumRepository { deleteAll(userId: string): Promise; + getAll(): Promise; + save(album: Partial): Promise; } diff --git a/server/libs/domain/src/asset/asset.core.ts b/server/libs/domain/src/asset/asset.core.ts new file mode 100644 index 000000000..e923f29d9 --- /dev/null +++ b/server/libs/domain/src/asset/asset.core.ts @@ -0,0 +1,21 @@ +import { AssetEntity, AssetType } from '@app/infra/db/entities'; +import { ISearchRepository, SearchCollection } from '../search/search.repository'; +import { AssetSearchOptions, IAssetRepository } from './asset.repository'; + +export class AssetCore { + constructor(private repository: IAssetRepository, private searchRepository: ISearchRepository) {} + + getAll(options: AssetSearchOptions) { + return this.repository.getAll(options); + } + + async save(asset: Partial) { + const _asset = await this.repository.save(asset); + await this.searchRepository.index(SearchCollection.ASSETS, _asset); + return _asset; + } + + findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise { + return this.repository.findLivePhotoMatch(livePhotoCID, otherAssetId, type); + } +} diff --git a/server/libs/domain/src/asset/asset.repository.ts b/server/libs/domain/src/asset/asset.repository.ts index 4e5600b8d..0173cc4ee 100644 --- a/server/libs/domain/src/asset/asset.repository.ts +++ b/server/libs/domain/src/asset/asset.repository.ts @@ -1,10 +1,14 @@ import { AssetEntity, AssetType } from '@app/infra/db/entities'; +export interface AssetSearchOptions { + isVisible?: boolean; +} + export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { deleteAll(ownerId: string): Promise; - getAll(): Promise; + getAll(options?: AssetSearchOptions): Promise; save(asset: Partial): Promise; findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise; } diff --git a/server/libs/domain/src/asset/asset.service.spec.ts b/server/libs/domain/src/asset/asset.service.spec.ts index 75f26eae2..bff4efa20 100644 --- a/server/libs/domain/src/asset/asset.service.spec.ts +++ b/server/libs/domain/src/asset/asset.service.spec.ts @@ -1,19 +1,25 @@ import { AssetEntity, AssetType } from '@app/infra/db/entities'; -import { newJobRepositoryMock } from '../../test'; -import { AssetService } from '../asset'; +import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test'; +import { newSearchRepositoryMock } from '../../test/search.repository.mock'; +import { AssetService, IAssetRepository } from '../asset'; import { IJobRepository, JobName } from '../job'; +import { ISearchRepository } from '../search'; describe(AssetService.name, () => { let sut: AssetService; + let assetMock: jest.Mocked; let jobMock: jest.Mocked; + let searchMock: jest.Mocked; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(async () => { + assetMock = newAssetRepositoryMock(); jobMock = newJobRepositoryMock(); - sut = new AssetService(jobMock); + searchMock = newSearchRepositoryMock(); + sut = new AssetService(assetMock, jobMock, searchMock); }); describe(`handle asset upload`, () => { @@ -42,4 +48,15 @@ describe(AssetService.name, () => { ]); }); }); + + describe('save', () => { + it('should save an asset', async () => { + assetMock.save.mockResolvedValue(assetEntityStub.image); + + await sut.save(assetEntityStub.image); + + expect(assetMock.save).toHaveBeenCalledWith(assetEntityStub.image); + expect(searchMock.index).toHaveBeenCalledWith('assets', assetEntityStub.image); + }); + }); }); diff --git a/server/libs/domain/src/asset/asset.service.ts b/server/libs/domain/src/asset/asset.service.ts index 023fb960d..06e8c7aa9 100644 --- a/server/libs/domain/src/asset/asset.service.ts +++ b/server/libs/domain/src/asset/asset.service.ts @@ -1,9 +1,20 @@ -import { AssetType } from '@app/infra/db/entities'; +import { AssetEntity, AssetType } from '@app/infra/db/entities'; import { Inject } from '@nestjs/common'; import { IAssetUploadedJob, IJobRepository, JobName } from '../job'; +import { ISearchRepository } from '../search'; +import { AssetCore } from './asset.core'; +import { IAssetRepository } from './asset.repository'; export class AssetService { - constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {} + private assetCore: AssetCore; + + constructor( + @Inject(IAssetRepository) assetRepository: IAssetRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(ISearchRepository) searchRepository: ISearchRepository, + ) { + this.assetCore = new AssetCore(assetRepository, searchRepository); + } async handleAssetUpload(data: IAssetUploadedJob) { await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data }); @@ -15,4 +26,8 @@ export class AssetService { await this.jobRepository.queue({ name: JobName.EXIF_EXTRACTION, data }); } } + + save(asset: Partial) { + return this.assetCore.save(asset); + } } diff --git a/server/libs/domain/src/asset/index.ts b/server/libs/domain/src/asset/index.ts index aa429787d..3aff64c2c 100644 --- a/server/libs/domain/src/asset/index.ts +++ b/server/libs/domain/src/asset/index.ts @@ -1,3 +1,4 @@ +export * from './asset.core'; export * from './asset.repository'; export * from './asset.service'; export * from './response-dto'; diff --git a/server/libs/domain/src/domain.module.ts b/server/libs/domain/src/domain.module.ts index d3faad973..c469a2bc5 100644 --- a/server/libs/domain/src/domain.module.ts +++ b/server/libs/domain/src/domain.module.ts @@ -5,6 +5,7 @@ import { AuthService } from './auth'; import { DeviceInfoService } from './device-info'; import { MediaService } from './media'; import { OAuthService } from './oauth'; +import { SearchService } from './search'; import { ShareService } from './share'; import { SmartInfoService } from './smart-info'; import { StorageService } from './storage'; @@ -25,6 +26,7 @@ const providers: Provider[] = [ SystemConfigService, UserService, ShareService, + SearchService, { provide: INITIAL_SYSTEM_CONFIG, inject: [SystemConfigService], diff --git a/server/libs/domain/src/index.ts b/server/libs/domain/src/index.ts index 93768f682..cf4403aed 100644 --- a/server/libs/domain/src/index.ts +++ b/server/libs/domain/src/index.ts @@ -9,6 +9,7 @@ export * from './domain.module'; export * from './job'; export * from './media'; export * from './oauth'; +export * from './search'; export * from './share'; export * from './smart-info'; export * from './storage'; diff --git a/server/libs/domain/src/job/job.constants.ts b/server/libs/domain/src/job/job.constants.ts index 13939e17f..52ee42572 100644 --- a/server/libs/domain/src/job/job.constants.ts +++ b/server/libs/domain/src/job/job.constants.ts @@ -5,6 +5,7 @@ export enum QueueName { MACHINE_LEARNING = 'machine-learning-queue', BACKGROUND_TASK = 'background-task', STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue', + SEARCH = 'search-queue', } export enum JobName { @@ -22,4 +23,10 @@ export enum JobName { OBJECT_DETECTION = 'detect-object', IMAGE_TAGGING = 'tag-image', DELETE_FILES = 'delete-files', + SEARCH_INDEX_ASSETS = 'search-index-assets', + SEARCH_INDEX_ASSET = 'search-index-asset', + SEARCH_INDEX_ALBUMS = 'search-index-albums', + SEARCH_INDEX_ALBUM = 'search-index-album', + SEARCH_REMOVE_ALBUM = 'search-remove-album', + SEARCH_REMOVE_ASSET = 'search-remove-asset', } diff --git a/server/libs/domain/src/job/job.interface.ts b/server/libs/domain/src/job/job.interface.ts index e52b1d879..0810bdad0 100644 --- a/server/libs/domain/src/job/job.interface.ts +++ b/server/libs/domain/src/job/job.interface.ts @@ -1,4 +1,8 @@ -import { AssetEntity, UserEntity } from '@app/infra/db/entities'; +import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/db/entities'; + +export interface IAlbumJob { + album: AlbumEntity; +} export interface IAssetJob { asset: AssetEntity; @@ -9,6 +13,10 @@ export interface IAssetUploadedJob { fileName: string; } +export interface IDeleteJob { + id: string; +} + export interface IDeleteFilesJob { files: Array; } diff --git a/server/libs/domain/src/job/job.repository.ts b/server/libs/domain/src/job/job.repository.ts index f06c791a3..0867f5391 100644 --- a/server/libs/domain/src/job/job.repository.ts +++ b/server/libs/domain/src/job/job.repository.ts @@ -1,5 +1,13 @@ import { JobName, QueueName } from './job.constants'; -import { IAssetJob, IAssetUploadedJob, IDeleteFilesJob, IReverseGeocodingJob, IUserDeletionJob } from './job.interface'; +import { + IAlbumJob, + IAssetJob, + IAssetUploadedJob, + IDeleteFilesJob, + IDeleteJob, + IReverseGeocodingJob, + IUserDeletionJob, +} from './job.interface'; export interface JobCounts { active: number; @@ -23,7 +31,13 @@ export type JobItem = | { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetUploadedJob } | { name: JobName.OBJECT_DETECTION; data: IAssetJob } | { name: JobName.IMAGE_TAGGING; data: IAssetJob } - | { name: JobName.DELETE_FILES; data: IDeleteFilesJob }; + | { name: JobName.DELETE_FILES; data: IDeleteFilesJob } + | { name: JobName.SEARCH_INDEX_ASSETS } + | { name: JobName.SEARCH_INDEX_ASSET; data: IAssetJob } + | { name: JobName.SEARCH_INDEX_ALBUMS } + | { name: JobName.SEARCH_INDEX_ALBUM; data: IAlbumJob } + | { name: JobName.SEARCH_REMOVE_ASSET; data: IDeleteJob } + | { name: JobName.SEARCH_REMOVE_ALBUM; data: IDeleteJob }; export const IJobRepository = 'IJobRepository'; diff --git a/server/libs/domain/src/search/dto/index.ts b/server/libs/domain/src/search/dto/index.ts new file mode 100644 index 000000000..cd914d0ea --- /dev/null +++ b/server/libs/domain/src/search/dto/index.ts @@ -0,0 +1 @@ +export * from './search.dto'; diff --git a/server/libs/domain/src/search/dto/search.dto.ts b/server/libs/domain/src/search/dto/search.dto.ts new file mode 100644 index 000000000..c080ff5ea --- /dev/null +++ b/server/libs/domain/src/search/dto/search.dto.ts @@ -0,0 +1,57 @@ +import { AssetType } from '@app/infra/db/entities'; +import { Transform } from 'class-transformer'; +import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { toBoolean } from '../../../../../apps/immich/src/utils/transform.util'; + +export class SearchDto { + @IsString() + @IsNotEmpty() + @IsOptional() + query?: string; + + @IsEnum(AssetType) + @IsOptional() + type?: AssetType; + + @IsBoolean() + @IsOptional() + @Transform(toBoolean) + isFavorite?: boolean; + + @IsString() + @IsNotEmpty() + @IsOptional() + 'exifInfo.city'?: string; + + @IsString() + @IsNotEmpty() + @IsOptional() + 'exifInfo.state'?: string; + + @IsString() + @IsNotEmpty() + @IsOptional() + 'exifInfo.country'?: string; + + @IsString() + @IsNotEmpty() + @IsOptional() + 'exifInfo.make'?: string; + + @IsString() + @IsNotEmpty() + @IsOptional() + 'exifInfo.model'?: string; + + @IsString({ each: true }) + @IsArray() + @IsOptional() + @Transform(({ value }) => value.split(',')) + 'smartInfo.objects'?: string[]; + + @IsString({ each: true }) + @IsArray() + @IsOptional() + @Transform(({ value }) => value.split(',')) + 'smartInfo.tags'?: string[]; +} diff --git a/server/libs/domain/src/search/index.ts b/server/libs/domain/src/search/index.ts new file mode 100644 index 000000000..173a67d76 --- /dev/null +++ b/server/libs/domain/src/search/index.ts @@ -0,0 +1,4 @@ +export * from './dto'; +export * from './response-dto'; +export * from './search.repository'; +export * from './search.service'; diff --git a/server/libs/domain/src/search/response-dto/index.ts b/server/libs/domain/src/search/response-dto/index.ts new file mode 100644 index 000000000..e55378686 --- /dev/null +++ b/server/libs/domain/src/search/response-dto/index.ts @@ -0,0 +1,2 @@ +export * from './search-config-response.dto'; +export * from './search-response.dto'; diff --git a/server/libs/domain/src/search/response-dto/search-config-response.dto.ts b/server/libs/domain/src/search/response-dto/search-config-response.dto.ts new file mode 100644 index 000000000..9f2f37958 --- /dev/null +++ b/server/libs/domain/src/search/response-dto/search-config-response.dto.ts @@ -0,0 +1,3 @@ +export class SearchConfigResponseDto { + enabled!: boolean; +} diff --git a/server/libs/domain/src/search/response-dto/search-response.dto.ts b/server/libs/domain/src/search/response-dto/search-response.dto.ts new file mode 100644 index 000000000..724cd5854 --- /dev/null +++ b/server/libs/domain/src/search/response-dto/search-response.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { AlbumResponseDto } from '../../album'; +import { AssetResponseDto } from '../../asset'; + +class SearchFacetCountResponseDto { + @ApiProperty({ type: 'integer' }) + count!: number; + value!: string; +} + +class SearchFacetResponseDto { + fieldName!: string; + counts!: SearchFacetCountResponseDto[]; +} + +class SearchAlbumResponseDto { + @ApiProperty({ type: 'integer' }) + total!: number; + @ApiProperty({ type: 'integer' }) + count!: number; + items!: AlbumResponseDto[]; + facets!: SearchFacetResponseDto[]; +} + +class SearchAssetResponseDto { + @ApiProperty({ type: 'integer' }) + total!: number; + @ApiProperty({ type: 'integer' }) + count!: number; + items!: AssetResponseDto[]; + facets!: SearchFacetResponseDto[]; +} + +export class SearchResponseDto { + albums!: SearchAlbumResponseDto; + assets!: SearchAssetResponseDto; +} diff --git a/server/libs/domain/src/search/search.repository.ts b/server/libs/domain/src/search/search.repository.ts new file mode 100644 index 000000000..f28857850 --- /dev/null +++ b/server/libs/domain/src/search/search.repository.ts @@ -0,0 +1,60 @@ +import { AlbumEntity, AssetEntity, AssetType } from '@app/infra/db/entities'; + +export enum SearchCollection { + ASSETS = 'assets', + ALBUMS = 'albums', +} + +export interface SearchFilter { + id?: string; + userId: string; + type?: AssetType; + isFavorite?: boolean; + city?: string; + state?: string; + country?: string; + make?: string; + model?: string; + objects?: string[]; + tags?: string[]; +} + +export interface SearchResult { + /** total matches */ + total: number; + /** collection size */ + count: number; + /** current page */ + page: number; + /** items for page */ + items: T[]; + facets: SearchFacet[]; +} + +export interface SearchFacet { + fieldName: string; + counts: Array<{ + count: number; + value: string; + }>; +} + +export type SearchCollectionIndexStatus = Record; + +export const ISearchRepository = 'ISearchRepository'; + +export interface ISearchRepository { + setup(): Promise; + checkMigrationStatus(): Promise; + + index(collection: SearchCollection.ASSETS, item: AssetEntity): Promise; + index(collection: SearchCollection.ALBUMS, item: AlbumEntity): Promise; + + delete(collection: SearchCollection, id: string): Promise; + + import(collection: SearchCollection.ASSETS, items: AssetEntity[], done: boolean): Promise; + import(collection: SearchCollection.ALBUMS, items: AlbumEntity[], done: boolean): Promise; + + search(collection: SearchCollection.ASSETS, query: string, filters: SearchFilter): Promise>; + search(collection: SearchCollection.ALBUMS, query: string, filters: SearchFilter): Promise>; +} diff --git a/server/libs/domain/src/search/search.service.spec.ts b/server/libs/domain/src/search/search.service.spec.ts new file mode 100644 index 000000000..813091f8d --- /dev/null +++ b/server/libs/domain/src/search/search.service.spec.ts @@ -0,0 +1,317 @@ +import { BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { plainToInstance } from 'class-transformer'; +import { + albumStub, + assetEntityStub, + authStub, + newAlbumRepositoryMock, + newAssetRepositoryMock, + newJobRepositoryMock, + newSearchRepositoryMock, +} from '../../test'; +import { IAlbumRepository } from '../album/album.repository'; +import { IAssetRepository } from '../asset/asset.repository'; +import { JobName } from '../job'; +import { IJobRepository } from '../job/job.repository'; +import { SearchDto } from './dto'; +import { ISearchRepository } from './search.repository'; +import { SearchService } from './search.service'; + +describe(SearchService.name, () => { + let sut: SearchService; + let albumMock: jest.Mocked; + let assetMock: jest.Mocked; + let jobMock: jest.Mocked; + let searchMock: jest.Mocked; + let configMock: jest.Mocked; + + beforeEach(() => { + albumMock = newAlbumRepositoryMock(); + assetMock = newAssetRepositoryMock(); + jobMock = newJobRepositoryMock(); + searchMock = newSearchRepositoryMock(); + configMock = { get: jest.fn() } as unknown as jest.Mocked; + + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('request dto', () => { + it('should convert smartInfo.tags to a string list', () => { + const instance = plainToInstance(SearchDto, { 'smartInfo.tags': 'a,b,c' }); + expect(instance['smartInfo.tags']).toEqual(['a', 'b', 'c']); + }); + + it('should handle empty smartInfo.tags', () => { + const instance = plainToInstance(SearchDto, {}); + expect(instance['smartInfo.tags']).toBeUndefined(); + }); + + it('should convert smartInfo.objects to a string list', () => { + const instance = plainToInstance(SearchDto, { 'smartInfo.objects': 'a,b,c' }); + expect(instance['smartInfo.objects']).toEqual(['a', 'b', 'c']); + }); + + it('should handle empty smartInfo.objects', () => { + const instance = plainToInstance(SearchDto, {}); + expect(instance['smartInfo.objects']).toBeUndefined(); + }); + }); + + describe('isEnabled', () => { + it('should be enabled by default', () => { + expect(sut.isEnabled()).toBe(true); + }); + + it('should be disabled via an env variable', () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + expect(sut.isEnabled()).toBe(false); + }); + }); + + describe('getConfig', () => { + it('should return the config', () => { + expect(sut.getConfig()).toEqual({ enabled: true }); + }); + + it('should return the config when search is disabled', () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + expect(sut.getConfig()).toEqual({ enabled: false }); + }); + }); + + describe(`bootstrap`, () => { + it('should skip when search is disabled', async () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + await sut.bootstrap(); + + expect(searchMock.setup).not.toHaveBeenCalled(); + expect(searchMock.checkMigrationStatus).not.toHaveBeenCalled(); + expect(jobMock.queue).not.toHaveBeenCalled(); + }); + + it('should skip schema migration if not needed', async () => { + searchMock.checkMigrationStatus.mockResolvedValue({ assets: false, albums: false }); + await sut.bootstrap(); + + expect(searchMock.setup).toHaveBeenCalled(); + expect(jobMock.queue).not.toHaveBeenCalled(); + }); + + it('should do schema migration if needed', async () => { + searchMock.checkMigrationStatus.mockResolvedValue({ assets: true, albums: true }); + await sut.bootstrap(); + + expect(searchMock.setup).toHaveBeenCalled(); + expect(jobMock.queue.mock.calls).toEqual([ + [{ name: JobName.SEARCH_INDEX_ASSETS }], + [{ name: JobName.SEARCH_INDEX_ALBUMS }], + ]); + }); + }); + + describe('search', () => { + it('should throw an error is search is disabled', async () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + await expect(sut.search(authStub.admin, {})).rejects.toBeInstanceOf(BadRequestException); + + expect(searchMock.search).not.toHaveBeenCalled(); + }); + + it('should search assets and albums', async () => { + searchMock.search.mockResolvedValue({ + total: 0, + count: 0, + page: 1, + items: [], + facets: [], + }); + + await expect(sut.search(authStub.admin, {})).resolves.toEqual({ + albums: { + total: 0, + count: 0, + page: 1, + items: [], + facets: [], + }, + assets: { + total: 0, + count: 0, + page: 1, + items: [], + facets: [], + }, + }); + + expect(searchMock.search.mock.calls).toEqual([ + ['assets', '*', { userId: authStub.admin.id }], + ['albums', '*', { userId: authStub.admin.id }], + ]); + }); + }); + + describe('handleIndexAssets', () => { + it('should skip if search is disabled', async () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + await sut.handleIndexAssets(); + + expect(searchMock.import).not.toHaveBeenCalled(); + }); + + it('should index all the assets', async () => { + assetMock.getAll.mockResolvedValue([]); + + await sut.handleIndexAssets(); + + expect(searchMock.import).toHaveBeenCalledWith('assets', [], true); + }); + + it('should log an error', async () => { + assetMock.getAll.mockResolvedValue([]); + searchMock.import.mockRejectedValue(new Error('import failed')); + + await sut.handleIndexAssets(); + }); + }); + + describe('handleIndexAsset', () => { + it('should skip if search is disabled', async () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + await sut.handleIndexAsset({ asset: assetEntityStub.image }); + + expect(searchMock.index).not.toHaveBeenCalled(); + }); + + it('should index the asset', async () => { + await sut.handleIndexAsset({ asset: assetEntityStub.image }); + + expect(searchMock.index).toHaveBeenCalledWith('assets', assetEntityStub.image); + }); + + it('should log an error', async () => { + searchMock.index.mockRejectedValue(new Error('index failed')); + + await sut.handleIndexAsset({ asset: assetEntityStub.image }); + + expect(searchMock.index).toHaveBeenCalled(); + }); + }); + + describe('handleIndexAlbums', () => { + it('should skip if search is disabled', async () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + await sut.handleIndexAlbums(); + + expect(searchMock.import).not.toHaveBeenCalled(); + }); + + it('should index all the albums', async () => { + albumMock.getAll.mockResolvedValue([]); + + await sut.handleIndexAlbums(); + + expect(searchMock.import).toHaveBeenCalledWith('albums', [], true); + }); + + it('should log an error', async () => { + albumMock.getAll.mockResolvedValue([]); + searchMock.import.mockRejectedValue(new Error('import failed')); + + await sut.handleIndexAlbums(); + }); + }); + + describe('handleIndexAlbum', () => { + it('should skip if search is disabled', async () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + await sut.handleIndexAlbum({ album: albumStub.empty }); + + expect(searchMock.index).not.toHaveBeenCalled(); + }); + + it('should index the album', async () => { + await sut.handleIndexAlbum({ album: albumStub.empty }); + + expect(searchMock.index).toHaveBeenCalledWith('albums', albumStub.empty); + }); + + it('should log an error', async () => { + searchMock.index.mockRejectedValue(new Error('index failed')); + + await sut.handleIndexAlbum({ album: albumStub.empty }); + + expect(searchMock.index).toHaveBeenCalled(); + }); + }); + + describe('handleRemoveAlbum', () => { + it('should skip if search is disabled', async () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + await sut.handleRemoveAlbum({ id: 'album1' }); + + expect(searchMock.delete).not.toHaveBeenCalled(); + }); + + it('should remove the album', async () => { + await sut.handleRemoveAlbum({ id: 'album1' }); + + expect(searchMock.delete).toHaveBeenCalledWith('albums', 'album1'); + }); + + it('should log an error', async () => { + searchMock.delete.mockRejectedValue(new Error('remove failed')); + + await sut.handleRemoveAlbum({ id: 'album1' }); + + expect(searchMock.delete).toHaveBeenCalled(); + }); + }); + + describe('handleRemoveAsset', () => { + it('should skip if search is disabled', async () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + await sut.handleRemoveAsset({ id: 'asset1`' }); + + expect(searchMock.delete).not.toHaveBeenCalled(); + }); + + it('should remove the asset', async () => { + await sut.handleRemoveAsset({ id: 'asset1' }); + + expect(searchMock.delete).toHaveBeenCalledWith('assets', 'asset1'); + }); + + it('should log an error', async () => { + searchMock.delete.mockRejectedValue(new Error('remove failed')); + + await sut.handleRemoveAsset({ id: 'asset1' }); + + expect(searchMock.delete).toHaveBeenCalled(); + }); + }); +}); diff --git a/server/libs/domain/src/search/search.service.ts b/server/libs/domain/src/search/search.service.ts new file mode 100644 index 000000000..322644167 --- /dev/null +++ b/server/libs/domain/src/search/search.service.ts @@ -0,0 +1,154 @@ +import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { IAlbumRepository } from '../album/album.repository'; +import { IAssetRepository } from '../asset/asset.repository'; +import { AuthUserDto } from '../auth'; +import { IAlbumJob, IAssetJob, IDeleteJob, IJobRepository, JobName } from '../job'; +import { SearchDto } from './dto'; +import { SearchConfigResponseDto, SearchResponseDto } from './response-dto'; +import { ISearchRepository, SearchCollection } from './search.repository'; + +@Injectable() +export class SearchService { + private logger = new Logger(SearchService.name); + private enabled: boolean; + + constructor( + @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(ISearchRepository) private searchRepository: ISearchRepository, + configService: ConfigService, + ) { + this.enabled = configService.get('TYPESENSE_ENABLED') !== 'false'; + } + + isEnabled() { + return this.enabled; + } + + getConfig(): SearchConfigResponseDto { + return { + enabled: this.enabled, + }; + } + + async bootstrap() { + if (!this.enabled) { + return; + } + + this.logger.log('Running bootstrap'); + await this.searchRepository.setup(); + + const migrationStatus = await this.searchRepository.checkMigrationStatus(); + if (migrationStatus[SearchCollection.ASSETS]) { + this.logger.debug('Queueing job to re-index all assets'); + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSETS }); + } + if (migrationStatus[SearchCollection.ALBUMS]) { + this.logger.debug('Queueing job to re-index all albums'); + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUMS }); + } + } + + async search(authUser: AuthUserDto, dto: SearchDto): Promise { + if (!this.enabled) { + throw new BadRequestException('Search is disabled'); + } + + const query = dto.query || '*'; + + return { + assets: (await this.searchRepository.search(SearchCollection.ASSETS, query, { + userId: authUser.id, + ...dto, + })) as any, + albums: (await this.searchRepository.search(SearchCollection.ALBUMS, query, { + userId: authUser.id, + ...dto, + })) as any, + }; + } + + async handleIndexAssets() { + if (!this.enabled) { + return; + } + + try { + this.logger.debug(`Running indexAssets`); + // TODO: do this in batches based on searchIndexVersion + const assets = await this.assetRepository.getAll({ isVisible: true }); + + this.logger.log(`Indexing ${assets.length} assets`); + await this.searchRepository.import(SearchCollection.ASSETS, assets, true); + } catch (error: any) { + this.logger.error(`Unable to index all assets`, error?.stack); + } + } + + async handleIndexAsset(data: IAssetJob) { + if (!this.enabled) { + return; + } + + const { asset } = data; + + try { + await this.searchRepository.index(SearchCollection.ASSETS, asset); + } catch (error: any) { + this.logger.error(`Unable to index asset: ${asset.id}`, error?.stack); + } + } + + async handleIndexAlbums() { + if (!this.enabled) { + return; + } + + try { + const albums = await this.albumRepository.getAll(); + this.logger.log(`Indexing ${albums.length} albums`); + await this.searchRepository.import(SearchCollection.ALBUMS, albums, true); + } catch (error: any) { + this.logger.error(`Unable to index all albums`, error?.stack); + } + } + + async handleIndexAlbum(data: IAlbumJob) { + if (!this.enabled) { + return; + } + + const { album } = data; + + try { + await this.searchRepository.index(SearchCollection.ALBUMS, album); + } catch (error: any) { + this.logger.error(`Unable to index album: ${album.id}`, error?.stack); + } + } + + async handleRemoveAlbum(data: IDeleteJob) { + await this.handleRemove(SearchCollection.ALBUMS, data); + } + + async handleRemoveAsset(data: IDeleteJob) { + await this.handleRemove(SearchCollection.ASSETS, data); + } + + private async handleRemove(collection: SearchCollection, data: IDeleteJob) { + if (!this.enabled) { + return; + } + + const { id } = data; + + try { + await this.searchRepository.delete(collection, id); + } catch (error: any) { + this.logger.error(`Unable to remove ${collection}: ${id}`, error?.stack); + } + } +} diff --git a/server/libs/domain/test/album.repository.mock.ts b/server/libs/domain/test/album.repository.mock.ts index a240524ae..dc21e5ecb 100644 --- a/server/libs/domain/test/album.repository.mock.ts +++ b/server/libs/domain/test/album.repository.mock.ts @@ -3,5 +3,7 @@ import { IAlbumRepository } from '../src'; export const newAlbumRepositoryMock = (): jest.Mocked => { return { deleteAll: jest.fn(), + getAll: jest.fn(), + save: jest.fn(), }; }; diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index 73f822a7f..da2aa42a5 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -1,4 +1,5 @@ import { + AlbumEntity, APIKeyEntity, AssetEntity, AssetType, @@ -155,6 +156,21 @@ export const assetEntityStub = { } as AssetEntity), }; +export const albumStub = { + empty: Object.freeze({ + id: 'album-1', + albumName: 'Empty album', + ownerId: authStub.admin.id, + owner: userEntityStub.admin, + assets: [], + albumThumbnailAssetId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + sharedLinks: [], + sharedUsers: [], + }), +}; + const assetInfo: ExifResponseDto = { make: 'camera-make', model: 'camera-model', diff --git a/server/libs/domain/test/index.ts b/server/libs/domain/test/index.ts index d2ba46e72..aec1d0c15 100644 --- a/server/libs/domain/test/index.ts +++ b/server/libs/domain/test/index.ts @@ -6,6 +6,7 @@ export * from './device-info.repository.mock'; export * from './fixtures'; export * from './job.repository.mock'; export * from './machine-learning.repository.mock'; +export * from './search.repository.mock'; export * from './shared-link.repository.mock'; export * from './smart-info.repository.mock'; export * from './storage.repository.mock'; diff --git a/server/libs/domain/test/search.repository.mock.ts b/server/libs/domain/test/search.repository.mock.ts new file mode 100644 index 000000000..b1918f393 --- /dev/null +++ b/server/libs/domain/test/search.repository.mock.ts @@ -0,0 +1,12 @@ +import { ISearchRepository } from '../src'; + +export const newSearchRepositoryMock = (): jest.Mocked => { + return { + setup: jest.fn(), + checkMigrationStatus: jest.fn(), + index: jest.fn(), + import: jest.fn(), + search: jest.fn(), + delete: jest.fn(), + }; +}; diff --git a/server/libs/infra/src/db/repository/album.repository.ts b/server/libs/infra/src/db/repository/album.repository.ts index 1615d9957..d4eca4e50 100644 --- a/server/libs/infra/src/db/repository/album.repository.ts +++ b/server/libs/infra/src/db/repository/album.repository.ts @@ -11,4 +11,13 @@ export class AlbumRepository implements IAlbumRepository { async deleteAll(userId: string): Promise { await this.repository.delete({ ownerId: userId }); } + + getAll(): Promise { + return this.repository.find(); + } + + async save(album: Partial) { + const { id } = await this.repository.save(album); + return this.repository.findOneOrFail({ where: { id } }); + } } diff --git a/server/libs/infra/src/db/repository/asset.repository.ts b/server/libs/infra/src/db/repository/asset.repository.ts index 7be96048a..6f0e65684 100644 --- a/server/libs/infra/src/db/repository/asset.repository.ts +++ b/server/libs/infra/src/db/repository/asset.repository.ts @@ -1,4 +1,4 @@ -import { IAssetRepository } from '@app/domain'; +import { AssetSearchOptions, IAssetRepository } from '@app/domain'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Not, Repository } from 'typeorm'; @@ -12,13 +12,32 @@ export class AssetRepository implements IAssetRepository { await this.repository.delete({ ownerId }); } - async getAll(): Promise { - return this.repository.find({ relations: { exifInfo: true } }); + getAll(options?: AssetSearchOptions | undefined): Promise { + options = options || {}; + + return this.repository.find({ + where: { + isVisible: options.isVisible, + }, + relations: { + exifInfo: true, + smartInfo: true, + tags: true, + }, + }); } async save(asset: Partial): Promise { const { id } = await this.repository.save(asset); - return this.repository.findOneOrFail({ where: { id } }); + return this.repository.findOneOrFail({ + where: { id }, + relations: { + exifInfo: true, + owner: true, + smartInfo: true, + tags: true, + }, + }); } findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise { diff --git a/server/libs/infra/src/infra.module.ts b/server/libs/infra/src/infra.module.ts index a783fe45b..6633a165e 100644 --- a/server/libs/infra/src/infra.module.ts +++ b/server/libs/infra/src/infra.module.ts @@ -8,6 +8,7 @@ import { IKeyRepository, IMachineLearningRepository, IMediaRepository, + ISearchRepository, ISharedLinkRepository, ISmartInfoRepository, IStorageRepository, @@ -45,6 +46,7 @@ import { import { JobRepository } from './job'; import { MachineLearningRepository } from './machine-learning'; import { MediaRepository } from './media'; +import { TypesenseRepository } from './search'; import { FilesystemProvider } from './storage'; const providers: Provider[] = [ @@ -52,12 +54,12 @@ const providers: Provider[] = [ { provide: IAssetRepository, useClass: AssetRepository }, { provide: ICommunicationRepository, useClass: CommunicationRepository }, { provide: ICryptoRepository, useClass: CryptoRepository }, - { provide: ICryptoRepository, useClass: CryptoRepository }, { provide: IDeviceInfoRepository, useClass: DeviceInfoRepository }, { provide: IKeyRepository, useClass: APIKeyRepository }, { provide: IJobRepository, useClass: JobRepository }, { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, { provide: IMediaRepository, useClass: MediaRepository }, + { provide: ISearchRepository, useClass: TypesenseRepository }, { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: ISmartInfoRepository, useClass: SmartInfoRepository }, { provide: IStorageRepository, useClass: FilesystemProvider }, diff --git a/server/libs/infra/src/job/job.repository.ts b/server/libs/infra/src/job/job.repository.ts index 4c791a397..e83ce0603 100644 --- a/server/libs/infra/src/job/job.repository.ts +++ b/server/libs/infra/src/job/job.repository.ts @@ -13,6 +13,7 @@ export class JobRepository implements IJobRepository { @InjectQueue(QueueName.STORAGE_TEMPLATE_MIGRATION) private storageTemplateMigration: Queue, @InjectQueue(QueueName.THUMBNAIL_GENERATION) private thumbnail: Queue, @InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue, + @InjectQueue(QueueName.SEARCH) private searchIndex: Queue, ) {} async isActive(name: QueueName): Promise { @@ -70,6 +71,18 @@ export class JobRepository implements IJobRepository { await this.videoTranscode.add(item.name, item.data); break; + case JobName.SEARCH_INDEX_ASSETS: + case JobName.SEARCH_INDEX_ALBUMS: + await this.searchIndex.add(item.name); + break; + + case JobName.SEARCH_INDEX_ASSET: + case JobName.SEARCH_INDEX_ALBUM: + case JobName.SEARCH_REMOVE_ALBUM: + case JobName.SEARCH_REMOVE_ASSET: + await this.searchIndex.add(item.name, item.data); + break; + default: // TODO inject remaining queues and map job to queue this.logger.error('Invalid job', item); diff --git a/server/libs/infra/src/search/index.ts b/server/libs/infra/src/search/index.ts new file mode 100644 index 000000000..c7673993a --- /dev/null +++ b/server/libs/infra/src/search/index.ts @@ -0,0 +1 @@ +export * from './typesense.repository'; diff --git a/server/libs/infra/src/search/schemas/album.schema.ts b/server/libs/infra/src/search/schemas/album.schema.ts new file mode 100644 index 000000000..bc01aca0c --- /dev/null +++ b/server/libs/infra/src/search/schemas/album.schema.ts @@ -0,0 +1,13 @@ +import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; + +export const albumSchemaVersion = 1; +export const albumSchema: CollectionCreateSchema = { + name: `albums-v${albumSchemaVersion}`, + fields: [ + { name: 'ownerId', type: 'string', facet: false }, + { name: 'albumName', type: 'string', facet: false, sort: true }, + { name: 'createdAt', type: 'string', facet: false, sort: true }, + { name: 'updatedAt', type: 'string', facet: false, sort: true }, + ], + default_sorting_field: 'createdAt', +}; diff --git a/server/libs/infra/src/search/schemas/asset.schema.ts b/server/libs/infra/src/search/schemas/asset.schema.ts new file mode 100644 index 000000000..962f4e9b2 --- /dev/null +++ b/server/libs/infra/src/search/schemas/asset.schema.ts @@ -0,0 +1,37 @@ +import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; + +export const assetSchemaVersion = 1; +export const assetSchema: CollectionCreateSchema = { + name: `assets-v${assetSchemaVersion}`, + fields: [ + // asset + { name: 'ownerId', type: 'string', facet: false }, + { name: 'type', type: 'string', facet: true }, + { name: 'originalPath', type: 'string', facet: false }, + { name: 'createdAt', type: 'string', facet: false, sort: true }, + { name: 'updatedAt', type: 'string', facet: false, sort: true }, + { name: 'fileCreatedAt', type: 'string', facet: false, sort: true }, + { name: 'fileModifiedAt', type: 'string', facet: false, sort: true }, + { name: 'isFavorite', type: 'bool', facet: true }, + // { name: 'checksum', type: 'string', facet: true }, + // { name: 'tags', type: 'string[]', facet: true, optional: true }, + + // exif + { name: 'exifInfo.city', type: 'string', facet: true, optional: true }, + { name: 'exifInfo.country', type: 'string', facet: true, optional: true }, + { name: 'exifInfo.state', type: 'string', facet: true, optional: true }, + { name: 'exifInfo.description', type: 'string', facet: false, optional: true }, + { name: 'exifInfo.imageName', type: 'string', facet: false, optional: true }, + { name: 'geo', type: 'geopoint', facet: false, optional: true }, + { name: 'exifInfo.make', type: 'string', facet: true, optional: true }, + { name: 'exifInfo.model', type: 'string', facet: true, optional: true }, + { name: 'exifInfo.orientation', type: 'string', optional: true }, + + // smart info + { name: 'smartInfo.objects', type: 'string[]', facet: true, optional: true }, + { name: 'smartInfo.tags', type: 'string[]', facet: true, optional: true }, + ], + token_separators: ['.'], + enable_nested_fields: true, + default_sorting_field: 'fileCreatedAt', +}; diff --git a/server/libs/infra/src/search/typesense.repository.ts b/server/libs/infra/src/search/typesense.repository.ts new file mode 100644 index 000000000..b24da0654 --- /dev/null +++ b/server/libs/infra/src/search/typesense.repository.ts @@ -0,0 +1,325 @@ +import { + ISearchRepository, + SearchCollection, + SearchCollectionIndexStatus, + SearchFilter, + SearchResult, +} from '@app/domain'; +import { Injectable, Logger } from '@nestjs/common'; +import _, { Dictionary } from 'lodash'; +import { Client } from 'typesense'; +import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; +import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents'; +import { AlbumEntity, AssetEntity } from '../db'; +import { albumSchema } from './schemas/album.schema'; +import { assetSchema } from './schemas/asset.schema'; + +interface GeoAssetEntity extends AssetEntity { + geo?: [number, number]; +} + +function removeNil>(item: T): Partial { + _.forOwn(item, (value, key) => { + if (_.isNil(value) || (_.isObject(value) && !_.isDate(value) && _.isEmpty(removeNil(value)))) { + delete item[key]; + } + }); + + return item; +} + +const schemaMap: Record = { + [SearchCollection.ASSETS]: assetSchema, + [SearchCollection.ALBUMS]: albumSchema, +}; + +const schemas = Object.entries(schemaMap) as [SearchCollection, CollectionCreateSchema][]; + +interface SearchUpdateQueue { + upsert: T[]; + delete: string[]; +} + +@Injectable() +export class TypesenseRepository implements ISearchRepository { + private logger = new Logger(TypesenseRepository.name); + private queue: Record = { + [SearchCollection.ASSETS]: { + upsert: [], + delete: [], + }, + [SearchCollection.ALBUMS]: { + upsert: [], + delete: [], + }, + }; + + private _client: Client | null = null; + private get client(): Client { + if (!this._client) { + throw new Error('Typesense client not available (no apiKey was provided)'); + } + return this._client; + } + + constructor() { + const apiKey = process.env.TYPESENSE_API_KEY; + if (!apiKey) { + return; + } + + this._client = new Client({ + nodes: [ + { + host: process.env.TYPESENSE_HOST || 'typesense', + port: Number(process.env.TYPESENSE_PORT) || 8108, + protocol: process.env.TYPESENSE_PROTOCOL || 'http', + }, + ], + apiKey, + numRetries: 3, + connectionTimeoutSeconds: 10, + }); + + setInterval(() => this.flush(), 5_000); + } + + async setup(): Promise { + // upsert collections + for (const [collectionName, schema] of schemas) { + const collection = await this.client + .collections(schema.name) + .retrieve() + .catch(() => null); + if (!collection) { + this.logger.log(`Creating schema: ${collectionName}/${schema.name}`); + await this.client.collections().create(schema); + } else { + this.logger.log(`Schema up to date: ${collectionName}/${schema.name}`); + } + } + } + + async checkMigrationStatus(): Promise { + const migrationMap: SearchCollectionIndexStatus = { + [SearchCollection.ASSETS]: false, + [SearchCollection.ALBUMS]: false, + }; + + // check if alias is using the current schema + const { aliases } = await this.client.aliases().retrieve(); + this.logger.log(`Alias mapping: ${JSON.stringify(aliases)}`); + + for (const [aliasName, schema] of schemas) { + const match = aliases.find((alias) => alias.name === aliasName); + if (!match || match.collection_name !== schema.name) { + migrationMap[aliasName] = true; + } + } + + this.logger.log(`Collections needing migration: ${JSON.stringify(migrationMap)}`); + + return migrationMap; + } + + async index(collection: SearchCollection, item: AssetEntity | AlbumEntity, immediate?: boolean): Promise { + const schema = schemaMap[collection]; + + if (collection === SearchCollection.ASSETS) { + item = this.patchAsset(item as AssetEntity); + } + + if (immediate) { + await this.client.collections(schema.name).documents().upsert(item); + return; + } + + this.queue[collection].upsert.push(item); + } + + async delete(collection: SearchCollection, id: string, immediate?: boolean): Promise { + const schema = schemaMap[collection]; + + if (immediate) { + await this.client.collections(schema.name).documents().delete(id); + return; + } + + this.queue[collection].delete.push(id); + } + + async import(collection: SearchCollection, items: AssetEntity[] | AlbumEntity[], done: boolean): Promise { + try { + const schema = schemaMap[collection]; + const _items = items.map((item) => { + if (collection === SearchCollection.ASSETS) { + item = this.patchAsset(item as AssetEntity); + } + // null values are invalid for typesense documents + return removeNil(item); + }); + if (_items.length > 0) { + await this.client + .collections(schema.name) + .documents() + .import(_items, { action: 'upsert', dirty_values: 'coerce_or_drop' }); + } + if (done) { + await this.updateAlias(collection); + } + } catch (error: any) { + this.handleError(error); + } + } + + search(collection: SearchCollection.ASSETS, query: string, filter: SearchFilter): Promise>; + search(collection: SearchCollection.ALBUMS, query: string, filter: SearchFilter): Promise>; + async search(collection: SearchCollection, query: string, filters: SearchFilter) { + const alias = await this.client.aliases(collection).retrieve(); + + const { userId } = filters; + + const _filters = [`ownerId:${userId}`]; + + if (filters.id) { + _filters.push(`id:=${filters.id}`); + } + if (collection === SearchCollection.ASSETS) { + for (const item of schemaMap[collection].fields || []) { + let value = filters[item.name as keyof SearchFilter]; + if (Array.isArray(value)) { + value = `[${value.join(',')}]`; + } + if (item.facet && value !== undefined) { + _filters.push(`${item.name}:${value}`); + } + } + + this.logger.debug(`Searching query='${query}', filters='${JSON.stringify(_filters)}'`); + + const results = await this.client + .collections(alias.collection_name) + .documents() + .search({ + q: query, + query_by: [ + 'exifInfo.imageName', + 'exifInfo.country', + 'exifInfo.state', + 'exifInfo.city', + 'exifInfo.description', + 'smartInfo.tags', + 'smartInfo.objects', + ].join(','), + filter_by: _filters.join(' && '), + per_page: 250, + facet_by: (assetSchema.fields || []) + .filter((field) => field.facet) + .map((field) => field.name) + .join(','), + }); + + return this.asResponse(results); + } + + if (collection === SearchCollection.ALBUMS) { + const results = await this.client + .collections(alias.collection_name) + .documents() + .search({ + q: query, + query_by: 'albumName', + filter_by: _filters.join(','), + }); + + return this.asResponse(results); + } + + throw new Error(`Invalid collection: ${collection}`); + } + + private asResponse(results: SearchResponse): SearchResult { + return { + page: results.page, + total: results.found, + count: results.out_of, + items: (results.hits || []).map((hit) => hit.document), + facets: (results.facet_counts || []).map((facet) => ({ + counts: facet.counts.map((item) => ({ count: item.count, value: item.value })), + fieldName: facet.field_name as string, + })), + }; + } + + private async flush() { + for (const [collection, schema] of schemas) { + if (this.queue[collection].upsert.length > 0) { + try { + const items = this.queue[collection].upsert.map((item) => removeNil(item)); + this.logger.debug(`Flushing ${items.length} ${collection} upserts to typesense`); + await this.client + .collections(schema.name) + .documents() + .import(items, { action: 'upsert', dirty_values: 'coerce_or_drop' }); + this.queue[collection].upsert = []; + } catch (error) { + this.handleError(error); + } + } + + if (this.queue[collection].delete.length > 0) { + try { + const items = this.queue[collection].delete; + this.logger.debug(`Flushing ${items.length} ${collection} deletes to typesense`); + await this.client + .collections(schema.name) + .documents() + .delete({ filter_by: `id: [${items.join(',')}]` }); + this.queue[collection].delete = []; + } catch (error) { + this.handleError(error); + } + } + } + } + + private handleError(error: any): never { + this.logger.error('Unable to index documents'); + const results = error.importResults || []; + for (const result of results) { + try { + result.document = JSON.parse(result.document); + } catch {} + } + this.logger.verbose(JSON.stringify(results, null, 2)); + throw error; + } + + private async updateAlias(collection: SearchCollection) { + const schema = schemaMap[collection]; + const alias = await this.client + .aliases(collection) + .retrieve() + .catch(() => null); + + // update alias to current collection + this.logger.log(`Using new schema: ${alias?.collection_name || '(unset)'} => ${schema.name}`); + await this.client.aliases().upsert(collection, { collection_name: schema.name }); + + // delete previous collection + if (alias && alias.collection_name !== schema.name) { + this.logger.log(`Deleting old schema: ${alias.collection_name}`); + await this.client.collections(alias.collection_name).delete(); + } + } + + private patchAsset(asset: AssetEntity): GeoAssetEntity { + const lat = asset.exifInfo?.latitude; + const lng = asset.exifInfo?.longitude; + if (lat && lng && lat !== 0 && lng !== 0) { + return { ...asset, geo: [lat, lng] }; + } + + return asset; + } +} diff --git a/server/package-lock.json b/server/package-lock.json index 880af80bf..5e26c539c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -6,9 +6,10 @@ "packages": { "": { "name": "immich", - "version": "1.49.0", + "version": "1.50.1", "license": "UNLICENSED", "dependencies": { + "@babel/runtime": "^7.20.13", "@nestjs/bull": "^0.6.2", "@nestjs/common": "^9.2.1", "@nestjs/config": "^2.2.0", @@ -46,7 +47,8 @@ "rxjs": "^7.2.0", "sanitize-filename": "^1.6.3", "sharp": "^0.28.0", - "typeorm": "^0.3.11" + "typeorm": "^0.3.11", + "typesense": "^1.5.2" }, "bin": { "immich": "bin/cli.sh" @@ -765,6 +767,17 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.20.13", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.13.tgz", + "integrity": "sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==", + "dependencies": { + "regenerator-runtime": "^0.13.11" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.16.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", @@ -8104,6 +8117,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loglevel": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.1.tgz", + "integrity": "sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -9498,6 +9523,11 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + }, "node_modules/regexpp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", @@ -11106,6 +11136,18 @@ "node": ">=4.2.0" } }, + "node_modules/typesense": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/typesense/-/typesense-1.5.2.tgz", + "integrity": "sha512-ysARFw+4z3AdSViOACqf7K9TXoP2wAXd5p5uSGTdXW14UYjcEzpV/S/EhMoiC6YdZyrnbDdNsxgWbf+AWJ9Udw==", + "dependencies": { + "axios": "^0.26.0", + "loglevel": "^1.8.0" + }, + "peerDependencies": { + "@babel/runtime": "^7.17.2" + } + }, "node_modules/uglify-js": { "version": "3.17.4", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", @@ -12115,6 +12157,14 @@ "@babel/helper-plugin-utils": "^7.16.7" } }, + "@babel/runtime": { + "version": "7.20.13", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.13.tgz", + "integrity": "sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, "@babel/template": { "version": "7.16.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", @@ -17808,6 +17858,11 @@ "is-unicode-supported": "^0.1.0" } }, + "loglevel": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.1.tgz", + "integrity": "sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==" + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -18862,6 +18917,11 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + }, "regexpp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", @@ -19962,6 +20022,15 @@ "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", "devOptional": true }, + "typesense": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/typesense/-/typesense-1.5.2.tgz", + "integrity": "sha512-ysARFw+4z3AdSViOACqf7K9TXoP2wAXd5p5uSGTdXW14UYjcEzpV/S/EhMoiC6YdZyrnbDdNsxgWbf+AWJ9Udw==", + "requires": { + "axios": "^0.26.0", + "loglevel": "^1.8.0" + } + }, "uglify-js": { "version": "3.17.4", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", diff --git a/server/package.json b/server/package.json index 2c476e24b..12fa9757e 100644 --- a/server/package.json +++ b/server/package.json @@ -39,6 +39,7 @@ "api:generate": "bash ./bin/generate-open-api.sh" }, "dependencies": { + "@babel/runtime": "^7.20.13", "@nestjs/bull": "^0.6.2", "@nestjs/common": "^9.2.1", "@nestjs/config": "^2.2.0", @@ -76,7 +77,8 @@ "rxjs": "^7.2.0", "sanitize-filename": "^1.6.3", "sharp": "^0.28.0", - "typeorm": "^0.3.11" + "typeorm": "^0.3.11", + "typesense": "^1.5.2" }, "devDependencies": { "@nestjs/cli": "^9.1.8", diff --git a/web/src/api/api.ts b/web/src/api/api.ts index 0bd8b76fc..329e14628 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -8,6 +8,7 @@ import { DeviceInfoApi, JobApi, OAuthApi, + SearchApi, ServerInfoApi, ShareApi, SystemConfigApi, @@ -21,6 +22,7 @@ export class ImmichApi { public authenticationApi: AuthenticationApi; public oauthApi: OAuthApi; public deviceInfoApi: DeviceInfoApi; + public searchApi: SearchApi; public serverInfoApi: ServerInfoApi; public jobApi: JobApi; public keyApi: APIKeyApi; @@ -41,6 +43,7 @@ export class ImmichApi { this.serverInfoApi = new ServerInfoApi(this.config); this.jobApi = new JobApi(this.config); this.keyApi = new APIKeyApi(this.config); + this.searchApi = new SearchApi(this.config); this.systemConfigApi = new SystemConfigApi(this.config); this.shareApi = new ShareApi(this.config); } diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 921bb0cca..a04d5cb81 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1451,6 +1451,37 @@ export interface RemoveAssetsDto { */ 'assetIds': Array; } +/** + * + * @export + * @interface SearchAlbumResponseDto + */ +export interface SearchAlbumResponseDto { + /** + * + * @type {number} + * @memberof SearchAlbumResponseDto + */ + 'total': number; + /** + * + * @type {number} + * @memberof SearchAlbumResponseDto + */ + 'count': number; + /** + * + * @type {Array} + * @memberof SearchAlbumResponseDto + */ + 'items': Array; + /** + * + * @type {Array} + * @memberof SearchAlbumResponseDto + */ + 'facets': Array; +} /** * * @export @@ -1464,6 +1495,107 @@ export interface SearchAssetDto { */ 'searchTerm': string; } +/** + * + * @export + * @interface SearchAssetResponseDto + */ +export interface SearchAssetResponseDto { + /** + * + * @type {number} + * @memberof SearchAssetResponseDto + */ + 'total': number; + /** + * + * @type {number} + * @memberof SearchAssetResponseDto + */ + 'count': number; + /** + * + * @type {Array} + * @memberof SearchAssetResponseDto + */ + 'items': Array; + /** + * + * @type {Array} + * @memberof SearchAssetResponseDto + */ + 'facets': Array; +} +/** + * + * @export + * @interface SearchConfigResponseDto + */ +export interface SearchConfigResponseDto { + /** + * + * @type {boolean} + * @memberof SearchConfigResponseDto + */ + 'enabled': boolean; +} +/** + * + * @export + * @interface SearchFacetCountResponseDto + */ +export interface SearchFacetCountResponseDto { + /** + * + * @type {number} + * @memberof SearchFacetCountResponseDto + */ + 'count': number; + /** + * + * @type {string} + * @memberof SearchFacetCountResponseDto + */ + 'value': string; +} +/** + * + * @export + * @interface SearchFacetResponseDto + */ +export interface SearchFacetResponseDto { + /** + * + * @type {string} + * @memberof SearchFacetResponseDto + */ + 'fieldName': string; + /** + * + * @type {Array} + * @memberof SearchFacetResponseDto + */ + 'counts': Array; +} +/** + * + * @export + * @interface SearchResponseDto + */ +export interface SearchResponseDto { + /** + * + * @type {SearchAlbumResponseDto} + * @memberof SearchResponseDto + */ + 'albums': SearchAlbumResponseDto; + /** + * + * @type {SearchAssetResponseDto} + * @memberof SearchResponseDto + */ + 'assets': SearchAssetResponseDto; +} /** * * @export @@ -6485,6 +6617,248 @@ export class OAuthApi extends BaseAPI { } +/** + * SearchApi - axios parameter creator + * @export + */ +export const SearchApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSearchConfig: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/search/config`; + // 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 bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + // authentication cookie required + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} [query] + * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] + * @param {boolean} [isFavorite] + * @param {string} [exifInfoCity] + * @param {string} [exifInfoState] + * @param {string} [exifInfoCountry] + * @param {string} [exifInfoMake] + * @param {string} [exifInfoModel] + * @param {Array} [smartInfoObjects] + * @param {Array} [smartInfoTags] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + search: async (query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/search`; + // 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 bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + // authentication cookie required + + if (query !== undefined) { + localVarQueryParameter['query'] = query; + } + + if (type !== undefined) { + localVarQueryParameter['type'] = type; + } + + if (isFavorite !== undefined) { + localVarQueryParameter['isFavorite'] = isFavorite; + } + + if (exifInfoCity !== undefined) { + localVarQueryParameter['exifInfo.city'] = exifInfoCity; + } + + if (exifInfoState !== undefined) { + localVarQueryParameter['exifInfo.state'] = exifInfoState; + } + + if (exifInfoCountry !== undefined) { + localVarQueryParameter['exifInfo.country'] = exifInfoCountry; + } + + if (exifInfoMake !== undefined) { + localVarQueryParameter['exifInfo.make'] = exifInfoMake; + } + + if (exifInfoModel !== undefined) { + localVarQueryParameter['exifInfo.model'] = exifInfoModel; + } + + if (smartInfoObjects) { + localVarQueryParameter['smartInfo.objects'] = smartInfoObjects; + } + + if (smartInfoTags) { + localVarQueryParameter['smartInfo.tags'] = smartInfoTags; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * SearchApi - functional programming interface + * @export + */ +export const SearchApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = SearchApiAxiosParamCreator(configuration) + return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getSearchConfig(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getSearchConfig(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} [query] + * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] + * @param {boolean} [isFavorite] + * @param {string} [exifInfoCity] + * @param {string} [exifInfoState] + * @param {string} [exifInfoCountry] + * @param {string} [exifInfoMake] + * @param {string} [exifInfoModel] + * @param {Array} [smartInfoObjects] + * @param {Array} [smartInfoTags] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * SearchApi - factory interface + * @export + */ +export const SearchApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = SearchApiFp(configuration) + return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSearchConfig(options?: any): AxiosPromise { + return localVarFp.getSearchConfig(options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} [query] + * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] + * @param {boolean} [isFavorite] + * @param {string} [exifInfoCity] + * @param {string} [exifInfoState] + * @param {string} [exifInfoCountry] + * @param {string} [exifInfoMake] + * @param {string} [exifInfoModel] + * @param {Array} [smartInfoObjects] + * @param {Array} [smartInfoTags] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, options?: any): AxiosPromise { + return localVarFp.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * SearchApi - object-oriented interface + * @export + * @class SearchApi + * @extends {BaseAPI} + */ +export class SearchApi extends BaseAPI { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SearchApi + */ + public getSearchConfig(options?: AxiosRequestConfig) { + return SearchApiFp(this.configuration).getSearchConfig(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {string} [query] + * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] + * @param {boolean} [isFavorite] + * @param {string} [exifInfoCity] + * @param {string} [exifInfoState] + * @param {string} [exifInfoCountry] + * @param {string} [exifInfoMake] + * @param {string} [exifInfoModel] + * @param {Array} [smartInfoObjects] + * @param {Array} [smartInfoTags] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SearchApi + */ + public search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, options?: AxiosRequestConfig) { + return SearchApiFp(this.configuration).search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * ServerInfoApi - axios parameter creator * @export diff --git a/web/src/app.d.ts b/web/src/app.d.ts index 073885899..c91d504d3 100644 --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -13,7 +13,7 @@ declare namespace App { interface Error { message: string; stack?: string; - code?: string; + code?: string | number; } } diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts index 65a4912e7..959ed5e3e 100644 --- a/web/src/hooks.server.ts +++ b/web/src/hooks.server.ts @@ -1,5 +1,5 @@ import type { Handle, HandleServerError } from '@sveltejs/kit'; -import { AxiosError } from 'axios'; +import { AxiosError, AxiosResponse } from 'axios'; import { env } from '$env/dynamic/public'; import { ImmichApi } from './api/api'; @@ -34,11 +34,24 @@ export const handle = (async ({ event, resolve }) => { return res; }) satisfies Handle; +const DEFAULT_MESSAGE = 'Hmm, not sure about that. Check the logs or open a ticket?'; + export const handleError: HandleServerError = async ({ error }) => { const httpError = error as AxiosError; + const response = httpError?.response as AxiosResponse<{ + message: string; + statusCode: number; + error: string; + }>; + + let code = response?.data?.statusCode || response?.status || httpError.code || '500'; + if (response) { + code += ` - ${response.data?.error || response.statusText}`; + } + return { - message: httpError?.message || 'Hmm, not sure about that. Check the logs or open a ticket?', - stack: httpError?.stack, - code: httpError.code || '500' + message: response?.data?.message || httpError?.message || DEFAULT_MESSAGE, + code, + stack: httpError?.stack }; }; diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index 67ed46919..2cc88c42f 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -22,7 +22,7 @@ $: { if (assets.length < 6) { - thumbnailSize = Math.floor(viewWidth / assets.length - assets.length); + thumbnailSize = Math.min(320, Math.floor(viewWidth / assets.length - assets.length)); } else { if (viewWidth > 600) thumbnailSize = Math.floor(viewWidth / 6 - 6); else if (viewWidth > 400) thumbnailSize = Math.floor(viewWidth / 4 - 6); diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index 348f7e1a3..091ba535d 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -11,6 +11,7 @@ import ImmichLogo from '../immich-logo.svelte'; export let user: UserResponseDto; export let shouldShowUploadButton = true; + export let term = ''; let shouldShowAccountInfo = false; @@ -35,6 +36,10 @@ goto(data.redirectUri || '/auth/login?autoLaunch=0'); }; + + const onSearch = () => { + goto(`/search?q=${term}`); + };
-
+
-
+
diff --git a/web/src/routes/(user)/search/+page.server.ts b/web/src/routes/(user)/search/+page.server.ts new file mode 100644 index 000000000..26eefac32 --- /dev/null +++ b/web/src/routes/(user)/search/+page.server.ts @@ -0,0 +1,26 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load = (async ({ locals, parent, url }) => { + const { user } = await parent(); + if (!user) { + throw redirect(302, '/auth/login'); + } + + const term = url.searchParams.get('q') || undefined; + + const { data: results } = await locals.api.searchApi.search( + term, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { params: url.searchParams } + ); + return { user, term, results }; +}) satisfies PageServerLoad; diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte new file mode 100644 index 000000000..6bcf8f956 --- /dev/null +++ b/web/src/routes/(user)/search/+page.svelte @@ -0,0 +1,27 @@ + + +
+ +
+ +
+
+
+ {#if data.results?.assets?.items} + + {/if} +
+
+
diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte index 3f0329a14..a6a7edf93 100644 --- a/web/src/routes/+error.svelte +++ b/web/src/routes/+error.svelte @@ -68,7 +68,7 @@
-

{$page.error?.message} - {$page.error?.code}

+

{$page.error?.message} ({$page.error?.code})

{#if $page.error?.stack}
{$page.error?.stack || 'No stack'}