From e7edbcdf0416a947860ab972520d5bbfa5d8e13a Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 19 May 2025 17:40:48 -0400 Subject: [PATCH] feat(server): lighter buckets (#17831) * feat(web): lighter timeline buckets * GalleryViewer * weird ssr * Remove generics from AssetInteraction * ensure keys on getAssetInfo, alt-text * empty - trigger ci * re-add alt-text * test fix * update tests * tests * missing import * feat(server): lighter buckets * fix: flappy e2e test * lint * revert settings * unneeded cast * fix after merge * Adapt web client to consume new server response format * test * missing import * lint * Use nulls, make-sql * openapi battle * date->string * tests * tests * lint/tests * lint * test * push aggregation to query * openapi * stack as tuple * openapi * update references to description * update alt text tests * update sql * update sql * update timeline tests * linting, fix expected response * string tuple * fix spec * fix * silly generator * rename patch * minimize sorting * review * lint * lint * sql * test * avoid abbreviations * review comment - type safety in test * merge conflicts * lint * lint/abbreviations * remove unncessary code * review comments * sql * re-add package-lock * use booleans, fix visibility in openapi spec, less cursed controller * update sql * no need to use sql template * array access actually doesn't seem to matter * remove redundant code * re-add sql decorator * unused type * remove null assertions * bad merge * Fix test * shave * extra clean shave * use decorator for content type * redundant types * redundant comment * update comment * unnecessary res --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> Co-authored-by: Alex --- e2e/src/api/specs/timeline.e2e-spec.ts | 78 ++++---- mobile/openapi/README.md | Bin 35659 -> 35685 bytes mobile/openapi/lib/api.dart | Bin 12715 -> 12730 bytes mobile/openapi/lib/api/timeline_api.dart | Bin 9009 -> 8917 bytes mobile/openapi/lib/api_client.dart | Bin 32276 -> 32287 bytes mobile/openapi/lib/api_helper.dart | Bin 6864 -> 6758 bytes .../model/time_bucket_asset_response_dto.dart | Bin 0 -> 8412 bytes .../openapi/lib/model/time_bucket_size.dart | Bin 2629 -> 0 bytes ...to.dart => time_buckets_response_dto.dart} | Bin 3180 -> 3199 bytes open-api/bin/generate-open-api.sh | 3 +- open-api/immich-openapi-specs.json | 174 +++++++++++++++--- .../native/native_class.mustache | 2 +- ...ative_class_nullable_items_in_arrays.patch | 13 ++ open-api/typescript-sdk/src/fetch-client.ts | 40 ++-- server/src/bin/sync-sql.ts | 4 +- server/src/controllers/timeline.controller.ts | 15 +- server/src/dtos/asset-response.dto.ts | 12 +- server/src/dtos/time-bucket.dto.ts | 77 +++++++- server/src/queries/asset.repository.sql | 127 +++++++++---- server/src/repositories/asset.repository.ts | 162 +++++++++++----- server/src/services/sync.service.ts | 3 +- server/src/services/timeline.service.spec.ts | 78 ++------ server/src/services/timeline.service.ts | 23 +-- server/src/utils/bytes.ts | 9 + server/src/utils/database.ts | 3 +- server/src/workers/api.ts | 1 - server/test/fixtures/asset.stub.ts | 4 + .../typescript-sdk/package-lock.json | 6 + .../actions/keep-this-delete-others.svelte | 1 + .../actions/set-visibility-action.svelte | 4 +- .../assets/thumbnail/thumbnail.svelte | 4 +- .../photos-page/actions/archive-action.svelte | 8 +- .../stores/asset-interaction.svelte.spec.ts | 6 +- .../lib/stores/asset-interaction.svelte.ts | 4 +- web/src/lib/stores/assets-store.spec.ts | 59 +++--- web/src/lib/stores/assets-store.svelte.ts | 161 +++++++++++----- web/src/lib/utils/actions.ts | 4 +- web/src/lib/utils/thumbnail-util.spec.ts | 12 +- web/src/lib/utils/thumbnail-util.ts | 15 +- web/src/lib/utils/timeline-util.ts | 20 +- web/src/test-data/factories/asset-factory.ts | 57 +++++- 41 files changed, 817 insertions(+), 372 deletions(-) create mode 100644 mobile/openapi/lib/model/time_bucket_asset_response_dto.dart delete mode 100644 mobile/openapi/lib/model/time_bucket_size.dart rename mobile/openapi/lib/model/{time_bucket_response_dto.dart => time_buckets_response_dto.dart} (62%) create mode 100644 open-api/templates/mobile/serialization/native/native_class_nullable_items_in_arrays.patch create mode 100644 typescript-open-api/typescript-sdk/package-lock.json diff --git a/e2e/src/api/specs/timeline.e2e-spec.ts b/e2e/src/api/specs/timeline.e2e-spec.ts index 93ba8b652..79bf748e9 100644 --- a/e2e/src/api/specs/timeline.e2e-spec.ts +++ b/e2e/src/api/specs/timeline.e2e-spec.ts @@ -1,4 +1,4 @@ -import { AssetMediaResponseDto, AssetVisibility, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk'; +import { AssetMediaResponseDto, AssetVisibility, LoginResponseDto, SharedLinkType } from '@immich/sdk'; import { DateTime } from 'luxon'; import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; @@ -52,7 +52,7 @@ describe('/timeline', () => { describe('GET /timeline/buckets', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get('/timeline/buckets').query({ size: TimeBucketSize.Month }); + const { status, body } = await request(app).get('/timeline/buckets'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); @@ -60,8 +60,7 @@ describe('/timeline', () => { it('should get time buckets by month', async () => { const { status, body } = await request(app) .get('/timeline/buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month }); + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`); expect(status).toBe(200); expect(body).toEqual( @@ -78,33 +77,17 @@ describe('/timeline', () => { assetIds: userAssets.map(({ id }) => id), }); - const { status, body } = await request(app) - .get('/timeline/buckets') - .query({ key: sharedLink.key, size: TimeBucketSize.Month }); + const { status, body } = await request(app).get('/timeline/buckets').query({ key: sharedLink.key }); expect(status).toBe(400); expect(body).toEqual(errorDto.noPermission); }); - it('should get time buckets by day', async () => { - const { status, body } = await request(app) - .get('/timeline/buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Day }); - - expect(status).toBe(200); - expect(body).toEqual([ - { count: 2, timeBucket: '1970-02-11T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-02-10T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, - ]); - }); - it('should return error if time bucket is requested with partners asset and archived', async () => { const req1 = await request(app) .get('/timeline/buckets') .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, visibility: AssetVisibility.Archive }); + .query({ withPartners: true, visibility: AssetVisibility.Archive }); expect(req1.status).toBe(400); expect(req1.body).toEqual(errorDto.badRequest()); @@ -112,7 +95,7 @@ describe('/timeline', () => { const req2 = await request(app) .get('/timeline/buckets') .set('Authorization', `Bearer ${user.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, visibility: undefined }); + .query({ withPartners: true, visibility: undefined }); expect(req2.status).toBe(400); expect(req2.body).toEqual(errorDto.badRequest()); @@ -122,7 +105,7 @@ describe('/timeline', () => { const req1 = await request(app) .get('/timeline/buckets') .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true }); + .query({ withPartners: true, isFavorite: true }); expect(req1.status).toBe(400); expect(req1.body).toEqual(errorDto.badRequest()); @@ -130,7 +113,7 @@ describe('/timeline', () => { const req2 = await request(app) .get('/timeline/buckets') .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false }); + .query({ withPartners: true, isFavorite: false }); expect(req2.status).toBe(400); expect(req2.body).toEqual(errorDto.badRequest()); @@ -140,7 +123,7 @@ describe('/timeline', () => { const req = await request(app) .get('/timeline/buckets') .set('Authorization', `Bearer ${user.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true }); + .query({ withPartners: true, isTrashed: true }); expect(req.status).toBe(400); expect(req.body).toEqual(errorDto.badRequest()); @@ -150,7 +133,6 @@ describe('/timeline', () => { describe('GET /timeline/bucket', () => { it('should require authentication', async () => { const { status, body } = await request(app).get('/timeline/bucket').query({ - size: TimeBucketSize.Month, timeBucket: '1900-01-01', }); @@ -161,11 +143,27 @@ describe('/timeline', () => { it('should handle 5 digit years', async () => { const { status, body } = await request(app) .get('/timeline/bucket') - .query({ size: TimeBucketSize.Month, timeBucket: '012345-01-01' }) + .query({ timeBucket: '012345-01-01' }) .set('Authorization', `Bearer ${timeBucketUser.accessToken}`); expect(status).toBe(200); - expect(body).toEqual([]); + expect(body).toEqual({ + city: [], + country: [], + duration: [], + id: [], + visibility: [], + isFavorite: [], + isImage: [], + isTrashed: [], + livePhotoVideoId: [], + localDateTime: [], + ownerId: [], + projectionType: [], + ratio: [], + status: [], + thumbhash: [], + }); }); // TODO enable date string validation while still accepting 5 digit years @@ -173,7 +171,7 @@ describe('/timeline', () => { // const { status, body } = await request(app) // .get('/timeline/bucket') // .set('Authorization', `Bearer ${user.accessToken}`) - // .query({ size: TimeBucketSize.Month, timeBucket: 'foo' }); + // .query({ timeBucket: 'foo' }); // expect(status).toBe(400); // expect(body).toEqual(errorDto.badRequest); @@ -183,10 +181,26 @@ describe('/timeline', () => { const { status, body } = await request(app) .get('/timeline/bucket') .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10' }); + .query({ timeBucket: '1970-02-10' }); expect(status).toBe(200); - expect(body).toEqual([]); + expect(body).toEqual({ + city: [], + country: [], + duration: [], + id: [], + visibility: [], + isFavorite: [], + isImage: [], + isTrashed: [], + livePhotoVideoId: [], + localDateTime: [], + ownerId: [], + projectionType: [], + ratio: [], + status: [], + thumbhash: [], + }); }); }); }); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 620fc976643ed91992eac6d6534be3ac0ad7bd0f..2c5dea7f19cde6a18eab16c22acc41c6aeb731c2 100644 GIT binary patch delta 49 zcmX>-jp^w$rVR!y%#Ovyll5BUCi}-pPEKgynw%If%oUQEo9a}WoSj-S*)dOab5F~D FHUQnA66gQ` delta 76 zcmaDljp_6>rVR!y(m|=k1^Ic!sV*h?u^K7)$@=;snYpP>rODZ;lLL!HCug;ANd{+D QrJ_rgfCM+sZ8^aP0BhqNRR910 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8710298d7db8192ae02c84e5f6227c6f5687d17e..541614ca553dea340c121494b910ea226f9d60e0 100644 GIT binary patch delta 24 gcmZ3TyeoObSFOqYGHR1wYH?4#z^k;`QoD!^0FkW2q=J@0G+ZV)vnq9RqOj!T z4?G7a-x3j@{Ev@!@>||a37`sluwESnI1{XRr(E0QccR{#Ls(ZaF$2w>9KdeQ4YEXC z2dsYbMG0YcAQ!9{C?^b(19718VDXC*MU!hKqbJ95sBf0!yhOH>wL>yMhM^7D#QT}txp?G(~eOAzuJguJy1>~}%_G+Cg7t)PB~IUd3Xxqb6F`2;5R zqSTVoqCADkl0u4;;{_C4bQIJPR-rpZT}M+Pu~-4O0&8H*C@4%`FEkkxcblt(TNx+u z-P-(4G?aOBtt1~aR5XE6z$de~#0Ftd@#NjSGEBu9llcDe6^Cb*WO$U66nN&PxXINpNOWYOF#rkesZ~q%v8R>FDGIyd0C|WDib$&8#Ad zst~MBM`1EQOXlPWqUSd!vaVv9%q1B=`3So@AIJ=K9k3EjP3z5yoRQ4L*mI24vA8(3 zWb%7i88Hof*0oHo;x}Xi*#dOIYW}p%#q!sgpqfLK)S#?3CEv+r!sWPZ-i_ot@yR-( ze8O0)oUFhSv3Y{%O=c|qnyko~u=$AOW}IONv*7~JM{>AK2ZkQVJ1QU-K)tkCQr?<* Ma;TEQlW|TflfY>T15;^wlYwX!lkaH>lRzgkvxsV! E6X$^wj{pDw diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 4928adf76766d3a50e9962ebe739d6e0acd434b9..1618f4a670a9a3f210baafa95dad6fe580fcb66e 100644 GIT binary patch delta 12 Tcmca$`pjfQgW%>@g3mYrDR%|^ delta 54 ycmaE6a=~;%gP>++ZmLsha&~G-aAsAirb0EB0uU6XmXsFdDTE+PZnhUx=L7&N`V%4m diff --git a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..3f1406c019dbca46652f2dc0784d2f1241c80095 GIT binary patch literal 8412 zcmcIpZExE)5dNND!4^g%W16$=Q{hf-yCogcwaw6G1BM_l6pN18%91Nm34%8NeRs!K zy(QYZ1V|zt@5s}=@j2??VDI37z5Mg^J4xLe5q~-LQnw%d)fc zIG8ExLRABkSzV<{ltsU&$Oh>0ReDucLh+&b<8695IC)y7a>EDK&czk~V^gZ~FYs4+ zJj7M5()=)09Iex@M0s8CYDj)pl^^*E-~9AuH?RO%rhjb+sMV+bDYdD$7aMrN{^=_r z#f8Yb-nyMaEHU;*NcHmvRf*#A`GTQW`^eg*Ju}vy?WqM5nWtTpWeybQ#qJFGuS+(b z^Ak?fw%gh)3zmm2>rNYYwI{#`O<#G2qTVhTIf-bPVv%;t@nOvL6Qm|rkQI;uQ>N-& z&aI6DLoZ+xV)tvi_j2y^bClqJIX^C#_bD;GD6e=`LG;2_Xlbk$pk`UIB#BNgFz#5X zHoTg$1pR_l38Fc|JdW+m*RT2symUteU{9Vfq;G(BV%JOX7*`}l*nRu_fecKPC>#wkCUW0MlpIACu4#frOe|Pxpo4?tW6oU zN|+?DL^vEHZ?Xu&NLRlxGR^#oTlqF-1xmtL=i(TCN;Z?CWB6SX#&Fn~@;XM&CuSTZ zsV=l(mWo2LOQ;iAwtp=%I1d(-Rt~=)Y$^DPOPN+T2sJrXWsUIS$w)?|q>I}V@Oz^KOe=xKK(K;=e*e9%C$%N{da}kW)vnxADbcM+H z1!=C(v|W-++LTr)0EW;63|j3^OR1cn_TK zljYy0I|Ezb7`Op=E>;V${cBkkV0jHx*P=*s_7O;cjXK<4Zl6)!Fyc6?_R$mj=fLU! zZks{-Cg9P87Mt~#=d+X+qsTl9!X*UPP8;vnfXVr~!%Y4TIf>9=Ga(Q%8d8h39S~lk zJ<}Ep10|3O$t@8RL!Q|XT5QdT^4v{&ONipl^U|=zLP6+RXxL(LQR&$nsm1Ce)$@9q zX0E6+qqsxDEdufX*s-y+$e3|G;LHD-^8%jRP^PBBg3Z8SF`R(Kz~7-NmJrv+!L)O9(&Ttxa{#>I?7 zhIfrjR&(BK$u1=0(G`^cq#p0fz6kFPDMXPkygEu!g6mDnOgmsNR<3(eUY zKsBGbjd96&tKmkPIWfFWu1bwi%ke&hX144()hq!r<9^)&=7&&DWKJ!r!}lvhY!9M} zE9+h*M;c|T#$nM_k^1j%2r&aE0`2T^Gs0>biok0;gkI+G3N-U4+!)-s9A5Nj!wE1|~QyJ&w5F^`7@3G;2Z&00P=6eO`k?@*$$yZ$^3t>C6M+|eRL z+&N}n=t9$CBIwNs6vej|?P;u6aardIo4h-=IXm~j5f?`Yf~vtSCBeCwknc)$wB4y2 zyE%2Kk7FO{unZSRWQwmgOS4R) z-9S9oPUAa_van4! zTERL6bh*T887ecNla_-A?iXUX^jiIsG4%>&(b?>JCCB{aE z&zMj&M@Udf?u|5f!)6E!`>9#3gMf>fP4>Baid_`;)C|}(2%ZCWy5TNP?00YmAt~W> zOCbnq2%!rOxA$DawwO36d;Q_$9ezVLv7rw}EBrgky0WnNLWa?|Zu&^sH?8cx8M+S8 zjG+OuPrf~J6y1`zp+)wF(1FjE-oUAbXu+WwwlJ8XYVczQ{=hbb;KW1<+%-bE{RiH5 Bgz5kQ literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/time_bucket_size.dart b/mobile/openapi/lib/model/time_bucket_size.dart deleted file mode 100644 index e843b43f43fb53ee8da2c10f1b362552e150a695..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2629 zcmai0ZExC05dO}um`JCFNSwL5PZd2%IfYzPwNN$WLxqsF*kjm>z3bau2XR&Y`_1gy z4mh+$0@m!j&oj?39QKAonBFfJzy3X&&%R8Tvun7y{Wu%I^#bOLC45?3-`;$^ATgGF zmkQ&?e?-5(?(w7CDs3>%w23pB;|MBMSxjThu|VYp&9f{@ZQNN6k?+O9#%1MVga202 zkh&5DeU`$|w?kvexUuKe6RoTh%EdAzhcc6Byt+N>ESCyxoNKiuF}D*ce*IgV?1Zts z9=UU%Gtea}QObzkZ+g96k_&5LDGU6oN*>VN$^Q_(^#F#H|7~zCg+maaa1W@e0`}7F z!0ix9qaP`-6jqNSuW%iao{zX=FpB$bCvD`VuE=dLza$ftO3~y8skY#6IYgEXo(e2B zXxMBfmB``FnO4%9d8Z%I7>a>J>Px4?p??MAx520vZLdFAt-6;~WG9n4`0MFc7{5%x zT++oQg%5Bd{%J8^zCW+vuUMtd_@lJG2_ny-)Yi&Pjv+(~Qia4qEqZ2^|toG$##Q-oPNCNI{Sw@GJtOzt#jBKy%$`TF=QgQIC#49cx)hG&{nxS zmp3KInad3NY!C=UzTU510Y695&b6Brr8_hh$)+~Y57;8V+yfSUzHht+Y`y?RTQ*Ym zz~BHv2tngw464fWV}k%8g-E1nTeYAE@Z?iyzQdD2R~gl4In7D+n=*~sf_eW>y(ea_ zA+$XrTvIK=m=h&9NQ45y7&mPVbe4X|6XeP3q7Iy{$78^$7EW>nAV9S1}}VF=H$)AwX+!U(16pN;`=(w<=W z5Qwdv@@qd4mMC-8)X1Y@y<*`tmq~QeWQwDW&fGExb&~i%;u;*;Z;`H+^(4M2cM^&dP9WARc zhm)4T@p7}me&j!);Sp~%jcEHKk(>ql&6rG)OGJS zJ?CaOtk!muh>LsKk{YuW3&IPgu(W2AUAR5wk3?96u!}}@2Ro5OME-#bnO17~u!_E? z;cuUHQp^Og?1++VUL5E}o5pcKEaA9yFg4qX{}}m}_Al?%gQA9pRUZ04&=2Sw!7rS4 wSf`DTAFv;@D91%Ww{<9|GtPvpn&7x)XV+gxWAe-|ZAQ{J~3?v&F&jLw5reBQ3n?0DBK+;|;D}m%^77rk4%~}W~ z*RUQ0lDTY2K=M3WHIVdT-wz}?IedU*D8~gLsmgf(NIvEC2a+aS1we8M*DWAf$Ndff Ds^KYH delta 84 zcmV-a0IUE180;9ZF#(e@0WFhY0V9)y0nL+A0{XK}0|Eh)Pz0%y`UFUmLj`e@wFSYG qcLrjU+6H@*RtLY676?p}V+hcbGzq|y^$AatMGA0}whG#lkqhorl_7lq diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index e2badc6df..d6f133348 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -OPENAPI_GENERATOR_VERSION=v7.8.0 +OPENAPI_GENERATOR_VERSION=v7.12.0 # usage: ./bin/generate-open-api.sh @@ -8,6 +8,7 @@ function dart { cd ./templates/mobile/serialization/native wget -O native_class.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache patch --no-backup-if-mismatch -u native_class.mustache {{/isArray}}{{^isArray}}{{{datatypeWithEnum}}}{{/isArray}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}}; {{/vars}} @override diff --git a/open-api/templates/mobile/serialization/native/native_class_nullable_items_in_arrays.patch b/open-api/templates/mobile/serialization/native/native_class_nullable_items_in_arrays.patch new file mode 100644 index 000000000..a59e30091 --- /dev/null +++ b/open-api/templates/mobile/serialization/native/native_class_nullable_items_in_arrays.patch @@ -0,0 +1,13 @@ +diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache b/open-api/templates/mobile/serialization/native/native_class.mustache +index 9a7b1439b..9f40d5b0b 100644 +--- a/open-api/templates/mobile/serialization/native/native_class.mustache ++++ b/open-api/templates/mobile/serialization/native/native_class.mustache +@@ -32,7 +32,7 @@ class {{{classname}}} { + {{/required}} + {{/isNullable}} + {{/isEnum}} +- {{{datatypeWithEnum}}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}}; ++ {{#isArray}}{{#uniqueItems}}Set{{/uniqueItems}}{{^uniqueItems}}List{{/uniqueItems}}<{{{items.dataType}}}{{#items.isNullable}}?{{/items.isNullable}}>{{/isArray}}{{^isArray}}{{{datatypeWithEnum}}}{{/isArray}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}}; + + {{/vars}} + @override diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c293b2aa6..5358cdfec 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1420,7 +1420,25 @@ export type TagBulkAssetsResponseDto = { export type TagUpdateDto = { color?: string | null; }; -export type TimeBucketResponseDto = { +export type TimeBucketAssetResponseDto = { + city: (string | null)[]; + country: (string | null)[]; + duration: (string | null)[]; + id: string[]; + isFavorite: boolean[]; + isImage: boolean[]; + isTrashed: boolean[]; + livePhotoVideoId: (string | null)[]; + localDateTime: string[]; + ownerId: string[]; + projectionType: (string | null)[]; + ratio: number[]; + /** (stack ID, stack asset count) tuple */ + stack?: (string[] | null)[]; + thumbhash: (string | null)[]; + visibility: AssetVisibility[]; +}; +export type TimeBucketsResponseDto = { count: number; timeBucket: string; }; @@ -3367,14 +3385,15 @@ export function tagAssets({ id, bulkIdsDto }: { body: bulkIdsDto }))); } -export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, visibility, withPartners, withStacked }: { +export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, page, pageSize, personId, tagId, timeBucket, userId, visibility, withPartners, withStacked }: { albumId?: string; isFavorite?: boolean; isTrashed?: boolean; key?: string; order?: AssetOrder; + page?: number; + pageSize?: number; personId?: string; - size: TimeBucketSize; tagId?: string; timeBucket: string; userId?: string; @@ -3384,15 +3403,16 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AssetResponseDto[]; + data: TimeBucketAssetResponseDto; }>(`/timeline/bucket${QS.query(QS.explode({ albumId, isFavorite, isTrashed, key, order, + page, + pageSize, personId, - size, tagId, timeBucket, userId, @@ -3403,14 +3423,13 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers ...opts })); } -export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, size, tagId, userId, visibility, withPartners, withStacked }: { +export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, tagId, userId, visibility, withPartners, withStacked }: { albumId?: string; isFavorite?: boolean; isTrashed?: boolean; key?: string; order?: AssetOrder; personId?: string; - size: TimeBucketSize; tagId?: string; userId?: string; visibility?: AssetVisibility; @@ -3419,7 +3438,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: TimeBucketResponseDto[]; + data: TimeBucketsResponseDto[]; }>(`/timeline/buckets${QS.query(QS.explode({ albumId, isFavorite, @@ -3427,7 +3446,6 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per key, order, personId, - size, tagId, userId, visibility, @@ -3921,7 +3939,3 @@ export enum OAuthTokenEndpointAuthMethod { ClientSecretPost = "client_secret_post", ClientSecretBasic = "client_secret_basic" } -export enum TimeBucketSize { - Day = "DAY", - Month = "MONTH" -} diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts index b791358a9..a114830e0 100644 --- a/server/src/bin/sync-sql.ts +++ b/server/src/bin/sync-sql.ts @@ -72,7 +72,9 @@ class SqlGenerator { await rm(this.options.targetDir, { force: true, recursive: true }); await mkdir(this.options.targetDir); - process.env.DB_HOSTNAME = 'localhost'; + if (!process.env.DB_HOSTNAME) { + process.env.DB_HOSTNAME = 'localhost'; + } const { database, cls, otel } = new ConfigRepository().getEnv(); const moduleFixture = await Test.createTestingModule({ diff --git a/server/src/controllers/timeline.controller.ts b/server/src/controllers/timeline.controller.ts index 92de84d34..b4ee04262 100644 --- a/server/src/controllers/timeline.controller.ts +++ b/server/src/controllers/timeline.controller.ts @@ -1,8 +1,7 @@ -import { Controller, Get, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { Controller, Get, Header, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; -import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; +import { TimeBucketAssetDto, TimeBucketAssetResponseDto, TimeBucketDto } from 'src/dtos/time-bucket.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { TimelineService } from 'src/services/timeline.service'; @@ -14,13 +13,15 @@ export class TimelineController { @Get('buckets') @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true }) - getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise { + getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto) { return this.service.getTimeBuckets(auth, dto); } @Get('bucket') @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true }) - getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise { - return this.service.getTimeBucket(auth, dto) as Promise; + @ApiOkResponse({ type: TimeBucketAssetResponseDto }) + @Header('Content-Type', 'application/json') + getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto) { + return this.service.getTimeBucket(auth, dto); } } diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 2a44a34b5..4c1f2571e 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -13,6 +13,7 @@ import { import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { mimeTypes } from 'src/utils/mime-types'; export class SanitizedAssetResponseDto { @@ -140,15 +141,6 @@ const mapStack = (entity: { stack?: Stack | null }) => { }; }; -// if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings -export const hexOrBufferToBase64 = (encoded: string | Buffer) => { - if (typeof encoded === 'string') { - return Buffer.from(encoded.slice(2), 'hex').toString('base64'); - } - - return encoded.toString('base64'); -}; - export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): AssetResponseDto { const { stripMetadata = false, withStack = false } = options; @@ -192,7 +184,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset tags: entity.tags?.map((tag) => mapTag(tag)), people: peopleWithFaces(entity.faces), unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)), - checksum: hexOrBufferToBase64(entity.checksum), + checksum: hexOrBufferToBase64(entity.checksum)!, stack: withStack ? mapStack(entity) : undefined, isOffline: entity.isOffline, hasMetadata: true, diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index 51d46871a..f68ce9307 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -1,15 +1,10 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; + +import { IsEnum, IsInt, IsString, Min } from 'class-validator'; import { AssetOrder, AssetVisibility } from 'src/enum'; -import { TimeBucketSize } from 'src/repositories/asset.repository'; import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateUUID } from 'src/validation'; export class TimeBucketDto { - @IsNotEmpty() - @IsEnum(TimeBucketSize) - @ApiProperty({ enum: TimeBucketSize, enumName: 'TimeBucketSize' }) - size!: TimeBucketSize; - @ValidateUUID({ optional: true }) userId?: string; @@ -46,9 +41,75 @@ export class TimeBucketDto { export class TimeBucketAssetDto extends TimeBucketDto { @IsString() timeBucket!: string; + + @IsInt() + @Min(1) + @Optional() + page?: number; + + @IsInt() + @Min(1) + @Optional() + pageSize?: number; } -export class TimeBucketResponseDto { +export class TimelineStackResponseDto { + id!: string; + primaryAssetId!: string; + assetCount!: number; +} + +export class TimeBucketAssetResponseDto { + id!: string[]; + + ownerId!: string[]; + + ratio!: number[]; + + isFavorite!: boolean[]; + + @ApiProperty({ enum: AssetVisibility, enumName: 'AssetVisibility', isArray: true }) + visibility!: AssetVisibility[]; + + isTrashed!: boolean[]; + + isImage!: boolean[]; + + @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + thumbhash!: (string | null)[]; + + localDateTime!: string[]; + + @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + duration!: (string | null)[]; + + @ApiProperty({ + type: 'array', + items: { + type: 'array', + items: { type: 'string' }, + minItems: 2, + maxItems: 2, + nullable: true, + }, + description: '(stack ID, stack asset count) tuple', + }) + stack?: ([string, string] | null)[]; + + @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + projectionType!: (string | null)[]; + + @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + livePhotoVideoId!: (string | null)[]; + + @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + city!: (string | null)[]; + + @ApiProperty({ type: 'array', items: { type: 'string', nullable: true } }) + country!: (string | null)[]; +} + +export class TimeBucketsResponseDto { @ApiProperty({ type: 'string' }) timeBucket!: string; diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index f4f13c4d2..8f25cbbd4 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -235,14 +235,14 @@ limit with "assets" as ( select - date_trunc($1, "localDateTime" at time zone 'UTC') at time zone 'UTC' as "timeBucket" + date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC' as "timeBucket" from "assets" where "assets"."deletedAt" is null and ( - "assets"."visibility" = $2 - or "assets"."visibility" = $3 + "assets"."visibility" = $1 + or "assets"."visibility" = $2 ) ) select @@ -256,40 +256,101 @@ order by "timeBucket" desc -- AssetRepository.getTimeBucket -select - "assets".*, - to_json("exif") as "exifInfo", - to_json("stacked_assets") as "stack" -from - "assets" - left join "exif" on "assets"."id" = "exif"."assetId" - left join "asset_stack" on "asset_stack"."id" = "assets"."stackId" - left join lateral ( +with + "cte" as ( select - "asset_stack".*, - count("stacked") as "assetCount" + "assets"."duration", + "assets"."id", + "assets"."visibility", + "assets"."isFavorite", + assets.type = 'IMAGE' as "isImage", + assets."deletedAt" is null as "isTrashed", + "assets"."livePhotoVideoId", + "assets"."localDateTime", + "assets"."ownerId", + "assets"."status", + encode("assets"."thumbhash", 'base64') as "thumbhash", + "exif"."city", + "exif"."country", + "exif"."projectionType", + coalesce( + case + when exif."exifImageHeight" = 0 + or exif."exifImageWidth" = 0 then 1 + when "exif"."orientation" in ('5', '6', '7', '8', '-90', '90') then round( + exif."exifImageHeight"::numeric / exif."exifImageWidth"::numeric, + 3 + ) + else round( + exif."exifImageWidth"::numeric / exif."exifImageHeight"::numeric, + 3 + ) + end, + 1 + ) as "ratio", + "stack" from - "assets" as "stacked" + "assets" + inner join "exif" on "assets"."id" = "exif"."assetId" + left join lateral ( + select + array[stacked."stackId"::text, count('stacked')::text] as "stack" + from + "assets" as "stacked" + where + "stacked"."stackId" = "assets"."stackId" + and "stacked"."deletedAt" is null + and "stacked"."visibility" != $1 + group by + "stacked"."stackId" + ) as "stacked_assets" on true where - "stacked"."stackId" = "asset_stack"."id" - and "stacked"."deletedAt" is null - and "stacked"."visibility" != $1 - group by - "asset_stack"."id" - ) as "stacked_assets" on "asset_stack"."id" is not null -where - ( - "asset_stack"."primaryAssetId" = "assets"."id" - or "assets"."stackId" is null + "assets"."deletedAt" is null + and ( + "assets"."visibility" = $2 + or "assets"."visibility" = $3 + ) + and date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC' = $4 + and ( + "assets"."visibility" = $5 + or "assets"."visibility" = $6 + ) + and not exists ( + select + from + "asset_stack" + where + "asset_stack"."id" = "assets"."stackId" + and "asset_stack"."primaryAssetId" != "assets"."id" + ) + order by + "assets"."localDateTime" desc + ), + "agg" as ( + select + coalesce(array_agg("city"), '{}') as "city", + coalesce(array_agg("country"), '{}') as "country", + coalesce(array_agg("duration"), '{}') as "duration", + coalesce(array_agg("id"), '{}') as "id", + coalesce(array_agg("visibility"), '{}') as "visibility", + coalesce(array_agg("isFavorite"), '{}') as "isFavorite", + coalesce(array_agg("isImage"), '{}') as "isImage", + coalesce(array_agg("isTrashed"), '{}') as "isTrashed", + coalesce(array_agg("livePhotoVideoId"), '{}') as "livePhotoVideoId", + coalesce(array_agg("localDateTime"), '{}') as "localDateTime", + coalesce(array_agg("ownerId"), '{}') as "ownerId", + coalesce(array_agg("projectionType"), '{}') as "projectionType", + coalesce(array_agg("ratio"), '{}') as "ratio", + coalesce(array_agg("status"), '{}') as "status", + coalesce(array_agg("thumbhash"), '{}') as "thumbhash", + coalesce(json_agg("stack"), '[]') as "stack" + from + "cte" ) - and "assets"."deletedAt" is null - and ( - "assets"."visibility" = $2 - or "assets"."visibility" = $3 - ) - and date_trunc($4, "localDateTime" at time zone 'UTC') at time zone 'UTC' = $5 -order by - "assets"."localDateTime" desc +select + to_json(agg)::text as "assets" +from + "agg" -- AssetRepository.getDuplicates with diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index e118bf39a..f2f323f71 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -68,7 +68,6 @@ export interface AssetBuilderOptions { } export interface TimeBucketOptions extends AssetBuilderOptions { - size: TimeBucketSize; order?: AssetOrder; } @@ -539,7 +538,7 @@ export class AssetRepository { .with('assets', (qb) => qb .selectFrom('assets') - .select(truncatedDate(options.size).as('timeBucket')) + .select(truncatedDate(TimeBucketSize.MONTH).as('timeBucket')) .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) .$if(options.visibility === undefined, withDefaultVisibility) @@ -581,53 +580,126 @@ export class AssetRepository { ); } - @GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }] }) - async getTimeBucket(timeBucket: string, options: TimeBucketOptions) { - return this.db - .selectFrom('assets') - .selectAll('assets') - .$call(withExif) - .$if(!!options.albumId, (qb) => + @GenerateSql({ + params: [DummyValue.TIME_BUCKET, { withStacked: true }], + }) + getTimeBucket(timeBucket: string, options: TimeBucketOptions) { + const query = this.db + .with('cte', (qb) => qb - .innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id') - .where('albums_assets_assets.albumsId', '=', options.albumId!), + .selectFrom('assets') + .innerJoin('exif', 'assets.id', 'exif.assetId') + .select((eb) => [ + 'assets.duration', + 'assets.id', + 'assets.visibility', + 'assets.isFavorite', + sql`assets.type = 'IMAGE'`.as('isImage'), + sql`assets."deletedAt" is null`.as('isTrashed'), + 'assets.livePhotoVideoId', + 'assets.localDateTime', + 'assets.ownerId', + 'assets.status', + eb.fn('encode', ['assets.thumbhash', sql.lit('base64')]).as('thumbhash'), + 'exif.city', + 'exif.country', + 'exif.projectionType', + eb.fn + .coalesce( + eb + .case() + .when(sql`exif."exifImageHeight" = 0 or exif."exifImageWidth" = 0`) + .then(eb.lit(1)) + .when('exif.orientation', 'in', sql`('5', '6', '7', '8', '-90', '90')`) + .then(sql`round(exif."exifImageHeight"::numeric / exif."exifImageWidth"::numeric, 3)`) + .else(sql`round(exif."exifImageWidth"::numeric / exif."exifImageHeight"::numeric, 3)`) + .end(), + eb.lit(1), + ) + .as('ratio'), + ]) + .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) + .$if(options.visibility == undefined, withDefaultVisibility) + .$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!)) + .where(truncatedDate(TimeBucketSize.MONTH), '=', timeBucket.replace(/^[+-]/, '')) + .$if(!!options.albumId, (qb) => + qb.where((eb) => + eb.exists( + eb + .selectFrom('albums_assets_assets') + .whereRef('albums_assets_assets.assetsId', '=', 'assets.id') + .where('albums_assets_assets.albumsId', '=', asUuid(options.albumId!)), + ), + ), + ) + .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) + .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) + .$if(options.visibility == undefined, withDefaultVisibility) + .$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!)) + .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) + .$if(!!options.withStacked, (qb) => + qb + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom('asset_stack') + .whereRef('asset_stack.id', '=', 'assets.stackId') + .whereRef('asset_stack.primaryAssetId', '!=', 'assets.id'), + ), + ), + ) + .leftJoinLateral( + (eb) => + eb + .selectFrom('assets as stacked') + .select(sql`array[stacked."stackId"::text, count('stacked')::text]`.as('stack')) + .whereRef('stacked.stackId', '=', 'assets.stackId') + .where('stacked.deletedAt', 'is', null) + .where('stacked.visibility', '!=', AssetVisibility.ARCHIVE) + .groupBy('stacked.stackId') + .as('stacked_assets'), + (join) => join.onTrue(), + ) + .select('stack'), + ) + .$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!)) + .$if(options.isDuplicate !== undefined, (qb) => + qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null), + ) + .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) + .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) + .orderBy('assets.localDateTime', options.order ?? 'desc'), ) - .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) - .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) - .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) - .$if(!!options.withStacked, (qb) => + .with('agg', (qb) => qb - .leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId') - .where((eb) => - eb.or([eb('asset_stack.primaryAssetId', '=', eb.ref('assets.id')), eb('assets.stackId', 'is', null)]), - ) - .leftJoinLateral( - (eb) => - eb - .selectFrom('assets as stacked') - .selectAll('asset_stack') - .select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount')) - .whereRef('stacked.stackId', '=', 'asset_stack.id') - .where('stacked.deletedAt', 'is', null) - .where('stacked.visibility', '!=', AssetVisibility.ARCHIVE) - .groupBy('asset_stack.id') - .as('stacked_assets'), - (join) => join.on('asset_stack.id', 'is not', null), - ) - .select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo()).as('stack')), + .selectFrom('cte') + .select((eb) => [ + eb.fn.coalesce(eb.fn('array_agg', ['city']), sql.lit('{}')).as('city'), + eb.fn.coalesce(eb.fn('array_agg', ['country']), sql.lit('{}')).as('country'), + eb.fn.coalesce(eb.fn('array_agg', ['duration']), sql.lit('{}')).as('duration'), + eb.fn.coalesce(eb.fn('array_agg', ['id']), sql.lit('{}')).as('id'), + eb.fn.coalesce(eb.fn('array_agg', ['visibility']), sql.lit('{}')).as('visibility'), + eb.fn.coalesce(eb.fn('array_agg', ['isFavorite']), sql.lit('{}')).as('isFavorite'), + eb.fn.coalesce(eb.fn('array_agg', ['isImage']), sql.lit('{}')).as('isImage'), + // TODO: isTrashed is redundant as it will always be all true or false depending on the options + eb.fn.coalesce(eb.fn('array_agg', ['isTrashed']), sql.lit('{}')).as('isTrashed'), + eb.fn.coalesce(eb.fn('array_agg', ['livePhotoVideoId']), sql.lit('{}')).as('livePhotoVideoId'), + eb.fn.coalesce(eb.fn('array_agg', ['localDateTime']), sql.lit('{}')).as('localDateTime'), + eb.fn.coalesce(eb.fn('array_agg', ['ownerId']), sql.lit('{}')).as('ownerId'), + eb.fn.coalesce(eb.fn('array_agg', ['projectionType']), sql.lit('{}')).as('projectionType'), + eb.fn.coalesce(eb.fn('array_agg', ['ratio']), sql.lit('{}')).as('ratio'), + eb.fn.coalesce(eb.fn('array_agg', ['status']), sql.lit('{}')).as('status'), + eb.fn.coalesce(eb.fn('array_agg', ['thumbhash']), sql.lit('{}')).as('thumbhash'), + ]) + .$if(!!options.withStacked, (qb) => + qb.select((eb) => eb.fn.coalesce(eb.fn('json_agg', ['stack']), sql.lit('[]')).as('stack')), + ), ) - .$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!)) - .$if(options.isDuplicate !== undefined, (qb) => - qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null), - ) - .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) - .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) - .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) - .$if(options.visibility == undefined, withDefaultVisibility) - .$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!)) - .where(truncatedDate(options.size), '=', timeBucket.replace(/^[+-]/, '')) - .orderBy('assets.localDateTime', options.order ?? 'desc') - .execute(); + .selectFrom('agg') + .select(sql`to_json(agg)::text`.as('assets')); + + return query.executeTakeFirstOrThrow(); } @GenerateSql({ params: [DummyValue.UUID] }) diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 6ad488c48..bd3c09098 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -4,7 +4,7 @@ import { DateTime } from 'luxon'; import { Writable } from 'node:stream'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; import { SessionSyncCheckpoints } from 'src/db'; -import { AssetResponseDto, hexOrBufferToBase64, mapAsset } from 'src/dtos/asset-response.dto'; +import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetDeltaSyncDto, @@ -18,6 +18,7 @@ import { AssetVisibility, DatabaseAction, EntityType, Permission, SyncEntityType import { BaseService } from 'src/services/base.service'; import { SyncAck } from 'src/types'; import { getMyPartnerIds } from 'src/utils/asset.util'; +import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { setIsEqual } from 'src/utils/set'; import { fromAck, serialize } from 'src/utils/sync'; diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index 1447594d4..1669b1eac 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -1,10 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import { AssetVisibility } from 'src/enum'; -import { TimeBucketSize } from 'src/repositories/asset.repository'; import { TimelineService } from 'src/services/timeline.service'; -import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(TimelineService.name, () => { @@ -19,13 +16,10 @@ describe(TimelineService.name, () => { it("should return buckets if userId and albumId aren't set", async () => { mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); - await expect( - sut.getTimeBuckets(authStub.admin, { - size: TimeBucketSize.DAY, - }), - ).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }])); + await expect(sut.getTimeBuckets(authStub.admin, {})).resolves.toEqual( + expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]), + ); expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({ - size: TimeBucketSize.DAY, userIds: [authStub.admin.user.id], }); }); @@ -34,35 +28,34 @@ describe(TimelineService.name, () => { describe('getTimeBucket', () => { it('should return the assets for a album time bucket if user has album.read', async () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); + const json = `[{ id: ['asset-id'] }]`; + mocks.asset.getTimeBucket.mockResolvedValue({ assets: json }); - await expect( - sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + await expect(sut.getTimeBucket(authStub.admin, { timeBucket: 'bucket', albumId: 'album-id' })).resolves.toEqual( + json, + ); expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { - size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id', }); }); it('should return the assets for a archive time bucket if user has archive.read', async () => { - mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); + const json = `[{ id: ['asset-id'] }]`; + mocks.asset.getTimeBucket.mockResolvedValue({ assets: json }); await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', visibility: AssetVisibility.ARCHIVE, userId: authStub.admin.user.id, }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + ).resolves.toEqual(json); expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( 'bucket', expect.objectContaining({ - size: TimeBucketSize.DAY, timeBucket: 'bucket', visibility: AssetVisibility.ARCHIVE, userIds: [authStub.admin.user.id], @@ -71,20 +64,19 @@ describe(TimelineService.name, () => { }); it('should include partner shared assets', async () => { - mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); + const json = `[{ id: ['asset-id'] }]`; + mocks.asset.getTimeBucket.mockResolvedValue({ assets: json }); mocks.partner.getAll.mockResolvedValue([]); await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', visibility: AssetVisibility.TIMELINE, userId: authStub.admin.user.id, withPartners: true, }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + ).resolves.toEqual(json); expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { - size: TimeBucketSize.DAY, timeBucket: 'bucket', visibility: AssetVisibility.TIMELINE, withPartners: true, @@ -93,62 +85,37 @@ describe(TimelineService.name, () => { }); it('should check permissions to read tag', async () => { - mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); + const json = `[{ id: ['asset-id'] }]`; + mocks.asset.getTimeBucket.mockResolvedValue({ assets: json }); mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123'])); await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', userId: authStub.admin.user.id, tagId: 'tag-123', }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + ).resolves.toEqual(json); expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { - size: TimeBucketSize.DAY, tagId: 'tag-123', timeBucket: 'bucket', userIds: [authStub.admin.user.id], }); }); - it('should strip metadata if showExif is disabled', async () => { - mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id'])); - mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); - - const auth = factory.auth({ sharedLink: { showExif: false } }); - - const buckets = await sut.getTimeBucket(auth, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - visibility: AssetVisibility.ARCHIVE, - albumId: 'album-id', - }); - - expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]); - expect(buckets[0]).not.toHaveProperty('exif'); - expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - visibility: AssetVisibility.ARCHIVE, - albumId: 'album-id', - }); - }); - it('should return the assets for a library time bucket if user has library.read', async () => { - mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); + const json = `[{ id: ['asset-id'] }]`; + mocks.asset.getTimeBucket.mockResolvedValue({ assets: json }); await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', userId: authStub.admin.user.id, }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + ).resolves.toEqual(json); expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( 'bucket', expect.objectContaining({ - size: TimeBucketSize.DAY, timeBucket: 'bucket', userIds: [authStub.admin.user.id], }), @@ -158,7 +125,6 @@ describe(TimelineService.name, () => { it('should throw an error if withParners is true and visibility true or undefined', async () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', visibility: AssetVisibility.ARCHIVE, withPartners: true, @@ -168,7 +134,6 @@ describe(TimelineService.name, () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', visibility: undefined, withPartners: true, @@ -180,7 +145,6 @@ describe(TimelineService.name, () => { it('should throw an error if withParners is true and isFavorite is either true or false', async () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', isFavorite: true, withPartners: true, @@ -190,7 +154,6 @@ describe(TimelineService.name, () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', isFavorite: false, withPartners: true, @@ -202,7 +165,6 @@ describe(TimelineService.name, () => { it('should throw an error if withParners is true and isTrash is true', async () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', isTrashed: true, withPartners: true, diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index c0cd4786a..f3ebcc2cd 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; +import { TimeBucketAssetDto, TimeBucketDto, TimeBucketsResponseDto } from 'src/dtos/time-bucket.dto'; import { AssetVisibility, Permission } from 'src/enum'; import { TimeBucketOptions } from 'src/repositories/asset.repository'; import { BaseService } from 'src/services/base.service'; @@ -9,22 +8,20 @@ import { getMyPartnerIds } from 'src/utils/asset.util'; @Injectable() export class TimelineService extends BaseService { - async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise { + async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise { await this.timeBucketChecks(auth, dto); const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); - return this.assetRepository.getTimeBuckets(timeBucketOptions); + return await this.assetRepository.getTimeBuckets(timeBucketOptions); } - async getTimeBucket( - auth: AuthDto, - dto: TimeBucketAssetDto, - ): Promise { + // pre-jsonified response + async getTimeBucket(auth: AuthDto, dto: TimeBucketAssetDto): Promise { await this.timeBucketChecks(auth, dto); - const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); - const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions); - return !auth.sharedLink || auth.sharedLink?.showExif - ? assets.map((asset) => mapAsset(asset, { withStack: true, auth })) - : assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth })); + const timeBucketOptions = await this.buildTimeBucketOptions(auth, { ...dto }); + + // TODO: use id cursor for pagination + const bucket = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions); + return bucket.assets; } private async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise { diff --git a/server/src/utils/bytes.ts b/server/src/utils/bytes.ts index e837c81b9..5e476f4de 100644 --- a/server/src/utils/bytes.ts +++ b/server/src/utils/bytes.ts @@ -22,3 +22,12 @@ export function asHumanReadable(bytes: number, precision = 1): string { return `${remainder.toFixed(magnitude == 0 ? 0 : precision)} ${units[magnitude]}`; } + +// if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings +export const hexOrBufferToBase64 = (encoded: string | Buffer) => { + if (typeof encoded === 'string') { + return Buffer.from(encoded.slice(2), 'hex').toString('base64'); + } + + return encoded.toString('base64'); +}; diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index bacdf06d6..e0e7af49a 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -271,7 +271,7 @@ export function withTags(eb: ExpressionBuilder) { } export function truncatedDate(size: TimeBucketSize) { - return sql`date_trunc(${size}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`; + return sql`date_trunc(${sql.lit(size)}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`; } export function withTagId(qb: SelectQueryBuilder, tagId: string) { @@ -285,6 +285,7 @@ export function withTagId(qb: SelectQueryBuilder, tagId: str ), ); } + const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); /** TODO: This should only be used for search-related queries, not as a general purpose query builder */ diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index 4248b23d3..ce1520c47 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -14,7 +14,6 @@ import { LoggingRepository } from 'src/repositories/logging.repository'; import { bootstrapTelemetry } from 'src/repositories/telemetry.repository'; import { ApiService } from 'src/services/api.service'; import { isStartUpError, useSwagger } from 'src/utils/misc'; - async function bootstrap() { process.title = 'immich-api'; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index a64194361..454be0084 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -251,6 +251,10 @@ export const assetStub = { duplicateId: null, isOffline: false, stack: null, + orientation: '', + projectionType: null, + height: 3840, + width: 2160, visibility: AssetVisibility.TIMELINE, }), diff --git a/typescript-open-api/typescript-sdk/package-lock.json b/typescript-open-api/typescript-sdk/package-lock.json new file mode 100644 index 000000000..ca6fc5e1d --- /dev/null +++ b/typescript-open-api/typescript-sdk/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "typescript-sdk", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/web/src/lib/components/asset-viewer/actions/keep-this-delete-others.svelte b/web/src/lib/components/asset-viewer/actions/keep-this-delete-others.svelte index 80dfb3506..be5e8f782 100644 --- a/web/src/lib/components/asset-viewer/actions/keep-this-delete-others.svelte +++ b/web/src/lib/components/asset-viewer/actions/keep-this-delete-others.svelte @@ -1,5 +1,6 @@