From fb4be6e2313e0c1bbd7536fe1381cbbdb20195a4 Mon Sep 17 00:00:00 2001 From: Jonathan Gilbert Date: Sat, 7 Jun 2025 11:12:53 +1000 Subject: [PATCH] feat(server): add /search/statistics resource (#18885) --- mobile/openapi/README.md | Bin 36936 -> 37176 bytes mobile/openapi/lib/api.dart | Bin 13187 -> 13278 bytes mobile/openapi/lib/api/search_api.dart | Bin 14969 -> 16725 bytes mobile/openapi/lib/api_client.dart | Bin 33293 -> 33489 bytes .../model/search_statistics_response_dto.dart | Bin 0 -> 3023 bytes .../lib/model/statistics_search_dto.dart | Bin 0 -> 18875 bytes open-api/immich-openapi-specs.json | 172 ++++++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 44 +++++ server/src/controllers/search.controller.ts | 9 + server/src/dtos/search.dto.ts | 44 +++-- server/src/queries/search.repository.sql | 14 ++ server/src/repositories/search.repository.ts | 20 ++ server/src/services/search.service.ts | 11 ++ server/src/utils/database.ts | 1 - 14 files changed, 299 insertions(+), 16 deletions(-) create mode 100644 mobile/openapi/lib/model/search_statistics_response_dto.dart create mode 100644 mobile/openapi/lib/model/statistics_search_dto.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 74597b43bc4ca1e6065dfae13ee638270ab9e724..ee646354d1d58e01b59531efbb629017ca2cda70 100644 GIT binary patch delta 139 zcmX@HfN94frVR?loWUiDC7Hz~naPtCjFr`4oMKId8U-z_0RP|+EiDE8;?%^V|CqgFr!0E0ydhyVZp diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 7b49661844726923a7fb525d80802e62181e2e3b..9bf40263207511d70e0acf6da3fe1302bcce9a49 100644 GIT binary patch delta 46 zcmZopzn8utTW<1xDXq!;a_pP4mi%T+-izoZY$WMNt!a7Nrb@M%?r2+t- C#1Sa~ delta 17 YcmcbY-kiQ6TW<4Qxh$5=2FmLM07y{>2><{9 diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 632107ff79759e867493450ee36bd76f82ea8c18..5c7a8de59d2f9fe22d40872bce8523e9489df78f 100644 GIT binary patch delta 377 zcmexaaKD9E*!n zOM+pVio-KYGCWF33Ow`D@-+~`aQ(>IKbpjF!Zl6~G_^!E U=uVvcUe0Z@jtTGP?GlF+0Z7)A-v9sr delta 13 VcmccG#Q3vh!*+?yljRR70st|s28{p! diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index a96b8956553b6edc4a19eb30e2cffcfcdde6769e..bc7f48255c867bcb112b8bb30414eeaba229f5d5 100644 GIT binary patch delta 67 zcmeBeVY=ALv|*9o)nA#f# delta 19 bcmccE%GBG!v|*9oW*+~GoSV%fXK4ZeR4xbl diff --git a/mobile/openapi/lib/model/search_statistics_response_dto.dart b/mobile/openapi/lib/model/search_statistics_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..84f31373d8883a84c82d63879814ac2fbe3ee746 GIT binary patch literal 3023 zcmbVOZExE)5dQ98aVdsG0aSV0ry_~H7K=0VEd~;Az+e~xBheN+S=30XhLQTe@9rqc zGU_N<130#*yW`#SJa^=9I2aD$-Ji4R>pv#9le>4b$u-=(|1=5VdJ4DG8GN2zzrXqW z1kK3uZ7Pf#{Tlu9WRcIsQr8e|BEu}X16wN%ouyFI#Dt_o@6To)@ebD1dO zH$TToCX5{na61Qj3A&VpC{=`?%fTSYg|%=mg-Nn|C!DfQCH79*5;vCDPQ&8>V2D`U zNmrS|f`CG9VBX`h0L4isGD6=T!+>G~G`N7vl#QHrA`kJ!?i}+2jOBpW`01_wDvd!f z@XdqPIhA9C(*{PPVEXVyCRn7)q&bJt7(8f+$O8%;HgCRt^S?m#mwtN6`Efj-7w+;z zGL+^@I>?00e%GnQ?b*3i5s9aeiL_RjEs>9WX3{FlW&xKNHQv7TZn5{ysA7ei`yT<< zch&>$oFk5Y;;+RSqxV^qSKdE_bc-@n$rwX^u)4s2o{gm{L=InY2}bap_2cf6^98n; zn==l7wm{oGd)~ybVAzfpc0}fn3JFA!g6}d5%31g-@=97*8ojQ=&Up#7w?u<8(&-$_ zU4&3nIm(1Iu~C*K+2N!#a*4gdZDy(jg=AxrSd3kVoe*P#W;!K~{6e3cf8%r*%o<#u zW`k^1u8c^jsRGV5gjYTMy+RF8cVIo`VM;KAooFX-!jiT=Wz%CXE~=tI67fg;vZ5PqMHN zmT@y?2#1Cj%%+zXwi|icp6UY}K=q-10qyx4;}M7_J)P)&7#~oKt7hAf9MO}KwkeD- z_Qm}#vN>kNE*IP5V1RlGHzAOTNTi`!E72>5V6y`>9bEe}3&+q>UK@@?-M<3~oOzFhES@N$5Y=|8*-=QjWV literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/statistics_search_dto.dart b/mobile/openapi/lib/model/statistics_search_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..0fe0770b6dc2f4acc5523194d141e7c3d27c1070 GIT binary patch literal 18875 zcmeGkYj4}g@wku+4;NE_y2wdXo&g4EUD_rpU1D?>|ryzEy_wx=VdjWXSqBS>te0sY$|iPltn$V z?QFR!t2#=NNzzXO<|F8hykQy|l|>EK#u?rPr<>?nE{HoT@>Q5ck{E`q`Hr$zgi)Lr*vOBwP z-R`@Uxa}-Gzg0@smmupze|~Ynd*|)j^LD;}v$Olw?Y@gLTZ}Rv-R9%5@EeiZ>whR= z%dUdmF-YTa(G<7Pp%N%7F_{d{Z+??0Y^=k^VRaxTM}ju$(5} z4lvwuGotuL1IMnA5|V2*uxM3}aLjW9!>>aT7Hc;!&FTrE*+zil)>071H$oh{vZ8TZ zBf!C>0PNO(!yY^Xpu6;g>6^}^T%G*BPH5}t zfka)rVkbTHOQfB!*yZDYMH#nIUoh~eD#^`u#Y{+9>-IQ^eMA|t;u^elJ90WzA z1hF*Kk9T!bM)eEAZiLoH+lH3DgHg32GOfwO$tZdl>g;a+qJ32I3S;kNpief!=`DSr z5t{Tc3QkP{n1xK&N3Z8PsX5|#)?~oK9`;Z;Lohv!d^R;1xX^`A8;}repAk;62%+?Z zFdtG7?pxSr;s$n@$C1y2CIc5F0BYwxK<(p5Cp?n@PIP$2ISdW_!;+WDz)j+jNmwQW znx`YmQ6>XD6Kw`E8TgFE9%@9GeMUStrA&vlE>AF^i_H_D;;!#H9VK`<0O5Z)0nE-g zP6oW?`t0Zi{yJjGz+~V(@_Ju8;#smXn?f+y4{%^OOOL=6e}Y4y;D~gdcAgbUE`9?L z91?9|e}4R4fA6!o(3J=k70)O`uq=cozpD1pdH1h@k2|DG)Q>{uaGsI`LN3z6#~5i) z8{$lw(J7JonNR}s$YoUBqS+i1o$?tHQ-VRCjqCE)IvpOs-&0k-`Rl9KHWc(I$(U%? zeVUSo^r=c%EnNxxt;#ayo!YVx)Tt}O@tQK=X3H_e&4#mKZ-X8b7r7Dy>C=GGby~s& z*iQKl!zBD~n=Rmj`34`+4Ci5J{f`gw-eBf220p z!6R`sJhV?0kJ+b=&APkIVKBHN2Gpkor|fi1_Z{vuhr{qHIM8Ml9NK0pxQ~9f3N|fZ zDp*jD3Kng%3cAGT8UyU~g0Zz(ro->M$V!g<-O z@~gkK4YtkKa0AAY*3BxiueMIy7~!`m$ryKQ$x`NSH5n4KC4+vwqKt`V-KQzNRG+Hs z)ybwLhQQydEMwlSE!Ndt(*~;!W=Cjko-_`>Qyi~ZI>mA%FP=lc+A^HU=3z9LJ;NAa zwRHW)VQe$+w5zY_I<4wayq+Gmd0;(W)M%BywyDcCHgR2oFXY^g6_5|k=2S0 zAu2Ji%B5jfwZWSe4$&(bMei(Qm6T-hc-Y{%?atDy^GQy`OiwzOTZZmVP_;VSD;>o^k6FWPH2 z&*7FK?z>^=r8|7EKL>AMxd^!b$Es|NGENhBWYbsYL%Oes%#7_L;)Yrn;$Xpj2x}s% zk9c$Ne0~Wt*&YKOVoeVVAXJqf&^1p@MAdIUovD&JDXU2}y*PwGaa*rA+Z|a6#dRt#G`7Yyc5t}2+&TtNs%XXDOKe@~6nRHCNdnt8x&WSL0?x)fj zHY0P%^Pyeiau5ix5EV}0LONa*c2gK<W{Qp|jt58jF)r%Iu8b zGU|Q{IMk0Jk-`|Q|EGgbV+Yz_;x^M?IPeYqwFIY(4NZ%zNz6*yUI0!e! z@2~#wnc=4#kNGlBsp3L8M$2G9lCzP%LusFz7}Y&k^Q3 z(Lu2`AmacQ-_eM1;=3Ex{vds$`8D2zhNmlqgB;mssrCn2rtljn@KZ4OlP`4IA{a`5 zRu6VC$JolGvM+=-rI-4mLDjs*8kG%E&)|Z&u88oHe84>%O;7&m$JCB(Fv38}sskJz zOyRYmN;=qfpVbQh;dH`ZkBN-M3&I3KQy}1vNx&~-;P1@7J3welikYHhDoV_n>ZRU*kt#=ptX@Zsx1c0z(*xxe};C2c&4igw}{MnKli3Oelu{bLd@zcfm2mCn^;|7%4 USmJ*ot=qml`V4rmaS1`?zY4F(HUIzs literal 0 HcmV?d00001 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0e35be2ee..352fe768f 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5158,6 +5158,48 @@ ] } }, + "/search/statistics": { + "post": { + "operationId": "searchAssetStatistics", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatisticsSearchDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchStatisticsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Search" + ] + } + }, "/search/suggestions": { "get": { "operationId": "getSearchSuggestions", @@ -12069,6 +12111,17 @@ ], "type": "object" }, + "SearchStatisticsResponseDto": { + "properties": { + "total": { + "type": "integer" + } + }, + "required": [ + "total" + ], + "type": "object" + }, "SearchSuggestionType": { "enum": [ "country", @@ -12974,6 +13027,125 @@ }, "type": "object" }, + "StatisticsSearchDto": { + "properties": { + "city": { + "nullable": true, + "type": "string" + }, + "country": { + "nullable": true, + "type": "string" + }, + "createdAfter": { + "format": "date-time", + "type": "string" + }, + "createdBefore": { + "format": "date-time", + "type": "string" + }, + "description": { + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "isEncoded": { + "type": "boolean" + }, + "isFavorite": { + "type": "boolean" + }, + "isMotion": { + "type": "boolean" + }, + "isNotInAlbum": { + "type": "boolean" + }, + "isOffline": { + "type": "boolean" + }, + "lensModel": { + "nullable": true, + "type": "string" + }, + "libraryId": { + "format": "uuid", + "nullable": true, + "type": "string" + }, + "make": { + "type": "string" + }, + "model": { + "nullable": true, + "type": "string" + }, + "personIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "rating": { + "maximum": 5, + "minimum": -1, + "type": "number" + }, + "state": { + "nullable": true, + "type": "string" + }, + "tagIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "takenAfter": { + "format": "date-time", + "type": "string" + }, + "takenBefore": { + "format": "date-time", + "type": "string" + }, + "trashedAfter": { + "format": "date-time", + "type": "string" + }, + "trashedBefore": { + "format": "date-time", + "type": "string" + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetTypeEnum" + } + ] + }, + "updatedAfter": { + "format": "date-time", + "type": "string" + }, + "updatedBefore": { + "format": "date-time", + "type": "string" + }, + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetVisibility" + } + ] + } + }, + "type": "object" + }, "SyncAckDeleteDto": { "properties": { "types": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index fa7504916..0308ecb9e 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -995,6 +995,38 @@ export type SmartSearchDto = { withDeleted?: boolean; withExif?: boolean; }; +export type StatisticsSearchDto = { + city?: string | null; + country?: string | null; + createdAfter?: string; + createdBefore?: string; + description?: string; + deviceId?: string; + isEncoded?: boolean; + isFavorite?: boolean; + isMotion?: boolean; + isNotInAlbum?: boolean; + isOffline?: boolean; + lensModel?: string | null; + libraryId?: string | null; + make?: string; + model?: string | null; + personIds?: string[]; + rating?: number; + state?: string | null; + tagIds?: string[]; + takenAfter?: string; + takenBefore?: string; + trashedAfter?: string; + trashedBefore?: string; + "type"?: AssetTypeEnum; + updatedAfter?: string; + updatedBefore?: string; + visibility?: AssetVisibility; +}; +export type SearchStatisticsResponseDto = { + total: number; +}; export type ServerAboutResponseDto = { build?: string; buildImage?: string; @@ -2882,6 +2914,18 @@ export function searchSmart({ smartSearchDto }: { body: smartSearchDto }))); } +export function searchAssetStatistics({ statisticsSearchDto }: { + statisticsSearchDto: StatisticsSearchDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SearchStatisticsResponseDto; + }>("/search/statistics", oazapfts.json({ + ...opts, + method: "POST", + body: statisticsSearchDto + }))); +} export function getSearchSuggestions({ country, includeNull, make, model, state, $type }: { country?: string; includeNull?: boolean; diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index c51ad8e06..9bda1fcad 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -11,8 +11,10 @@ import { SearchPeopleDto, SearchPlacesDto, SearchResponseDto, + SearchStatisticsResponseDto, SearchSuggestionRequestDto, SmartSearchDto, + StatisticsSearchDto, } from 'src/dtos/search.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { SearchService } from 'src/services/search.service'; @@ -29,6 +31,13 @@ export class SearchController { return this.service.searchMetadata(auth, dto); } + @Post('statistics') + @HttpCode(HttpStatus.OK) + @Authenticated() + searchAssetStatistics(@Auth() auth: AuthDto, @Body() dto: StatisticsSearchDto): Promise { + return this.service.searchStatistics(auth, dto); + } + @Post('random') @HttpCode(HttpStatus.OK) @Authenticated() diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 579cba680..81d74e0a7 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -37,12 +37,6 @@ class BaseSearchDto { @ValidateAssetVisibility({ optional: true }) visibility?: AssetVisibility; - @ValidateBoolean({ optional: true }) - withDeleted?: boolean; - - @ValidateBoolean({ optional: true }) - withExif?: boolean; - @ValidateDate({ optional: true }) createdBefore?: Date; @@ -92,13 +86,6 @@ class BaseSearchDto { @Optional({ nullable: true, emptyToNull: true }) lensModel?: string | null; - @IsInt() - @Min(1) - @Max(1000) - @Type(() => Number) - @Optional() - size?: number; - @ValidateBoolean({ optional: true }) isNotInAlbum?: boolean; @@ -115,7 +102,22 @@ class BaseSearchDto { rating?: number; } -export class RandomSearchDto extends BaseSearchDto { +class BaseSearchWithResultsDto extends BaseSearchDto { + @ValidateBoolean({ optional: true }) + withDeleted?: boolean; + + @ValidateBoolean({ optional: true }) + withExif?: boolean; + + @IsInt() + @Min(1) + @Max(1000) + @Type(() => Number) + @Optional() + size?: number; +} + +export class RandomSearchDto extends BaseSearchWithResultsDto { @ValidateBoolean({ optional: true }) withStacked?: boolean; @@ -179,7 +181,14 @@ export class MetadataSearchDto extends RandomSearchDto { page?: number; } -export class SmartSearchDto extends BaseSearchDto { +export class StatisticsSearchDto extends BaseSearchDto { + @IsString() + @IsNotEmpty() + @Optional() + description?: string; +} + +export class SmartSearchDto extends BaseSearchWithResultsDto { @IsString() @IsNotEmpty() query!: string; @@ -299,6 +308,11 @@ export class SearchResponseDto { assets!: SearchAssetResponseDto; } +export class SearchStatisticsResponseDto { + @ApiProperty({ type: 'integer' }) + total!: number; +} + class SearchExploreItem { value!: string; data!: AssetResponseDto; diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 806fdb1c7..b68e0e177 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -20,6 +20,20 @@ limit offset $7 +-- SearchRepository.searchStatistics +select + count(*) as "total" +from + "assets" + inner join "exif" on "assets"."id" = "exif"."assetId" +where + "assets"."visibility" = $1 + and "assets"."fileCreatedAt" >= $2 + and "exif"."lensModel" = $3 + and "assets"."ownerId" = any ($4::uuid[]) + and "assets"."isFavorite" = $5 + and "assets"."deletedAt" is null + -- SearchRepository.searchRandom ( select diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 747a59c65..14150d424 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -185,6 +185,7 @@ export class SearchRepository { async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions) { const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection; const items = await searchAssetBuilder(this.db, options) + .selectAll('assets') .orderBy('assets.fileCreatedAt', orderDirection) .limit(pagination.size + 1) .offset((pagination.page - 1) * pagination.size) @@ -193,6 +194,22 @@ export class SearchRepository { return paginationHelper(items, pagination.size); } + @GenerateSql({ + params: [ + { + takenAfter: DummyValue.DATE, + lensModel: DummyValue.STRING, + isFavorite: true, + userIds: [DummyValue.UUID], + }, + ], + }) + searchStatistics(options: AssetSearchOptions) { + return searchAssetBuilder(this.db, options) + .select((qb) => qb.fn.countAll().as('total')) + .executeTakeFirstOrThrow(); + } + @GenerateSql({ params: [ 100, @@ -209,10 +226,12 @@ export class SearchRepository { const uuid = randomUUID(); const builder = searchAssetBuilder(this.db, options); const lessThan = builder + .selectAll('assets') .where('assets.id', '<', uuid) .orderBy(sql`random()`) .limit(size); const greaterThan = builder + .selectAll('assets') .where('assets.id', '>', uuid) .orderBy(sql`random()`) .limit(size); @@ -241,6 +260,7 @@ export class SearchRepository { return this.db.transaction().execute(async (trx) => { await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.CLIP])}`.execute(trx); const items = await searchAssetBuilder(trx, options) + .selectAll('assets') .innerJoin('smart_search', 'assets.id', 'smart_search.assetId') .orderBy(sql`smart_search.embedding <=> ${options.embedding}`) .limit(pagination.size + 1) diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 3f122b5e7..73678f05a 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -10,9 +10,11 @@ import { SearchPeopleDto, SearchPlacesDto, SearchResponseDto, + SearchStatisticsResponseDto, SearchSuggestionRequestDto, SearchSuggestionType, SmartSearchDto, + StatisticsSearchDto, } from 'src/dtos/search.dto'; import { AssetOrder, AssetVisibility } from 'src/enum'; import { BaseService } from 'src/services/base.service'; @@ -67,6 +69,15 @@ export class SearchService extends BaseService { return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); } + async searchStatistics(auth: AuthDto, dto: StatisticsSearchDto): Promise { + const userIds = await this.getUserIdsToSearch(auth); + + return await this.searchRepository.searchStatistics({ + ...dto, + userIds, + }); + } + async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { if (dto.visibility === AssetVisibility.LOCKED) { requireElevatedPermission(auth); diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 5e5c6c5fb..d8171dc95 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -291,7 +291,6 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild return kysely .withPlugin(joinDeduplicationPlugin) .selectFrom('assets') - .selectAll('assets') .where('assets.visibility', '=', visibility) .$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!)) .$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!))