From 9f8a7e0beac3615fd2b7b3e2f8cbb4d91448e238 Mon Sep 17 00:00:00 2001 From: jschwalbe Date: Mon, 23 Sep 2024 12:09:26 -0400 Subject: [PATCH] feat(server): sort assets randomly from the API 'api/search/metadata' endpoint by including 'order': 'rand' in the API call. (#12741) feat(server): search metadata random sort order Co-authored-by: Jason Rasmussen --- mobile/openapi/README.md | Bin 31964 -> 32199 bytes mobile/openapi/lib/api.dart | Bin 11453 -> 11490 bytes mobile/openapi/lib/api/assets_api.dart | Bin 33309 -> 33392 bytes mobile/openapi/lib/api/deprecated_api.dart | Bin 2164 -> 3938 bytes mobile/openapi/lib/api/search_api.dart | Bin 13180 -> 14825 bytes mobile/openapi/lib/api_client.dart | Bin 29523 -> 29605 bytes .../openapi/lib/model/random_search_dto.dart | Bin 0 -> 22089 bytes open-api/immich-openapi-specs.json | 176 +++++++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 49 +++++ server/src/controllers/asset.controller.ts | 2 + server/src/controllers/search.controller.ts | 8 + server/src/dtos/search.dto.ts | 16 +- server/src/interfaces/search.interface.ts | 1 + server/src/repositories/search.repository.ts | 7 +- server/src/services/search.service.ts | 17 ++ 15 files changed, 266 insertions(+), 10 deletions(-) create mode 100644 mobile/openapi/lib/model/random_search_dto.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 697239fa44e996dee3ffe833324bfdaa7d01278c..c8135519def5656957ccec94c12c1415c5abc1c3 100644 GIT binary patch delta 110 zcmccflkxa(#tqLUCznf#O_mVhpS(y?WU{%4>gH#XcSYHP67y2>b0<6MiA}zz&&E~+ v;a=1eg)!ghNpC)^Z(*(gQWu<>Sd^UMQj#C5k&>URuMg1;5#9VY?Y$@fT;MA= delta 24 gcmX^9oAJ(1#tqLUH@ivQ5#8KiU}3)bS^7g!0HF#D761SM diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8a1655d35a10956f577e9fe55b08093ca42660be..7fa06b04875ee56898e30fe6d4e26a8ae6ef6eb2 100644 GIT binary patch delta 28 kcmdlR`6zP3W-0Exl>FTI;?%^VYC%zIa$-qpib7_dLYbkSp`n?c i!DN5VeCqbf=O delta 42 zcmV+_0M-BSgaVy}0=CMTX}R AegFUf diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart index 96cb3c2ef0ad24f27032bbaa6f2e27ad769bd821..bc8f50092a030a0837c25e057c02388c65484a33 100644 GIT binary patch delta 289 zcmew&@JMb$5y#~9EIN#4lW#Dly5yDS#wsM|m*$mNaVaRcm6ntirP>6g78m5_6{p%M zq^FhyCFZ5%=Z0sNWO$U66nN&Pllh({9BP*>McPzUP; zGQhN^rZre^?dF5bQj9>?-(k_>Lb!giG^@H8#Hkq0i)7VM!r}|4w_)DcJeT!469C;k BVyyrG delta 10 RcmaDP_eEes5eHK(7XTT61GoSH diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 4b6cdfea78aa491d4417ebdd0e39672c0fd67f4a..3b981e0ccb5bb3ba65e54ed547adbdb1f847916a 100644 GIT binary patch delta 228 zcmey9_Of_`lP+6PVqQvq?&LaNPw^lyGdMM|C^^HWBtKRGDkmtZuL+S0&n(ICC@CrM z%uCDHKvND=y}>|Ya=f|&%-{|D)|?8qP|3}+^wu%ru}6OLUuh{>h{3qcHqWl~hX delta 12 TcmaD^{3mULlkVpA##U?qEdd3y diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4976c8a75f331d9f3e6a3175c3534811ba9cde7a..597a15d5b0562a58f93572b3b4d849bfa7527ba3 100644 GIT binary patch delta 39 tcmccojB)96#tj*6EO{yUxswCzWVwS9LCoOP#G>TMj4>*k)7;+b002Yx4}1Us delta 14 WcmZ4bobmEA#tj*6n|Har)&T%ICzVzZDBIK&5Ke_=0!P~r2$Fy%Bqng$sUqNHCe8zNhyC{r6sWE$pZc^lM;R_St<}-s%G|Sk*g}nt4TV80@Haa zOU;+&18C)lxk}WimOC17sXQM$ub?m|L?Z8rdgtt_?YCg zVsRmpa(Z`M72@-j5PR_cA7!;Fb0rdy%Lj24!LCKBm7B^D;G@g|$`GQuOVwzaR*yrL zELM3{29#1Jm7E>T!5SP$5P$5bp*Pn_cA1&*0Zjity>XG)BPZW-e0niZhZ|8O)koZu}hC@YU`tcf)XS7oB^>J?(hb@=0w z7ra`|nyS-~YupcMb!Tf2UTmIZic7=nW9V_vK-jw?_2iFq9+7^Q#WJV}#9dU$^b>JE z?Lwm$;-V_k{Ps{_%M1zBHZ=`;06?)H{G7=|Ns$*7_<&1j(qdUcKPsz7q3()RHWPQr zy%aZ6<^alH=c_DBph3J67im6~&+DuB(oX88WaG%TV7BX0Q`^*CPV8P!65z^ zRJ=?V66%0;{*B$&Zg=XA8^gb};jPQDgV)C5FKzLgqR0+~j|?^zUzf#A*tIeEx(se( z+Kt86WpSD?Z0x-*d;8hg#^&p?xtZ#248AUdn{@crK_$c0OFTA*<$;~>}xbT z+s)Y6eeHJNtT3-@jhszuUteqIyPg}nuifs<%izMl;JeD3>Uinu zpuHBhRlhO$`b>`JAU5`1pS|gj$;RUAv$#3IqfP#O0cTxGI93Ad1lrT%Nt~oAN--V} z&ToE`Q#j`aCvM7JF+LE0gL8A!Bpcu%l%dV}^G|w$?cw<3n)voxkSr))pIrmN`lL{l zLq~;lrg;oG*|`=C;^`(NjbIFF2m#g}A3&IU4iJ9B8!;>uq0A;Nfovke*nLF;xm1Lr zy)eR<=Lq3ebj9Dfpj7w`h(Sp9nE_30-4cBEaC9FJ|lCFb7ZS z=`8&a1g zZt2J}o>b1|R4N4rPVuBMIKexqQl()7JoboX;GsWpDBg&D?cZLF ztiWCI7lZUpj)RHXgy&8!DuHFSbNWi;I@@)UeTcThnq5 zpDSUQ`gAa`J{9cH(P0S-;YwIqpB5I^r-qq5v>So|Tm_=^DZuzzn0{{DI`L^WC#3p;e=$%tR4Boo}OC7V~YTatm8 zEgAL86=gzt>`BNzi*i&=S7QEJWuIkNTtsQ>5jZttxUi|5oY zwhR~2U5y6WQ;h+trt4Q$V_kS`SLbv2wAZV_sgdVpi%hNcz%ZdqGmGwS3^8}CyYtEj z)GJzT&CmiuC6J|G14L=Q&f?ATSeF6>2~{9kzY>hMQw_7O&{-h{6Dnee{hDy@Sd~&= z?V?u=P|y?Jit)c(`6A%T7(w^mD3Ml*zI#z(UKR@@>PlZ<0k_1=0kDI&$Xvnd$@P%7 zo%G)4L#oW35N_jYS;{%wUZbx!DOXe2*H;tV@t-Idf79s%uFSbBX1I|>gn0Y@P^+NV zW6b&tsb*JJuqD#H8nE>b`dGSvJ2}|0jfRY-iTkX?FZT@S4iP*fV5}Iea}Gc``iHAG z`>=So{1tEVIG|p6T zb$0S(o&09Bi3Qm8f?nM%keBY~`89St=W*cP!2?5fGez&^wo`fb=6=n$m)lLj19qAh z_cEL5bHM87vAygb8os%ig!b~=$!bu(nLhS1+i50ab`!rEv-uXw)tN(b#odIkpWRLG zSm`+b>gCjvG|q%msD3Uz(PC^k6WRyQwHU!}x{`yP?a$bMVTG3FNKgE|+@xsVF3UO! zxN`72r*P2~UJ&FQ1@`+xp{<1??FV_!EISQptOCuJ83tN3S0Yl zhnTx53QNMtQ}fHvv?_QRbhG_t)+lZRKO@n`t~nx~UvoojuF=8U;)3{|L<={M?qd%Y z998M$Zz~^M{Jsdz;*W4p)FYB$=0Z85X0RZ3?Bs7z8iOu-1()XM<5lQ< zIdo@N9Kv|(FYLm`g#Z&XK0}m2@|I&Gz-MslfREV`StMta5eWU;Istw;!m;6&xXNG=}mmM>)W`8Hsp zU}_x4VZPCWcQTei>-hMq7Jy?my~GGE9HS_WagtrFeFG~SuBwRqp*5M7!mEI9UXh*wa3 zKrJ6_1&%P4N@ZULjZQDrTajAMNW)mQ^i(LA>53RX$w%DC)cn*QvFwgo1y(jU!D_)t zgf>z^=X|lh0|~nh-fbDx!D~VRlbN32g>-Pc9^Qlr!l0X)4td5lb+HB2I?w^)F{iOz z48`+0D9GR~rn#gqX*bVo63=N~CU#(*zqj?kYlSr&Rl^9Y#~F?_qM@+;bKgA;UCW|| z5p;7Xi8Y8lxQbD_y0^Eh3TS2ucFPl}W4!M0M#5EzQbDoU0}}D$#rX%khR{?!1xJ?o SU&!nB><>Nz4tDFoar_tTq|0vr literal 0 HcmV?d00001 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f48fa989d..706ff5b8f 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1646,6 +1646,8 @@ }, "/assets/random": { "get": { + "deprecated": true, + "description": "This property was deprecated in v1.116.0", "operationId": "getRandom", "parameters": [ { @@ -1685,8 +1687,12 @@ } ], "tags": [ - "Assets" - ] + "Assets", + "Deprecated" + ], + "x-immich-lifecycle": { + "deprecatedAt": "v1.116.0" + } } }, "/assets/statistics": { @@ -4677,6 +4683,48 @@ ] } }, + "/search/random": { + "post": { + "operationId": "searchRandom", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RandomSearchDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Search" + ] + } + }, "/search/smart": { "post": { "operationId": "searchSmart", @@ -10454,6 +10502,130 @@ ], "type": "object" }, + "RandomSearchDto": { + "properties": { + "city": { + "nullable": true, + "type": "string" + }, + "country": { + "nullable": true, + "type": "string" + }, + "createdAfter": { + "format": "date-time", + "type": "string" + }, + "createdBefore": { + "format": "date-time", + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "isArchived": { + "type": "boolean" + }, + "isEncoded": { + "type": "boolean" + }, + "isFavorite": { + "type": "boolean" + }, + "isMotion": { + "type": "boolean" + }, + "isNotInAlbum": { + "type": "boolean" + }, + "isOffline": { + "type": "boolean" + }, + "isVisible": { + "type": "boolean" + }, + "lensModel": { + "nullable": true, + "type": "string" + }, + "libraryId": { + "format": "uuid", + "nullable": true, + "type": "string" + }, + "make": { + "type": "string" + }, + "model": { + "nullable": true, + "type": "string" + }, + "page": { + "minimum": 1, + "type": "number" + }, + "personIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "size": { + "maximum": 1000, + "minimum": 1, + "type": "number" + }, + "state": { + "nullable": true, + "type": "string" + }, + "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": { + "$ref": "#/components/schemas/AssetTypeEnum" + }, + "updatedAfter": { + "format": "date-time", + "type": "string" + }, + "updatedBefore": { + "format": "date-time", + "type": "string" + }, + "withArchived": { + "default": false, + "type": "boolean" + }, + "withDeleted": { + "type": "boolean" + }, + "withExif": { + "type": "boolean" + }, + "withPeople": { + "type": "boolean" + }, + "withStacked": { + "type": "boolean" + } + }, + "type": "object" + }, "RatingsResponse": { "properties": { "enabled": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c2d73bda1..8e607f757 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -837,6 +837,40 @@ export type PlacesResponseDto = { longitude: number; name: string; }; +export type RandomSearchDto = { + city?: string | null; + country?: string | null; + createdAfter?: string; + createdBefore?: string; + deviceId?: string; + isArchived?: boolean; + isEncoded?: boolean; + isFavorite?: boolean; + isMotion?: boolean; + isNotInAlbum?: boolean; + isOffline?: boolean; + isVisible?: boolean; + lensModel?: string | null; + libraryId?: string | null; + make?: string; + model?: string | null; + page?: number; + personIds?: string[]; + size?: number; + state?: string | null; + takenAfter?: string; + takenBefore?: string; + trashedAfter?: string; + trashedBefore?: string; + "type"?: AssetTypeEnum; + updatedAfter?: string; + updatedBefore?: string; + withArchived?: boolean; + withDeleted?: boolean; + withExif?: boolean; + withPeople?: boolean; + withStacked?: boolean; +}; export type SmartSearchDto = { city?: string | null; country?: string | null; @@ -1696,6 +1730,9 @@ export function getMemoryLane({ day, month }: { ...opts })); } +/** + * This property was deprecated in v1.116.0 + */ export function getRandom({ count }: { count?: number; }, opts?: Oazapfts.RequestOpts) { @@ -2500,6 +2537,18 @@ export function searchPlaces({ name }: { ...opts })); } +export function searchRandom({ randomSearchDto }: { + randomSearchDto: RandomSearchDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SearchResponseDto; + }>("/search/random", oazapfts.json({ + ...opts, + method: "POST", + body: randomSearchDto + }))); +} export function searchSmart({ smartSearchDto }: { smartSearchDto: SmartSearchDto; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index c6fdac171..9d3d23065 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { EndpointLifecycle } from 'src/decorators'; import { AssetResponseDto, MemoryLaneResponseDto } from 'src/dtos/asset-response.dto'; import { AssetBulkDeleteDto, @@ -31,6 +32,7 @@ export class AssetController { @Get('random') @Authenticated() + @EndpointLifecycle({ deprecatedAt: 'v1.116.0' }) getRandom(@Auth() auth: AuthDto, @Query() dto: RandomAssetsDto): Promise { return this.service.getRandom(auth, dto.count ?? 1); } diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index 5b8c1eeec..5b6deb298 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -6,6 +6,7 @@ import { PersonResponseDto } from 'src/dtos/person.dto'; import { MetadataSearchDto, PlacesResponseDto, + RandomSearchDto, SearchExploreResponseDto, SearchPeopleDto, SearchPlacesDto, @@ -28,6 +29,13 @@ export class SearchController { return this.service.searchMetadata(auth, dto); } + @Post('random') + @HttpCode(HttpStatus.OK) + @Authenticated() + searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise { + return this.service.searchRandom(auth, dto); + } + @Post('smart') @HttpCode(HttpStatus.OK) @Authenticated() diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 9e36cfee8..ddc6c192c 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -119,7 +119,15 @@ class BaseSearchDto { personIds?: string[]; } -export class MetadataSearchDto extends BaseSearchDto { +export class RandomSearchDto extends BaseSearchDto { + @ValidateBoolean({ optional: true }) + withStacked?: boolean; + + @ValidateBoolean({ optional: true }) + withPeople?: boolean; +} + +export class MetadataSearchDto extends RandomSearchDto { @ValidateUUID({ optional: true }) id?: string; @@ -133,12 +141,6 @@ export class MetadataSearchDto extends BaseSearchDto { @Optional() checksum?: string; - @ValidateBoolean({ optional: true }) - withStacked?: boolean; - - @ValidateBoolean({ optional: true }) - withPeople?: boolean; - @IsString() @IsNotEmpty() @Optional() diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index 6578d0a48..0ba524c00 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -116,6 +116,7 @@ export interface SearchPeopleOptions { export interface SearchOrderOptions { orderDirection?: 'ASC' | 'DESC'; + random?: boolean; } export interface SearchPaginationOptions { diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 999e9063e..8115c72cf 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -73,8 +73,13 @@ export class SearchRepository implements ISearchRepository { async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated { let builder = this.assetRepository.createQueryBuilder('asset'); builder = searchAssetBuilder(builder, options); - builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); + + if (options.random) { + // TODO replace with complicated SQL magic after kysely migration + builder.addSelect('RANDOM() as r').orderBy('r'); + } + return paginatedBuilder(builder, { mode: PaginationMode.SKIP_TAKE, skip: (pagination.page - 1) * pagination.size, diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 73ace233d..dc6e71f34 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -6,6 +6,7 @@ import { PersonResponseDto } from 'src/dtos/person.dto'; import { MetadataSearchDto, PlacesResponseDto, + RandomSearchDto, SearchPeopleDto, SearchPlacesDto, SearchResponseDto, @@ -93,6 +94,22 @@ export class SearchService { return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); } + async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { + const userIds = await this.getUserIdsToSearch(auth); + const page = dto.page ?? 1; + const size = dto.size || 250; + const { hasNextPage, items } = await this.searchRepository.searchMetadata( + { page, size }, + { + ...dto, + userIds, + random: true, + }, + ); + + return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); + } + async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { const { machineLearning } = await this.configCore.getConfig({ withCache: false }); if (!isSmartSearchEnabled(machineLearning)) {