From d33ce135613df46a467b88ecababa5260b1350b4 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 6 May 2025 12:12:48 -0500 Subject: [PATCH] feat(server): visibility column (#17939) * feat: private view * pr feedback * sql generation * feat: visibility column * fix: set visibility value as the same as the still part after unlinked live photos * fix: test * pr feedback --- e2e/src/api/specs/asset.e2e-spec.ts | 15 +- e2e/src/api/specs/map.e2e-spec.ts | 4 +- e2e/src/api/specs/search.e2e-spec.ts | 40 ++--- e2e/src/api/specs/timeline.e2e-spec.ts | 6 +- e2e/src/utils.ts | 6 +- mobile/lib/services/asset.service.dart | 10 +- mobile/lib/services/search.service.dart | 8 +- mobile/openapi/README.md | Bin 34236 -> 34282 bytes mobile/openapi/lib/api.dart | Bin 12397 -> 12433 bytes mobile/openapi/lib/api/assets_api.dart | Bin 31644 -> 31491 bytes mobile/openapi/lib/api/timeline_api.dart | Bin 8921 -> 9009 bytes mobile/openapi/lib/api_client.dart | Bin 31569 -> 31666 bytes mobile/openapi/lib/api_helper.dart | Bin 6756 -> 6864 bytes .../lib/model/asset_bulk_update_dto.dart | Bin 7572 -> 7587 bytes .../openapi/lib/model/asset_visibility.dart | Bin 0 -> 2809 bytes .../lib/model/metadata_search_dto.dart | Bin 29963 -> 29009 bytes .../openapi/lib/model/random_search_dto.dart | Bin 22513 -> 21559 bytes .../openapi/lib/model/smart_search_dto.dart | Bin 22599 -> 21645 bytes mobile/openapi/lib/model/sync_asset_v1.dart | Bin 8777 -> 11605 bytes .../openapi/lib/model/update_asset_dto.dart | Bin 7984 -> 7999 bytes open-api/immich-openapi-specs.json | 149 +++++++++------- open-api/typescript-sdk/src/fetch-client.ts | 44 +++-- .../asset-media.controller.spec.ts | 37 ++-- .../src/controllers/search.controller.spec.ts | 168 +++++++++--------- server/src/database.ts | 7 +- server/src/db.d.ts | 4 +- server/src/dtos/asset-media.dto.ts | 10 +- server/src/dtos/asset-response.dto.ts | 7 +- server/src/dtos/asset.dto.ts | 12 +- server/src/dtos/search.dto.ts | 15 +- server/src/dtos/sync.dto.ts | 4 +- server/src/dtos/time-bucket.dto.ts | 10 +- server/src/enum.ts | 10 ++ server/src/queries/access.repository.sql | 7 +- server/src/queries/asset.job.repository.sql | 28 +-- server/src/queries/asset.repository.sql | 36 ++-- server/src/queries/library.repository.sql | 4 +- server/src/queries/map.repository.sql | 2 +- server/src/queries/person.repository.sql | 6 +- server/src/queries/search.repository.sql | 66 ++++--- server/src/queries/sync.repository.sql | 4 +- server/src/queries/user.repository.sql | 4 +- server/src/queries/view.repository.sql | 12 +- server/src/repositories/access.repository.ts | 10 +- .../src/repositories/asset-job.repository.ts | 24 +-- server/src/repositories/asset.repository.ts | 41 +++-- .../src/repositories/download.repository.ts | 6 +- server/src/repositories/library.repository.ts | 10 +- server/src/repositories/map.repository.ts | 23 ++- server/src/repositories/person.repository.ts | 10 +- server/src/repositories/search.repository.ts | 15 +- server/src/repositories/user.repository.ts | 12 +- server/src/repositories/view-repository.ts | 7 +- server/src/schema/index.ts | 8 +- .../1745902563899-AddAssetVisibilityColumn.ts | 37 ++++ server/src/schema/tables/asset.table.ts | 12 +- .../src/services/asset-media.service.spec.ts | 14 +- server/src/services/asset-media.service.ts | 6 +- server/src/services/asset.service.spec.ts | 65 ++++--- server/src/services/asset.service.ts | 10 +- server/src/services/duplicate.service.spec.ts | 9 +- server/src/services/duplicate.service.ts | 4 +- server/src/services/job.service.ts | 3 +- server/src/services/media.service.ts | 3 +- server/src/services/metadata.service.spec.ts | 35 ++-- server/src/services/metadata.service.ts | 14 +- server/src/services/person.service.ts | 7 +- server/src/services/smart-info.service.ts | 4 +- server/src/services/sync.service.ts | 7 +- server/src/services/timeline.service.spec.ts | 19 +- server/src/services/timeline.service.ts | 6 +- server/src/utils/asset.util.ts | 10 +- server/src/utils/database.ts | 17 +- server/src/validation.ts | 13 ++ server/test/fixtures/asset.stub.ts | 65 +++---- server/test/fixtures/shared-link.stub.ts | 4 +- server/test/medium.factory.ts | 31 +++- .../specs/services/sync.service.spec.ts | 4 +- server/test/small.factory.ts | 5 +- .../search-bar/search-filter-modal.svelte | 36 ++-- .../user-usage-statistic.svelte | 5 +- web/src/lib/stores/assets-store.svelte.ts | 3 +- web/src/lib/utils/asset-utils.ts | 9 +- .../[[assetId=id]]/+page.svelte | 8 +- .../[[assetId=id]]/+page.svelte | 13 +- .../[[assetId=id]]/+page.svelte | 16 +- .../[[assetId=id]]/+page.svelte | 22 ++- .../[[assetId=id]]/+page.svelte | 7 +- .../(user)/photos/[[assetId=id]]/+page.svelte | 4 +- .../[[assetId=id]]/+page.svelte | 38 ++-- 90 files changed, 822 insertions(+), 644 deletions(-) create mode 100644 mobile/openapi/lib/model/asset_visibility.dart create mode 100644 server/src/schema/migrations/1745902563899-AddAssetVisibilityColumn.ts diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 97e53d4a7..819618605 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -3,6 +3,7 @@ import { AssetMediaStatus, AssetResponseDto, AssetTypeEnum, + AssetVisibility, getAssetInfo, getMyUser, LoginResponseDto, @@ -119,9 +120,9 @@ describe('/asset', () => { // stats utils.createAsset(statsUser.accessToken), utils.createAsset(statsUser.accessToken, { isFavorite: true }), - utils.createAsset(statsUser.accessToken, { isArchived: true }), + utils.createAsset(statsUser.accessToken, { visibility: AssetVisibility.Archive }), utils.createAsset(statsUser.accessToken, { - isArchived: true, + visibility: AssetVisibility.Archive, isFavorite: true, assetData: { filename: 'example.mp4' }, }), @@ -309,7 +310,7 @@ describe('/asset', () => { }); it('disallows viewing archived assets', async () => { - const asset = await utils.createAsset(user1.accessToken, { isArchived: true }); + const asset = await utils.createAsset(user1.accessToken, { visibility: AssetVisibility.Archive }); const { status } = await request(app) .get(`/assets/${asset.id}`) @@ -353,7 +354,7 @@ describe('/asset', () => { const { status, body } = await request(app) .get('/assets/statistics') .set('Authorization', `Bearer ${statsUser.accessToken}`) - .query({ isArchived: true }); + .query({ visibility: AssetVisibility.Archive }); expect(status).toBe(200); expect(body).toEqual({ images: 1, videos: 1, total: 2 }); @@ -363,7 +364,7 @@ describe('/asset', () => { const { status, body } = await request(app) .get('/assets/statistics') .set('Authorization', `Bearer ${statsUser.accessToken}`) - .query({ isFavorite: true, isArchived: true }); + .query({ isFavorite: true, visibility: AssetVisibility.Archive }); expect(status).toBe(200); expect(body).toEqual({ images: 0, videos: 1, total: 1 }); @@ -373,7 +374,7 @@ describe('/asset', () => { const { status, body } = await request(app) .get('/assets/statistics') .set('Authorization', `Bearer ${statsUser.accessToken}`) - .query({ isFavorite: false, isArchived: false }); + .query({ isFavorite: false, visibility: AssetVisibility.Timeline }); expect(status).toBe(200); expect(body).toEqual({ images: 1, videos: 0, total: 1 }); @@ -459,7 +460,7 @@ describe('/asset', () => { const { status, body } = await request(app) .put(`/assets/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ isArchived: true }); + .send({ visibility: AssetVisibility.Archive }); expect(body).toMatchObject({ id: user1Assets[0].id, isArchived: true }); expect(status).toEqual(200); }); diff --git a/e2e/src/api/specs/map.e2e-spec.ts b/e2e/src/api/specs/map.e2e-spec.ts index da5f779cf..977638aa2 100644 --- a/e2e/src/api/specs/map.e2e-spec.ts +++ b/e2e/src/api/specs/map.e2e-spec.ts @@ -1,4 +1,4 @@ -import { LoginResponseDto } from '@immich/sdk'; +import { AssetVisibility, LoginResponseDto } from '@immich/sdk'; import { readFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { Socket } from 'socket.io-client'; @@ -44,7 +44,7 @@ describe('/map', () => { it('should get map markers for all non-archived assets', async () => { const { status, body } = await request(app) .get('/map/markers') - .query({ isArchived: false }) + .query({ visibility: AssetVisibility.Timeline }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts index 23f8c65fa..2f6ea75f7 100644 --- a/e2e/src/api/specs/search.e2e-spec.ts +++ b/e2e/src/api/specs/search.e2e-spec.ts @@ -1,4 +1,11 @@ -import { AssetMediaResponseDto, AssetResponseDto, deleteAssets, LoginResponseDto, updateAsset } from '@immich/sdk'; +import { + AssetMediaResponseDto, + AssetResponseDto, + AssetVisibility, + deleteAssets, + LoginResponseDto, + updateAsset, +} from '@immich/sdk'; import { DateTime } from 'luxon'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; @@ -49,7 +56,7 @@ describe('/search', () => { { filename: '/formats/motionphoto/samsung-one-ui-6.heic' }, { filename: '/formats/motionphoto/samsung-one-ui-5.jpg' }, - { filename: '/metadata/gps-position/thompson-springs.jpg', dto: { isArchived: true } }, + { filename: '/metadata/gps-position/thompson-springs.jpg', dto: { visibility: AssetVisibility.Archive } }, // used for search suggestions { filename: '/formats/png/density_plot.png' }, @@ -171,12 +178,12 @@ describe('/search', () => { deferred: () => ({ dto: { size: 1, isFavorite: false }, assets: [assetLast] }), }, { - should: 'should search by isArchived (true)', - deferred: () => ({ dto: { isArchived: true }, assets: [assetSprings] }), + should: 'should search by visibility (AssetVisibility.Archive)', + deferred: () => ({ dto: { visibility: AssetVisibility.Archive }, assets: [assetSprings] }), }, { - should: 'should search by isArchived (false)', - deferred: () => ({ dto: { size: 1, isArchived: false }, assets: [assetLast] }), + should: 'should search by visibility (AssetVisibility.Timeline)', + deferred: () => ({ dto: { size: 1, visibility: AssetVisibility.Timeline }, assets: [assetLast] }), }, { should: 'should search by type (image)', @@ -185,7 +192,7 @@ describe('/search', () => { { should: 'should search by type (video)', deferred: () => ({ - dto: { type: 'VIDEO' }, + dto: { type: 'VIDEO', visibility: AssetVisibility.Hidden }, assets: [ // the three live motion photos { id: expect.any(String) }, @@ -229,13 +236,6 @@ describe('/search', () => { should: 'should search by takenAfter (no results)', deferred: () => ({ dto: { takenAfter: today.plus({ hour: 1 }).toJSDate() }, assets: [] }), }, - // { - // should: 'should search by originalPath', - // deferred: () => ({ - // dto: { originalPath: asset1.originalPath }, - // assets: [asset1], - // }), - // }, { should: 'should search by originalFilename', deferred: () => ({ @@ -265,7 +265,7 @@ describe('/search', () => { deferred: () => ({ dto: { city: '', - isVisible: true, + visibility: AssetVisibility.Timeline, includeNull: true, }, assets: [assetLast], @@ -276,7 +276,7 @@ describe('/search', () => { deferred: () => ({ dto: { city: null, - isVisible: true, + visibility: AssetVisibility.Timeline, includeNull: true, }, assets: [assetLast], @@ -297,7 +297,7 @@ describe('/search', () => { deferred: () => ({ dto: { state: '', - isVisible: true, + visibility: AssetVisibility.Timeline, withExif: true, includeNull: true, }, @@ -309,7 +309,7 @@ describe('/search', () => { deferred: () => ({ dto: { state: null, - isVisible: true, + visibility: AssetVisibility.Timeline, includeNull: true, }, assets: [assetLast, assetNotocactus], @@ -330,7 +330,7 @@ describe('/search', () => { deferred: () => ({ dto: { country: '', - isVisible: true, + visibility: AssetVisibility.Timeline, includeNull: true, }, assets: [assetLast], @@ -341,7 +341,7 @@ describe('/search', () => { deferred: () => ({ dto: { country: null, - isVisible: true, + visibility: AssetVisibility.Timeline, includeNull: true, }, assets: [assetLast], diff --git a/e2e/src/api/specs/timeline.e2e-spec.ts b/e2e/src/api/specs/timeline.e2e-spec.ts index bf330e994..93ba8b652 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, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk'; +import { AssetMediaResponseDto, AssetVisibility, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk'; import { DateTime } from 'luxon'; import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; @@ -104,7 +104,7 @@ describe('/timeline', () => { const req1 = await request(app) .get('/timeline/buckets') .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: true }); + .query({ size: TimeBucketSize.Month, withPartners: true, visibility: AssetVisibility.Archive }); expect(req1.status).toBe(400); expect(req1.body).toEqual(errorDto.badRequest()); @@ -112,7 +112,7 @@ describe('/timeline', () => { const req2 = await request(app) .get('/timeline/buckets') .set('Authorization', `Bearer ${user.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: undefined }); + .query({ size: TimeBucketSize.Month, withPartners: true, visibility: undefined }); expect(req2.status).toBe(400); expect(req2.body).toEqual(errorDto.badRequest()); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 08b29a4a1..1d5004d38 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -3,6 +3,7 @@ import { AssetMediaCreateDto, AssetMediaResponseDto, AssetResponseDto, + AssetVisibility, CheckExistingAssetsDto, CreateAlbumDto, CreateLibraryDto, @@ -429,7 +430,10 @@ export const utils = { }, archiveAssets: (accessToken: string, ids: string[]) => - updateAssets({ assetBulkUpdateDto: { ids, isArchived: true } }, { headers: asBearerAuth(accessToken) }), + updateAssets( + { assetBulkUpdateDto: { ids, visibility: AssetVisibility.Archive } }, + { headers: asBearerAuth(accessToken) }, + ), deleteAssets: (accessToken: string, ids: string[]) => deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }), diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 4bf62eca3..8a24e72fb 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -197,7 +197,7 @@ class AssetService { ids: assets.map((e) => e.remoteId!).toList(), dateTimeOriginal: updateAssetDto.dateTimeOriginal, isFavorite: updateAssetDto.isFavorite, - isArchived: updateAssetDto.isArchived, + visibility: updateAssetDto.visibility, latitude: updateAssetDto.latitude, longitude: updateAssetDto.longitude, ), @@ -229,7 +229,13 @@ class AssetService { bool isArchived, ) async { try { - await updateAssets(assets, UpdateAssetDto(isArchived: isArchived)); + await updateAssets( + assets, + UpdateAssetDto( + visibility: + isArchived ? AssetVisibility.archive : AssetVisibility.timeline, + ), + ); for (var element in assets) { element.isArchived = isArchived; diff --git a/mobile/lib/services/search.service.dart b/mobile/lib/services/search.service.dart index 44ace7885..bcf67889c 100644 --- a/mobile/lib/services/search.service.dart +++ b/mobile/lib/services/search.service.dart @@ -68,7 +68,9 @@ class SearchService { model: filter.camera.model, takenAfter: filter.date.takenAfter, takenBefore: filter.date.takenBefore, - isArchived: filter.display.isArchive ? true : null, + visibility: filter.display.isArchive + ? AssetVisibility.archive + : AssetVisibility.timeline, isFavorite: filter.display.isFavorite ? true : null, isNotInAlbum: filter.display.isNotInAlbum ? true : null, personIds: filter.people.map((e) => e.id).toList(), @@ -95,7 +97,9 @@ class SearchService { model: filter.camera.model, takenAfter: filter.date.takenAfter, takenBefore: filter.date.takenBefore, - isArchived: filter.display.isArchive ? true : null, + visibility: filter.display.isArchive + ? AssetVisibility.archive + : AssetVisibility.timeline, isFavorite: filter.display.isFavorite ? true : null, isNotInAlbum: filter.display.isNotInAlbum ? true : null, personIds: filter.people.map((e) => e.id).toList(), diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 7c8afb09e4f617f4a35703f20b74938c5fa4542d..5395f46801013b66ca506cef4eb4230b1e624125 100644 GIT binary patch delta 51 ucmdnf&Gf39X#-ERN^x;&NmyoaW>RKOW=UnNMoNCNzP=->=w_zqU0MJTLKGMP delta 14 VcmaFW&9tYRX#-F6W~=B8S^zMO1^NI0 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index ab9b251e01d5d088889e8ad2b5e97251fbf6f5e5..9a806a3f2071efe77433285fa423ffaae691b0b1 100644 GIT binary patch delta 27 icmaExFfnn17~f?IZ6PI1POTn delta 12 TcmbQ3_%>mK7~f_qzHlV~BXk6a diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index f744988449517ec0036332d9013cfeb843baf14d..06965e1f8beceb245174ee25e51da98c2ab43b13 100644 GIT binary patch delta 567 zcmbR9ow4~F;|4?R$qjjm>>)*o#Tlt7lQ)!ziG^hrXC`IlWR_IMDwM(5lMA9nC--Y> zPClhAE#+8ToLYiVXOB=f$vSkhxz6Ru<(X2GAC#$1-mKdpr~m|+X$l%}!zTONSct=U zmFhYO{gs;y^;8&%b<;e32LkSy{7&6)vZaC4WZ6QA$;}2Lq6llP5Vmd>l+t0^e8A|c z9?)jV$qfn|lfNh0Pu@}@ggZ3ECwnHH1lrCu**@6}Z-|6!K9+3C3Jj8}%~@HBj9dsO zOs>x`L*^Q|iz9J2S7kqCBGy6kyp<=*=kJF(NE&w(RZg}qI410tSe9RuS(0j{kXa06 QPj)MG#1}prir=sU0OPyd{{R30 delta 538 zcmZqv#yICY;|4=*HpimmjLfph6ZM5Bzt2?!Vvfl@`rMO40t6?oQ{kBWN}p?Tz5%ar zwL(&UevZ9DW-&xVYKqR}UD_&>AF2dR_SLxzGp1kHS`4N@T?a|E=H#Clx|92CR42#l zc1+H4Ri50V_8w##OeeYn4(r=+!c2l#C2X!aIl(|)1SV>QWc=j10;$PU4a7DdHuz_N z#YpzB%;L4xj2HWu7hO2<^U-}rpdfKenhxv z7ZI+Bnf#uack+Ec^~rMlWnu_Rt&r_JAU}a=@>E{I$!SVbn|*~88HunQ6ddA{x#jsL s|6vxLd{;!82*+4#UL#rt3YKe3#0N(pF_9BG`L=8X5iV%O9}JSr0HfTV6XBY?j(HATOn|R^^JSLRjBo)(HVd)LVyKoB zbsYsbXS2U}C)4Cu4)w{BoQ<1VxNL>OkQ z6~ffXy8P9f5Afe&hHFAN(`WKW5j(gz#IVNAmSWmOI`=ix<|R`0*qk{D%~X&ReepQ2 Kk7@D>rA`3(349j- diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index ec5eb097293564c238625a19d2227c5e1bac008e..041a584296040c6bd5820d191bf0d54ff41ca22a 100644 GIT binary patch delta 48 ucmcckjd9a=#tpMICmSfRaD`*1@v*7+Wl(v}18` zYDpMe+Fk*nbg~WizsU(aRcZ=qYFr9HkYAFKT7+blf~_sG@>@KQCV%2Rpsk<{R;__B z38XNuG$%*FUco@Y3R%x`zW&L6{C*NTNP@O12%Vef@GoWDd_wRmyKq9(G M9oeGI?c%&_01mu^tN;K2 delta 258 zcmZ2%J;i#%ZAK2qqU4OsvecBx=NJPwn=lD8!$mgxvnDZawr8EdGTDdwrx;A3f~~DW zW-*L6*_5Yi@>8BiN-zPCbY5vrj)J{{fr1s1O2x^Wd9O^~%hw_TQ);V%WR5L=(B|d* zOOYHTC?F|^P@o>GV5?A)ky)&V5S!d5B*%rs)e)B4>?Qb}aq|h`Jxp+aOzsg==e1`LKFFw!aJN|72##W0He_uY|_ z?8tH(Ahu}U`#txZrqj`M3Re%y`yYQ>+%5jPS}v~P=Jw-a0@wF&cfW*B_t&>K|D2H+ zOMb|O_48knpWlx7D4(>km=?ySg;Y3$TGtNq6cv`J{iHq1vNF~WY6!IzJC|0qPc8me zOH1lnl=N8%OWz)?BjeT$t7k?#FSJi(P7Y-u(FS!l=uAnCE>%W9k(l2Ioxc4k%{Ici z(TLo6Fa?+jwWwr5|L;blQKp1*aOE8OzonCpQc1squOomd72q~{m%<|m(6|NEbqQPP zH{dr2l{L>4Sq`gbp=w+wq!&W&TTIfi-$<7Pu?un>?Jvnjt#h<$C-oDATMm+CqpK3j zN3?9WkXk7C>}@9*%zQA^-xK)yXpDjSi5Z2B5nyyU_Qcof+emOe6(46|4dOQFk!$h@(;@4}9unRansUe1SUt>=PRF3#v6 z2c8mcXekzK+#3ks)B}(4L*&nn2ClaIzl93 zws(Y~H6z;Fnz){t!2CXJminuWhew3iryN|JW$0W%Ms`dGj1SV+0h(+zA`J5cem7er z#-8vP^<0N}{I>7L_IDzPz8QrNns#&C)|fA>H8v)>p>fi$OToLS7ZAm{16rf`4z`>P zS-kr57HFf=hNZGol@7LIoJUHQ#*S7Z4Zg*awo~3#gyre|!IFeUS{#ztyLP#b?(WvdH`tU}nd2k6j05qRI<3$XSN0jK Y#|#9~hB3MK!%MZNa=B-#cTCX#1F8|KssI20 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 3fb003d16434e45154462ffdd16df51d0f9c7386..7f1184467b1d6c8c9c5ded2832fc6ebf2199aca6 100644 GIT binary patch delta 370 zcmeBv#dz@%9X5=c%EY3{I%*iaN+W46epr0ZDzMaSr3=Dr0B1$&W2}Y~F8W#w>x5Rj{>1Rx4=tn`yI?BM0N= zIwvg_BpHypywaQ;1$zYp1uK}uWCwe($p-E$n@_pDW}JN1qiA!e=PgDtgz>g22=-=A zZw0o^9|C7GZRQC*%Qd+ngcHeIlM4eRr4fqNV-;)_N-{Ew^`K&t*ZC;2!`K&m6gLYe y$g^!OOfzNN{5btHt0h9Kl>%C5=%p3q=Xw?A=V@dCiRdDAgj)4j&CL>dpVSvUACdQwXpP!?EFa)SPk8$&9 zLuWQogsHX)nZ+;{{53x*3UfiKf-Oi2YTR}US7sT6NvS~R*dm#nnzC8M<~Nf9vaxxk zIXMdU3I+;RNH$Eg6WPpb&&3Eg8Eg%TQmBp04t6X&$Tm(s?;^Ij+T}H)Fw7QP6@=3h z-O7bwu1y6=K#jTTc9&5K*%(_DWZvcz9tv#m7-;a5mWBmJih3+C$V)OZi}hf(Ox_r$ zzzO4|PCn=>zxkQpY$kZ5OVtzr>?1BZx2mV Iaao_)0M7IAQUCw| diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 10727ec10d015b19ed64621a062e1a4cc3e441e7..0284212efcbe90e9339f6c4597ee2f8f5c2ac972 100644 GIT binary patch delta 372 zcmeyko^ksM#tp9-Co!dNu4M9JY8$te#tp9-*&U0LGcwCkC*Nf(=Ey7#%Ph`J%GvC~6vil0o>`Isl}%BwRY*(B zDNfzIgxQCU117rJmoJNP^9Rn$teb=QQW@Exy7%%I^9v^B=jSLO8?pJXN)F>@5v68U zQJ8)OTU&+9Vwels)K7}ST#yQpf*R}q-WMlJ6b8-~y6$})t z;5JM?s3X34i4F%NPy}Ky*cKF(5b?<$^+h(X(f!H9gKWuUMnkd9_YDdeg<%%isvunN zZB#A{Hy0!Ub^CrJZ$>F(V{BEBd7BkXYMJ4YaL`Ih4sK+MdMq&5OENNx^$=o{KUylV zLF}Kr(M5c+gDL0c+2*sEIAIp1PQGX>DGlc#n*sIQ2TMh0OidQBmEUY;^`2cvK_RO+ zKQFpS9ocONy~v{0o0mI1V1c=z$6Xob`pp&YE{sU-=yQkJvw6Ko0SgzxkjXvnisA@v SYKpq1g1tR7g+=(@X9EBa(Dal5 diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index 47c800ff0953321bbc798af34044e7cb5b1d0cf8..a915d97b319af940626a3be2d2aef171bded918e 100644 GIT binary patch delta 371 zcmX@Ufw6ZbF40=SB!N(&U~7x4-a>ai<7Qrc z4#v%%206?ssOs`cb8-~y6$})tkhCanK4>h#IQgSV(dJ6iGDb0k7F#6kn>o$BnK$3I zn#r{IyDjJB3s#&+o|>%aC@F`~ryi?dt5A}WS*(W;n|#nwjvdOKeBWGgv!VMQ=FK|Z zri`2C`LMEDB2-!_I2ISDmW08>Locl;Ki8``KTjhINJJN@Bh;$LYHofSn8XGE`n!q0 delta 676 zcmeBO$#{GN`Isl}%BwRY*(B zDNfydhgp%0117pzkv)lVa=#ke=HHw~>>vih2nF_R#?7XJyn=#B`T02t$cDkRacx#p zoy#N&GflzPRw1((=AKRJCq-fINrgy39halAokWXf@rNhAp6oVKJwh2WoM0}E-=;p_|*~~l$ds0(27a2-03d3x% zRY5qu)u>z;ZX`$o>hzyRm5eA(w^czgu*1Zg86LWmH=0Sx!Ocujj|E0TNk(R|9ztyL zLQ4gN`b1N?%@@sQGr=Qa@<&S~WDA?K}tDnW{$vF8R+jJoXZLo3; zgv!a?>_;c3aRf-{AZf5wK`7X~jpGp0=EdBVjOq#sS;hHz(M9SAS@l>2TZNL0%wiVg6=AK^3;iY2tRg3JOTpZk{i`ic5wo zIVZ8W7`wTX4~PeGg~3B`vY5o?`oyB-jLfoBpo?)kC0?UQ9jZ=U(;66B#U+U)naK*t z`FX`93MHAjsX3W>gpGhHL^i_&YDh+AN=j-TL03SOO`ay{i0qHslGQY-WJV^D$;M2HlQ%K-@h0Wx=P1CWCR;IQY(CBG#wZFC zNma15MMyKTc5mLy>dD9flb-yNZPn!U?7M|w0;#qtFy`cRj^NFgI1VwXC@5qV=jTNi zsl%jF)ngTG6-qKPi}hean|rw%7&lw-zGXrf{()bK4dUj_k^(G@o3jNa*buT+;_jO- Ii!bE@0LSJ~_y7O^ diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index c6ae6d8e07d3ddbdd7a59ba3d3a5416d838c3109..7b364f13874af7e4dc3a6c82fba75c3b7d0d08f3 100644 GIT binary patch delta 383 zcmdmBx8H8VHpa=P8RNwj6iPBOi}lJfi!+llb23XRb+{B1YBe|iXHR0>+{gT#Y4dmX zr7W9SIAR&49gB-oOTyqP?G+HZCQs)%H@Ta4jhcd*8qfd*h5V9?)FLEf6>M#hm7DXO z-E76r#Hg*H4OXs!FbbqHuQVq|!Ct{Y!3tTEp+Mv0QvyX2I!J=HDhQpMZ3NpGH+u?C zVVZ0w%*ln2m>ei9DTm;y$12#OdwVjYupEjGQPItJMdg?_TS=%gx+oyoXr+J_4ti-t j`MF-j`FR>yKq9(G9oZ&LPHvC delta 264 zcmdmQx4~}1HbxG|qU4OsvecBxD;aZ`YBe|avL!KY?qmMW1ebvb)v~Q+nY^6mq!>(t zf~~DWW-*L6Ie~Z2WGB9}iZB6?bY5vrj)J{{fr1s1%FVy{#2F{M3zUh#l-jBwnX^-% zY_q#y8iAOCkKkiZ$2$FjcM}>Q8|P+Ci6)s QBK-72+?a85p5#SV0J2|S4gdfE diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0951177c7..c4c9e7d19 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1781,14 +1781,6 @@ "get": { "operationId": "getAssetStatistics", "parameters": [ - { - "name": "isArchived", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, { "name": "isFavorite", "required": false, @@ -1804,6 +1796,14 @@ "schema": { "type": "boolean" } + }, + { + "name": "visibility", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetVisibility" + } } ], "responses": { @@ -6909,14 +6909,6 @@ "type": "string" } }, - { - "name": "isArchived", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, { "name": "isFavorite", "required": false, @@ -6992,6 +6984,14 @@ "type": "string" } }, + { + "name": "visibility", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetVisibility" + } + }, { "name": "withPartners", "required": false, @@ -7053,14 +7053,6 @@ "type": "string" } }, - { - "name": "isArchived", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, { "name": "isFavorite", "required": false, @@ -7128,6 +7120,14 @@ "type": "string" } }, + { + "name": "visibility", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetVisibility" + } + }, { "name": "withPartners", "required": false, @@ -8273,9 +8273,6 @@ }, "type": "array" }, - "isArchived": { - "type": "boolean" - }, "isFavorite": { "type": "boolean" }, @@ -8289,6 +8286,13 @@ "maximum": 5, "minimum": -1, "type": "number" + }, + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetVisibility" + } + ] } }, "required": [ @@ -8713,15 +8717,9 @@ "format": "date-time", "type": "string" }, - "isArchived": { - "type": "boolean" - }, "isFavorite": { "type": "boolean" }, - "isVisible": { - "type": "boolean" - }, "livePhotoVideoId": { "format": "uuid", "type": "string" @@ -8729,6 +8727,13 @@ "sidecarData": { "format": "binary", "type": "string" + }, + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetVisibility" + } + ] } }, "required": [ @@ -9009,6 +9014,14 @@ ], "type": "string" }, + "AssetVisibility": { + "enum": [ + "archive", + "timeline", + "hidden" + ], + "type": "string" + }, "AudioCodec": { "enum": [ "mp3", @@ -10204,9 +10217,6 @@ "format": "uuid", "type": "string" }, - "isArchived": { - "type": "boolean" - }, "isEncoded": { "type": "boolean" }, @@ -10222,9 +10232,6 @@ "isOffline": { "type": "boolean" }, - "isVisible": { - "type": "boolean" - }, "lensModel": { "nullable": true, "type": "string" @@ -10324,9 +10331,12 @@ "format": "date-time", "type": "string" }, - "withArchived": { - "default": false, - "type": "boolean" + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetVisibility" + } + ] }, "withDeleted": { "type": "boolean" @@ -11041,9 +11051,6 @@ "deviceId": { "type": "string" }, - "isArchived": { - "type": "boolean" - }, "isEncoded": { "type": "boolean" }, @@ -11059,9 +11066,6 @@ "isOffline": { "type": "boolean" }, - "isVisible": { - "type": "boolean" - }, "lensModel": { "nullable": true, "type": "string" @@ -11137,9 +11141,12 @@ "format": "date-time", "type": "string" }, - "withArchived": { - "default": false, - "type": "boolean" + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetVisibility" + } + ] }, "withDeleted": { "type": "boolean" @@ -11989,9 +11996,6 @@ "deviceId": { "type": "string" }, - "isArchived": { - "type": "boolean" - }, "isEncoded": { "type": "boolean" }, @@ -12007,9 +12011,6 @@ "isOffline": { "type": "boolean" }, - "isVisible": { - "type": "boolean" - }, "language": { "type": "string" }, @@ -12095,9 +12096,12 @@ "format": "date-time", "type": "string" }, - "withArchived": { - "default": false, - "type": "boolean" + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetVisibility" + } + ] }, "withDeleted": { "type": "boolean" @@ -12381,9 +12385,6 @@ "isFavorite": { "type": "boolean" }, - "isVisible": { - "type": "boolean" - }, "localDateTime": { "format": "date-time", "nullable": true, @@ -12404,6 +12405,14 @@ "OTHER" ], "type": "string" + }, + "visibility": { + "enum": [ + "archive", + "timeline", + "hidden" + ], + "type": "string" } }, "required": [ @@ -12413,11 +12422,11 @@ "fileModifiedAt", "id", "isFavorite", - "isVisible", "localDateTime", "ownerId", "thumbhash", - "type" + "type", + "visibility" ], "type": "object" }, @@ -13671,9 +13680,6 @@ "description": { "type": "string" }, - "isArchived": { - "type": "boolean" - }, "isFavorite": { "type": "boolean" }, @@ -13692,6 +13698,13 @@ "maximum": 5, "minimum": -1, "type": "number" + }, + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetVisibility" + } + ] } }, "type": "object" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 20fb72b48..b2abdb0a2 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -413,11 +413,10 @@ export type AssetMediaCreateDto = { duration?: string; fileCreatedAt: string; fileModifiedAt: string; - isArchived?: boolean; isFavorite?: boolean; - isVisible?: boolean; livePhotoVideoId?: string; sidecarData?: Blob; + visibility?: AssetVisibility; }; export type AssetMediaResponseDto = { id: string; @@ -427,11 +426,11 @@ export type AssetBulkUpdateDto = { dateTimeOriginal?: string; duplicateId?: string | null; ids: string[]; - isArchived?: boolean; isFavorite?: boolean; latitude?: number; longitude?: number; rating?: number; + visibility?: AssetVisibility; }; export type AssetBulkUploadCheckItem = { /** base64 or hex encoded sha1 hash */ @@ -470,12 +469,12 @@ export type AssetStatsResponseDto = { export type UpdateAssetDto = { dateTimeOriginal?: string; description?: string; - isArchived?: boolean; isFavorite?: boolean; latitude?: number; livePhotoVideoId?: string | null; longitude?: number; rating?: number; + visibility?: AssetVisibility; }; export type AssetMediaReplaceDto = { assetData: Blob; @@ -815,13 +814,11 @@ export type MetadataSearchDto = { deviceId?: string; encodedVideoPath?: string; id?: string; - isArchived?: boolean; isEncoded?: boolean; isFavorite?: boolean; isMotion?: boolean; isNotInAlbum?: boolean; isOffline?: boolean; - isVisible?: boolean; lensModel?: string | null; libraryId?: string | null; make?: string; @@ -844,7 +841,7 @@ export type MetadataSearchDto = { "type"?: AssetTypeEnum; updatedAfter?: string; updatedBefore?: string; - withArchived?: boolean; + visibility?: AssetVisibility; withDeleted?: boolean; withExif?: boolean; withPeople?: boolean; @@ -888,13 +885,11 @@ export type RandomSearchDto = { 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; @@ -911,7 +906,7 @@ export type RandomSearchDto = { "type"?: AssetTypeEnum; updatedAfter?: string; updatedBefore?: string; - withArchived?: boolean; + visibility?: AssetVisibility; withDeleted?: boolean; withExif?: boolean; withPeople?: boolean; @@ -923,13 +918,11 @@ export type SmartSearchDto = { createdAfter?: string; createdBefore?: string; deviceId?: string; - isArchived?: boolean; isEncoded?: boolean; isFavorite?: boolean; isMotion?: boolean; isNotInAlbum?: boolean; isOffline?: boolean; - isVisible?: boolean; language?: string; lensModel?: string | null; libraryId?: string | null; @@ -949,7 +942,7 @@ export type SmartSearchDto = { "type"?: AssetTypeEnum; updatedAfter?: string; updatedBefore?: string; - withArchived?: boolean; + visibility?: AssetVisibility; withDeleted?: boolean; withExif?: boolean; }; @@ -1877,18 +1870,18 @@ export function getRandom({ count }: { ...opts })); } -export function getAssetStatistics({ isArchived, isFavorite, isTrashed }: { - isArchived?: boolean; +export function getAssetStatistics({ isFavorite, isTrashed, visibility }: { isFavorite?: boolean; isTrashed?: boolean; + visibility?: AssetVisibility; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetStatsResponseDto; }>(`/assets/statistics${QS.query(QS.explode({ - isArchived, isFavorite, - isTrashed + isTrashed, + visibility }))}`, { ...opts })); @@ -3242,9 +3235,8 @@ export function tagAssets({ id, bulkIdsDto }: { body: bulkIdsDto }))); } -export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, withPartners, withStacked }: { +export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, visibility, withPartners, withStacked }: { albumId?: string; - isArchived?: boolean; isFavorite?: boolean; isTrashed?: boolean; key?: string; @@ -3254,6 +3246,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, tagId?: string; timeBucket: string; userId?: string; + visibility?: AssetVisibility; withPartners?: boolean; withStacked?: boolean; }, opts?: Oazapfts.RequestOpts) { @@ -3262,7 +3255,6 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, data: AssetResponseDto[]; }>(`/timeline/bucket${QS.query(QS.explode({ albumId, - isArchived, isFavorite, isTrashed, key, @@ -3272,15 +3264,15 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, tagId, timeBucket, userId, + visibility, withPartners, withStacked }))}`, { ...opts })); } -export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, userId, withPartners, withStacked }: { +export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, size, tagId, userId, visibility, withPartners, withStacked }: { albumId?: string; - isArchived?: boolean; isFavorite?: boolean; isTrashed?: boolean; key?: string; @@ -3289,6 +3281,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key size: TimeBucketSize; tagId?: string; userId?: string; + visibility?: AssetVisibility; withPartners?: boolean; withStacked?: boolean; }, opts?: Oazapfts.RequestOpts) { @@ -3297,7 +3290,6 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key data: TimeBucketResponseDto[]; }>(`/timeline/buckets${QS.query(QS.explode({ albumId, - isArchived, isFavorite, isTrashed, key, @@ -3306,6 +3298,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key size, tagId, userId, + visibility, withPartners, withStacked }))}`, { @@ -3620,6 +3613,11 @@ export enum Permission { AdminUserUpdate = "admin.user.update", AdminUserDelete = "admin.user.delete" } +export enum AssetVisibility { + Archive = "archive", + Timeline = "timeline", + Hidden = "hidden" +} export enum AssetMediaStatus { Created = "created", Replaced = "replaced", diff --git a/server/src/controllers/asset-media.controller.spec.ts b/server/src/controllers/asset-media.controller.spec.ts index c674dc1f2..67bdeff22 100644 --- a/server/src/controllers/asset-media.controller.spec.ts +++ b/server/src/controllers/asset-media.controller.spec.ts @@ -100,38 +100,29 @@ describe(AssetMediaController.name, () => { expect(body).toEqual(factory.responses.badRequest()); }); - it('should throw if `isVisible` is not a boolean', async () => { + it('should throw if `visibility` is not an enum', async () => { const { status, body } = await request(ctx.getHttpServer()) .post('/assets') .attach('assetData', assetData, filename) - .field({ ...makeUploadDto(), isVisible: 'not-a-boolean' }); + .field({ ...makeUploadDto(), visibility: 'not-a-boolean' }); expect(status).toBe(400); expect(body).toEqual(factory.responses.badRequest()); }); - it('should throw if `isArchived` is not a boolean', async () => { - const { status, body } = await request(ctx.getHttpServer()) - .post('/assets') - .attach('assetData', assetData, filename) - .field({ ...makeUploadDto(), isArchived: 'not-a-boolean' }); - expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest()); + // TODO figure out how to deal with `sendFile` + describe.skip('GET /assets/:id/original', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/original`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); }); - }); - // TODO figure out how to deal with `sendFile` - describe.skip('GET /assets/:id/original', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/original`); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - // TODO figure out how to deal with `sendFile` - describe.skip('GET /assets/:id/thumbnail', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/thumbnail`); - expect(ctx.authenticate).toHaveBeenCalled(); + // TODO figure out how to deal with `sendFile` + describe.skip('GET /assets/:id/thumbnail', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/thumbnail`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); }); }); }); diff --git a/server/src/controllers/search.controller.spec.ts b/server/src/controllers/search.controller.spec.ts index 03c9625e1..14130fabc 100644 --- a/server/src/controllers/search.controller.spec.ts +++ b/server/src/controllers/search.controller.spec.ts @@ -60,12 +60,14 @@ describe(SearchController.name, () => { expect(body).toEqual(errorDto.badRequest(['size must not be less than 1', 'size must be an integer number'])); }); - it('should reject an isArchived as not a boolean', async () => { + it('should reject an visibility as not an enum', async () => { const { status, body } = await request(ctx.getHttpServer()) .post('/search/metadata') - .send({ isArchived: 'immich' }); + .send({ visibility: 'immich' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['isArchived must be a boolean value'])); + expect(body).toEqual( + errorDto.badRequest(['visibility must be one of the following values: archive, timeline, hidden']), + ); }); it('should reject an isFavorite as not a boolean', async () => { @@ -98,104 +100,98 @@ describe(SearchController.name, () => { expect(body).toEqual(errorDto.badRequest(['isMotion must be a boolean value'])); }); - it('should reject an isVisible as not a boolean', async () => { - const { status, body } = await request(ctx.getHttpServer()) - .post('/search/metadata') - .send({ isVisible: 'immich' }); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['isVisible must be a boolean value'])); - }); - }); + describe('POST /search/random', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post('/search/random'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); - describe('POST /search/random', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/search/random'); - expect(ctx.authenticate).toHaveBeenCalled(); + it('should reject if withStacked is not a boolean', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/search/random') + .send({ withStacked: 'immich' }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['withStacked must be a boolean value'])); + }); + + it('should reject if withPeople is not a boolean', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/search/random') + .send({ withPeople: 'immich' }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['withPeople must be a boolean value'])); + }); }); - it('should reject if withStacked is not a boolean', async () => { - const { status, body } = await request(ctx.getHttpServer()) - .post('/search/random') - .send({ withStacked: 'immich' }); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['withStacked must be a boolean value'])); + describe('POST /search/smart', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post('/search/smart'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a query', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/search/smart').send({}); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['query should not be empty', 'query must be a string'])); + }); }); - it('should reject if withPeople is not a boolean', async () => { - const { status, body } = await request(ctx.getHttpServer()).post('/search/random').send({ withPeople: 'immich' }); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['withPeople must be a boolean value'])); - }); - }); - - describe('POST /search/smart', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/search/smart'); - expect(ctx.authenticate).toHaveBeenCalled(); + describe('GET /search/explore', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/search/explore'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); }); - it('should require a query', async () => { - const { status, body } = await request(ctx.getHttpServer()).post('/search/smart').send({}); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['query should not be empty', 'query must be a string'])); - }); - }); + describe('POST /search/person', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/search/person'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); - describe('GET /search/explore', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/search/explore'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('POST /search/person', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/search/person'); - expect(ctx.authenticate).toHaveBeenCalled(); + it('should require a name', async () => { + const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({}); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['name should not be empty', 'name must be a string'])); + }); }); - it('should require a name', async () => { - const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({}); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['name should not be empty', 'name must be a string'])); - }); - }); + describe('GET /search/places', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/search/places'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); - describe('GET /search/places', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/search/places'); - expect(ctx.authenticate).toHaveBeenCalled(); + it('should require a name', async () => { + const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({}); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['name should not be empty', 'name must be a string'])); + }); }); - it('should require a name', async () => { - const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({}); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['name should not be empty', 'name must be a string'])); - }); - }); - - describe('GET /search/cities', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/search/cities'); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - - describe('GET /search/suggestions', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/search/suggestions'); - expect(ctx.authenticate).toHaveBeenCalled(); + describe('GET /search/cities', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/search/cities'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); }); - it('should require a type', async () => { - const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({}); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.badRequest([ - 'type should not be empty', - expect.stringContaining('type must be one of the following values:'), - ]), - ); + describe('GET /search/suggestions', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/search/suggestions'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a type', async () => { + const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({}); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.badRequest([ + 'type should not be empty', + expect.stringContaining('type must be one of the following values:'), + ]), + ); + }); }); }); }); diff --git a/server/src/database.ts b/server/src/database.ts index a93873ef4..a13b07444 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -5,6 +5,7 @@ import { AlbumUserRole, AssetFileType, AssetType, + AssetVisibility, MemoryType, Permission, SharedLinkType, @@ -108,7 +109,7 @@ export type Asset = { fileCreatedAt: Date; fileModifiedAt: Date; isExternal: boolean; - isVisible: boolean; + visibility: AssetVisibility; libraryId: string | null; livePhotoVideoId: string | null; localDateTime: Date; @@ -285,7 +286,7 @@ export const columns = { 'assets.fileCreatedAt', 'assets.fileModifiedAt', 'assets.isExternal', - 'assets.isVisible', + 'assets.visibility', 'assets.libraryId', 'assets.livePhotoVideoId', 'assets.localDateTime', @@ -345,7 +346,7 @@ export const columns = { 'type', 'deletedAt', 'isFavorite', - 'isVisible', + 'visibility', 'updateId', ], stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'], diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 85be9d520..1b039f998 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -10,6 +10,7 @@ import { AssetOrder, AssetStatus, AssetType, + AssetVisibility, MemoryType, NotificationLevel, NotificationType, @@ -148,11 +149,10 @@ export interface Assets { fileCreatedAt: Timestamp; fileModifiedAt: Timestamp; id: Generated; - isArchived: Generated; isExternal: Generated; isFavorite: Generated; isOffline: Generated; - isVisible: Generated; + visibility: Generated; libraryId: string | null; livePhotoVideoId: string | null; localDateTime: Timestamp; diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index 883713859..a647b4515 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -1,7 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, IsEnum, IsNotEmpty, IsString, ValidateNested } from 'class-validator'; -import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; +import { AssetVisibility } from 'src/enum'; +import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; export enum AssetMediaSize { /** @@ -55,11 +56,8 @@ export class AssetMediaCreateDto extends AssetMediaBase { @ValidateBoolean({ optional: true }) isFavorite?: boolean; - @ValidateBoolean({ optional: true }) - isArchived?: boolean; - - @ValidateBoolean({ optional: true }) - isVisible?: boolean; + @ValidateAssetVisibility({ optional: true }) + visibility?: AssetVisibility; @ValidateUUID({ optional: true }) livePhotoVideoId?: string; diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 3732e665c..480ad0b9b 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -12,7 +12,7 @@ import { } from 'src/dtos/person.dto'; import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; -import { AssetStatus, AssetType } from 'src/enum'; +import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { mimeTypes } from 'src/utils/mime-types'; export class SanitizedAssetResponseDto { @@ -74,11 +74,10 @@ export type MapAsset = { fileCreatedAt: Date; fileModifiedAt: Date; files?: AssetFile[]; - isArchived: boolean; isExternal: boolean; isFavorite: boolean; isOffline: boolean; - isVisible: boolean; + visibility: AssetVisibility; libraryId: string | null; livePhotoVideoId: string | null; localDateTime: Date; @@ -183,7 +182,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset localDateTime: entity.localDateTime, updatedAt: entity.updatedAt, isFavorite: options.auth?.user.id === entity.ownerId ? entity.isFavorite : false, - isArchived: entity.isArchived, + isArchived: entity.visibility === AssetVisibility.ARCHIVE, isTrashed: !!entity.deletedAt, duration: entity.duration ?? '0:00:00.00000', exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined, diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 32b14055d..078963387 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -14,9 +14,9 @@ import { ValidateIf, } from 'class-validator'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetType } from 'src/enum'; +import { AssetType, AssetVisibility } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; -import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; +import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateUUID } from 'src/validation'; export class DeviceIdDto { @IsNotEmpty() @@ -32,8 +32,8 @@ export class UpdateAssetBase { @ValidateBoolean({ optional: true }) isFavorite?: boolean; - @ValidateBoolean({ optional: true }) - isArchived?: boolean; + @ValidateAssetVisibility({ optional: true }) + visibility?: AssetVisibility; @Optional() @IsDateString() @@ -105,8 +105,8 @@ export class AssetJobsDto extends AssetIdsDto { } export class AssetStatsDto { - @ValidateBoolean({ optional: true }) - isArchived?: boolean; + @ValidateAssetVisibility({ optional: true }) + visibility?: AssetVisibility; @ValidateBoolean({ optional: true }) isFavorite?: boolean; diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index a7633dce7..579cba680 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -5,8 +5,8 @@ import { Place } from 'src/database'; import { PropertyLifecycle } from 'src/decorators'; import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { AssetOrder, AssetType } from 'src/enum'; -import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; +import { AssetOrder, AssetType, AssetVisibility } from 'src/enum'; +import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; class BaseSearchDto { @ValidateUUID({ optional: true, nullable: true }) @@ -22,13 +22,6 @@ class BaseSearchDto { @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType }) type?: AssetType; - @ValidateBoolean({ optional: true }) - isArchived?: boolean; - - @ValidateBoolean({ optional: true }) - @ApiProperty({ default: false }) - withArchived?: boolean; - @ValidateBoolean({ optional: true }) isEncoded?: boolean; @@ -41,8 +34,8 @@ class BaseSearchDto { @ValidateBoolean({ optional: true }) isOffline?: boolean; - @ValidateBoolean({ optional: true }) - isVisible?: boolean; + @ValidateAssetVisibility({ optional: true }) + visibility?: AssetVisibility; @ValidateBoolean({ optional: true }) withDeleted?: boolean; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index a035f8ecb..cc11c3410 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsInt, IsPositive, IsString } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { AssetType, SyncEntityType, SyncRequestType } from 'src/enum'; +import { AssetType, AssetVisibility, SyncEntityType, SyncRequestType } from 'src/enum'; import { Optional, ValidateDate, ValidateUUID } from 'src/validation'; export class AssetFullSyncDto { @@ -67,7 +67,7 @@ export class SyncAssetV1 { type!: AssetType; deletedAt!: Date | null; isFavorite!: boolean; - isVisible!: boolean; + visibility!: AssetVisibility; } export class SyncAssetDeleteV1 { diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index a9dfa49a0..51d46871a 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -1,8 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; -import { AssetOrder } from 'src/enum'; +import { AssetOrder, AssetVisibility } from 'src/enum'; import { TimeBucketSize } from 'src/repositories/asset.repository'; -import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; +import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateUUID } from 'src/validation'; export class TimeBucketDto { @IsNotEmpty() @@ -22,9 +22,6 @@ export class TimeBucketDto { @ValidateUUID({ optional: true }) tagId?: string; - @ValidateBoolean({ optional: true }) - isArchived?: boolean; - @ValidateBoolean({ optional: true }) isFavorite?: boolean; @@ -41,6 +38,9 @@ export class TimeBucketDto { @Optional() @ApiProperty({ enum: AssetOrder, enumName: 'AssetOrder' }) order?: AssetOrder; + + @ValidateAssetVisibility({ optional: true }) + visibility?: AssetVisibility; } export class TimeBucketAssetDto extends TimeBucketDto { diff --git a/server/src/enum.ts b/server/src/enum.ts index a9ea285c2..f21459397 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -618,3 +618,13 @@ export enum DatabaseSslMode { Require = 'require', VerifyFull = 'verify-full', } + +export enum AssetVisibility { + ARCHIVE = 'archive', + TIMELINE = 'timeline', + + /** + * Video part of the LivePhotos and MotionPhotos + */ + HIDDEN = 'hidden', +} diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 03f1af3b2..f550c5b0c 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -110,8 +110,11 @@ from and "assets"."deletedAt" is null where "partner"."sharedWithId" = $1 - and "assets"."isArchived" = $2 - and "assets"."id" in ($3) + and ( + "assets"."visibility" = 'timeline' + or "assets"."visibility" = 'hidden' + ) + and "assets"."id" in ($2) -- AccessRepository.asset.checkSharedLinkAccess select diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index d8e8430be..577635a91 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -7,7 +7,7 @@ select "ownerId", "duplicateId", "stackId", - "isVisible", + "visibility", "smart_search"."embedding", ( select @@ -83,7 +83,7 @@ from inner join "asset_job_status" on "asset_job_status"."assetId" = "assets"."id" where "assets"."deletedAt" is null - and "assets"."isVisible" = $1 + and "assets"."visibility" != $1 and ( "asset_job_status"."previewAt" is null or "asset_job_status"."thumbnailAt" is null @@ -118,7 +118,7 @@ where -- AssetJobRepository.getForGenerateThumbnailJob select "assets"."id", - "assets"."isVisible", + "assets"."visibility", "assets"."originalFileName", "assets"."originalPath", "assets"."ownerId", @@ -155,7 +155,7 @@ select "assets"."fileCreatedAt", "assets"."fileModifiedAt", "assets"."isExternal", - "assets"."isVisible", + "assets"."visibility", "assets"."libraryId", "assets"."livePhotoVideoId", "assets"."localDateTime", @@ -201,7 +201,7 @@ from "assets" inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id" where - "assets"."isVisible" = $1 + "assets"."visibility" != $1 and "assets"."deletedAt" is null and "job_status"."previewAt" is not null and not exists ( @@ -220,7 +220,7 @@ from "assets" inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id" where - "assets"."isVisible" = $1 + "assets"."visibility" != $1 and "assets"."deletedAt" is null and "job_status"."previewAt" is not null and not exists ( @@ -234,7 +234,7 @@ where -- AssetJobRepository.getForClipEncoding select "assets"."id", - "assets"."isVisible", + "assets"."visibility", ( select coalesce(json_agg(agg), '[]') @@ -259,7 +259,7 @@ where -- AssetJobRepository.getForDetectFacesJob select "assets"."id", - "assets"."isVisible", + "assets"."visibility", to_json("exif") as "exifInfo", ( select @@ -312,7 +312,7 @@ where -- AssetJobRepository.getForAssetDeletion select "assets"."id", - "assets"."isVisible", + "assets"."visibility", "assets"."libraryId", "assets"."ownerId", "assets"."livePhotoVideoId", @@ -372,7 +372,7 @@ from "assets" as "stacked" where "stacked"."deletedAt" is not null - and "stacked"."isArchived" = $1 + and "stacked"."visibility" != $1 and "stacked"."stackId" = "asset_stack"."id" group by "asset_stack"."id" @@ -391,7 +391,7 @@ where "assets"."encodedVideoPath" is null or "assets"."encodedVideoPath" = $2 ) - and "assets"."isVisible" = $3 + and "assets"."visibility" != $3 and "assets"."deletedAt" is null -- AssetJobRepository.getForVideoConversion @@ -417,7 +417,7 @@ where "asset_job_status"."metadataExtractedAt" is null or "asset_job_status"."assetId" is null ) - and "assets"."isVisible" = $1 + and "assets"."visibility" != $1 and "assets"."deletedAt" is null -- AssetJobRepository.getForStorageTemplateJob @@ -480,7 +480,7 @@ where "assets"."sidecarPath" = $1 or "assets"."sidecarPath" is null ) - and "assets"."isVisible" = $2 + and "assets"."visibility" != $2 -- AssetJobRepository.streamForDetectFacesJob select @@ -489,7 +489,7 @@ from "assets" inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id" where - "assets"."isVisible" = $1 + "assets"."visibility" != $1 and "assets"."deletedAt" is null and "job_status"."previewAt" is not null and "job_status"."facesRecognizedAt" is null diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index cb438e1c6..4a3fbf0e3 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -43,21 +43,20 @@ with "asset_job_status"."previewAt" is not null and (assets."localDateTime" at time zone 'UTC')::date = today.date and "assets"."ownerId" = any ($3::uuid[]) - and "assets"."isVisible" = $4 - and "assets"."isArchived" = $5 + and "assets"."visibility" = $4 and exists ( select from "asset_files" where "assetId" = "assets"."id" - and "asset_files"."type" = $6 + and "asset_files"."type" = $5 ) and "assets"."deletedAt" is null order by (assets."localDateTime" at time zone 'UTC')::date desc limit - $7 + $6 ) as "a" on true inner join "exif" on "a"."id" = "exif"."assetId" ) @@ -159,7 +158,7 @@ from where "ownerId" = $1::uuid and "deviceId" = $2 - and "isVisible" = $3 + and "visibility" != $3 and "deletedAt" is null -- AssetRepository.getLivePhotoCount @@ -241,7 +240,10 @@ with "assets" where "assets"."deletedAt" is null - and "assets"."isVisible" = $2 + and ( + "assets"."visibility" = $2 + or "assets"."visibility" = $3 + ) ) select "timeBucket", @@ -271,7 +273,7 @@ from where "stacked"."stackId" = "asset_stack"."id" and "stacked"."deletedAt" is null - and "stacked"."isArchived" = $1 + and "stacked"."visibility" != $1 group by "asset_stack"."id" ) as "stacked_assets" on "asset_stack"."id" is not null @@ -281,8 +283,11 @@ where or "assets"."stackId" is null ) and "assets"."deletedAt" is null - and "assets"."isVisible" = $2 - and date_trunc($3, "localDateTime" at time zone 'UTC') at time zone 'UTC' = $4 + 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 @@ -307,7 +312,7 @@ with "assets"."ownerId" = $1::uuid and "assets"."duplicateId" is not null and "assets"."deletedAt" is null - and "assets"."isVisible" = $2 + and "assets"."visibility" != $2 and "assets"."stackId" is null group by "assets"."duplicateId" @@ -365,12 +370,11 @@ from inner join "cities" on "exif"."city" = "cities"."city" where "ownerId" = $2::uuid - and "isVisible" = $3 - and "isArchived" = $4 - and "type" = $5 + and "visibility" = $3 + and "type" = $4 and "deletedAt" is null limit - $6 + $5 -- AssetRepository.getAllForUserFullSync select @@ -394,7 +398,7 @@ from ) as "stacked_assets" on "asset_stack"."id" is not null where "assets"."ownerId" = $1::uuid - and "assets"."isVisible" = $2 + and "assets"."visibility" != $2 and "assets"."updatedAt" <= $3 and "assets"."id" > $4 order by @@ -424,7 +428,7 @@ from ) as "stacked_assets" on "asset_stack"."id" is not null where "assets"."ownerId" = any ($1::uuid[]) - and "assets"."isVisible" = $2 + and "assets"."visibility" != $2 and "assets"."updatedAt" > $3 limit $4 diff --git a/server/src/queries/library.repository.sql b/server/src/queries/library.repository.sql index 43500a874..f17d9663d 100644 --- a/server/src/queries/library.repository.sql +++ b/server/src/queries/library.repository.sql @@ -35,14 +35,14 @@ select where ( "assets"."type" = $1 - and "assets"."isVisible" = $2 + and "assets"."visibility" != $2 ) ) as "photos", count(*) filter ( where ( "assets"."type" = $3 - and "assets"."isVisible" = $4 + and "assets"."visibility" != $4 ) ) as "videos", coalesce(sum("exif"."fileSizeInByte"), $5) as "usage" diff --git a/server/src/queries/map.repository.sql b/server/src/queries/map.repository.sql index b3bb20794..edfdec13d 100644 --- a/server/src/queries/map.repository.sql +++ b/server/src/queries/map.repository.sql @@ -14,7 +14,7 @@ from and "exif"."latitude" is not null and "exif"."longitude" is not null where - "isVisible" = $1 + "assets"."visibility" = $1 and "deletedAt" is null and ( "ownerId" in ($2) diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index f9ba32262..09517a1e1 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -107,7 +107,7 @@ select ( select "assets"."ownerId", - "assets"."isArchived", + "assets"."visibility", "assets"."fileCreatedAt" from "assets" @@ -203,7 +203,7 @@ from "asset_faces" left join "assets" on "assets"."id" = "asset_faces"."assetId" and "asset_faces"."personId" = $1 - and "assets"."isArchived" = $2 + and "assets"."visibility" != $2 and "assets"."deletedAt" is null where "asset_faces"."deletedAt" is null @@ -220,7 +220,7 @@ from inner join "asset_faces" on "asset_faces"."personId" = "person"."id" inner join "assets" on "assets"."id" = "asset_faces"."assetId" and "assets"."deletedAt" is null - and "assets"."isArchived" = $2 + and "assets"."visibility" != $2 where "person"."ownerId" = $3 and "asset_faces"."deletedAt" is null diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 4fce27236..c18fe0241 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -7,11 +7,11 @@ from "assets" inner join "exif" on "assets"."id" = "exif"."assetId" where - "assets"."fileCreatedAt" >= $1 - and "exif"."lensModel" = $2 - and "assets"."ownerId" = any ($3::uuid[]) - and "assets"."isFavorite" = $4 - and "assets"."isArchived" = $5 + "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 order by "assets"."fileCreatedAt" desc @@ -28,11 +28,11 @@ offset "assets" inner join "exif" on "assets"."id" = "exif"."assetId" where - "assets"."fileCreatedAt" >= $1 - and "exif"."lensModel" = $2 - and "assets"."ownerId" = any ($3::uuid[]) - and "assets"."isFavorite" = $4 - and "assets"."isArchived" = $5 + "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 and "assets"."id" < $6 order by @@ -48,11 +48,11 @@ union all "assets" inner join "exif" on "assets"."id" = "exif"."assetId" where - "assets"."fileCreatedAt" >= $8 - and "exif"."lensModel" = $9 - and "assets"."ownerId" = any ($10::uuid[]) - and "assets"."isFavorite" = $11 - and "assets"."isArchived" = $12 + "assets"."visibility" = $8 + and "assets"."fileCreatedAt" >= $9 + and "exif"."lensModel" = $10 + and "assets"."ownerId" = any ($11::uuid[]) + and "assets"."isFavorite" = $12 and "assets"."deletedAt" is null and "assets"."id" > $13 order by @@ -71,11 +71,11 @@ from inner join "exif" on "assets"."id" = "exif"."assetId" inner join "smart_search" on "assets"."id" = "smart_search"."assetId" where - "assets"."fileCreatedAt" >= $1 - and "exif"."lensModel" = $2 - and "assets"."ownerId" = any ($3::uuid[]) - and "assets"."isFavorite" = $4 - and "assets"."isArchived" = $5 + "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 order by smart_search.embedding <=> $6 @@ -97,7 +97,7 @@ with where "assets"."ownerId" = any ($2::uuid[]) and "assets"."deletedAt" is null - and "assets"."isVisible" = $3 + and "assets"."visibility" != $3 and "assets"."type" = $4 and "assets"."id" != $5::uuid and "assets"."stackId" is null @@ -176,14 +176,13 @@ with recursive inner join "assets" on "assets"."id" = "exif"."assetId" where "assets"."ownerId" = any ($1::uuid[]) - and "assets"."isVisible" = $2 - and "assets"."isArchived" = $3 - and "assets"."type" = $4 + and "assets"."visibility" = $2 + and "assets"."type" = $3 and "assets"."deletedAt" is null order by "city" limit - $5 + $4 ) union all ( @@ -200,16 +199,15 @@ with recursive "exif" inner join "assets" on "assets"."id" = "exif"."assetId" where - "assets"."ownerId" = any ($6::uuid[]) - and "assets"."isVisible" = $7 - and "assets"."isArchived" = $8 - and "assets"."type" = $9 + "assets"."ownerId" = any ($5::uuid[]) + and "assets"."visibility" = $6 + and "assets"."type" = $7 and "assets"."deletedAt" is null and "exif"."city" > "cte"."city" order by "city" limit - $10 + $8 ) as "l" on true ) ) @@ -231,7 +229,7 @@ from inner join "assets" on "assets"."id" = "exif"."assetId" where "ownerId" = any ($1::uuid[]) - and "isVisible" = $2 + and "visibility" != $2 and "deletedAt" is null and "state" is not null @@ -243,7 +241,7 @@ from inner join "assets" on "assets"."id" = "exif"."assetId" where "ownerId" = any ($1::uuid[]) - and "isVisible" = $2 + and "visibility" != $2 and "deletedAt" is null and "city" is not null @@ -255,7 +253,7 @@ from inner join "assets" on "assets"."id" = "exif"."assetId" where "ownerId" = any ($1::uuid[]) - and "isVisible" = $2 + and "visibility" != $2 and "deletedAt" is null and "make" is not null @@ -267,6 +265,6 @@ from inner join "assets" on "assets"."id" = "exif"."assetId" where "ownerId" = any ($1::uuid[]) - and "isVisible" = $2 + and "visibility" != $2 and "deletedAt" is null and "model" is not null diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index e08335d9f..54c1292d8 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -84,7 +84,7 @@ select "type", "deletedAt", "isFavorite", - "isVisible", + "visibility", "updateId" from "assets" @@ -106,7 +106,7 @@ select "type", "deletedAt", "isFavorite", - "isVisible", + "visibility", "updateId" from "assets" diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index e8ab5018f..72881feea 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -285,14 +285,14 @@ select where ( "assets"."type" = 'IMAGE' - and "assets"."isVisible" = true + and "assets"."visibility" != 'hidden' ) ) as "photos", count(*) filter ( where ( "assets"."type" = 'VIDEO' - and "assets"."isVisible" = true + and "assets"."visibility" != 'hidden' ) ) as "videos", coalesce( diff --git a/server/src/queries/view.repository.sql b/server/src/queries/view.repository.sql index 151052152..a2260ce5f 100644 --- a/server/src/queries/view.repository.sql +++ b/server/src/queries/view.repository.sql @@ -7,8 +7,7 @@ from "assets" where "ownerId" = $2::uuid - and "isVisible" = $3 - and "isArchived" = $4 + and "visibility" = $3 and "deletedAt" is null and "fileCreatedAt" is not null and "fileModifiedAt" is not null @@ -23,13 +22,12 @@ from left join "exif" on "assets"."id" = "exif"."assetId" where "ownerId" = $1::uuid - and "isVisible" = $2 - and "isArchived" = $3 + and "visibility" = $2 and "deletedAt" is null and "fileCreatedAt" is not null and "fileModifiedAt" is not null and "localDateTime" is not null - and "originalPath" like $4 - and "originalPath" not like $5 + and "originalPath" like $3 + and "originalPath" not like $4 order by - regexp_replace("assets"."originalPath", $6, $7) asc + regexp_replace("assets"."originalPath", $5, $6) asc diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index c24209e48..5680ce2c6 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -3,7 +3,7 @@ import { Kysely, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DB } from 'src/db'; import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; -import { AlbumUserRole } from 'src/enum'; +import { AlbumUserRole, AssetVisibility } from 'src/enum'; import { asUuid } from 'src/utils/database'; class ActivityAccess { @@ -199,7 +199,13 @@ class AssetAccess { ) .select('assets.id') .where('partner.sharedWithId', '=', userId) - .where('assets.isArchived', '=', false) + .where((eb) => + eb.or([ + eb('assets.visibility', '=', sql.lit(AssetVisibility.TIMELINE)), + eb('assets.visibility', '=', sql.lit(AssetVisibility.HIDDEN)), + ]), + ) + .where('assets.id', 'in', [...assetIds]) .execute() .then((assets) => new Set(assets.map((asset) => asset.id))); diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 1506f2997..132bef698 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -5,7 +5,7 @@ import { InjectKysely } from 'nestjs-kysely'; import { Asset, columns } from 'src/database'; import { DB } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { AssetFileType, AssetType } from 'src/enum'; +import { AssetFileType, AssetType, AssetVisibility } from 'src/enum'; import { StorageAsset } from 'src/types'; import { anyUuid, @@ -34,7 +34,7 @@ export class AssetJobRepository { 'ownerId', 'duplicateId', 'stackId', - 'isVisible', + 'visibility', 'smart_search.embedding', withFiles(eb, AssetFileType.PREVIEW), ]) @@ -70,7 +70,7 @@ export class AssetJobRepository { .select(['assets.id', 'assets.thumbhash']) .select(withFiles) .where('assets.deletedAt', 'is', null) - .where('assets.isVisible', '=', true) + .where('assets.visibility', '!=', AssetVisibility.HIDDEN) .$if(!force, (qb) => qb // If there aren't any entries, metadata extraction hasn't run yet which is required for thumbnails @@ -102,7 +102,7 @@ export class AssetJobRepository { .selectFrom('assets') .select([ 'assets.id', - 'assets.isVisible', + 'assets.visibility', 'assets.originalFileName', 'assets.originalPath', 'assets.ownerId', @@ -138,7 +138,7 @@ export class AssetJobRepository { private assetsWithPreviews() { return this.db .selectFrom('assets') - .where('assets.isVisible', '=', true) + .where('assets.visibility', '!=', AssetVisibility.HIDDEN) .where('assets.deletedAt', 'is', null) .innerJoin('asset_job_status as job_status', 'assetId', 'assets.id') .where('job_status.previewAt', 'is not', null); @@ -169,7 +169,7 @@ export class AssetJobRepository { getForClipEncoding(id: string) { return this.db .selectFrom('assets') - .select(['assets.id', 'assets.isVisible']) + .select(['assets.id', 'assets.visibility']) .select((eb) => withFiles(eb, AssetFileType.PREVIEW)) .where('assets.id', '=', id) .executeTakeFirst(); @@ -179,7 +179,7 @@ export class AssetJobRepository { getForDetectFacesJob(id: string) { return this.db .selectFrom('assets') - .select(['assets.id', 'assets.isVisible']) + .select(['assets.id', 'assets.visibility']) .$call(withExifInner) .select((eb) => withFaces(eb, true)) .select((eb) => withFiles(eb, AssetFileType.PREVIEW)) @@ -209,7 +209,7 @@ export class AssetJobRepository { .selectFrom('assets') .select([ 'assets.id', - 'assets.isVisible', + 'assets.visibility', 'assets.libraryId', 'assets.ownerId', 'assets.livePhotoVideoId', @@ -228,7 +228,7 @@ export class AssetJobRepository { .select(['asset_stack.id', 'asset_stack.primaryAssetId']) .select((eb) => eb.fn('array_agg', [eb.table('stacked')]).as('assets')) .where('stacked.deletedAt', 'is not', null) - .where('stacked.isArchived', '=', false) + .where('stacked.visibility', '!=', AssetVisibility.ARCHIVE) .whereRef('stacked.stackId', '=', 'asset_stack.id') .groupBy('asset_stack.id') .as('stacked_assets'), @@ -248,7 +248,7 @@ export class AssetJobRepository { .$if(!force, (qb) => qb .where((eb) => eb.or([eb('assets.encodedVideoPath', 'is', null), eb('assets.encodedVideoPath', '=', '')])) - .where('assets.isVisible', '=', true), + .where('assets.visibility', '!=', AssetVisibility.HIDDEN), ) .where('assets.deletedAt', 'is', null) .stream(); @@ -275,7 +275,7 @@ export class AssetJobRepository { .where((eb) => eb.or([eb('asset_job_status.metadataExtractedAt', 'is', null), eb('asset_job_status.assetId', 'is', null)]), ) - .where('assets.isVisible', '=', true), + .where('assets.visibility', '!=', AssetVisibility.HIDDEN), ) .where('assets.deletedAt', 'is', null) .stream(); @@ -331,7 +331,7 @@ export class AssetJobRepository { .$if(!force, (qb) => qb.where((eb) => eb.or([eb('assets.sidecarPath', '=', ''), eb('assets.sidecarPath', 'is', null)])), ) - .where('assets.isVisible', '=', true) + .where('assets.visibility', '!=', AssetVisibility.HIDDEN) .stream(); } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 89062c210..9bd115089 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -6,7 +6,7 @@ import { Stack } from 'src/database'; import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { MapAsset } from 'src/dtos/asset-response.dto'; -import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum'; +import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { anyUuid, asUuid, @@ -14,6 +14,7 @@ import { removeUndefinedKeys, truncatedDate, unnest, + withDefaultVisibility, withExif, withFaces, withFacesAndPeople, @@ -30,8 +31,8 @@ export type AssetStats = Record; export interface AssetStatsOptions { isFavorite?: boolean; - isArchived?: boolean; isTrashed?: boolean; + visibility?: AssetVisibility; } export interface LivePhotoSearchOptions { @@ -52,7 +53,6 @@ export enum TimeBucketSize { } export interface AssetBuilderOptions { - isArchived?: boolean; isFavorite?: boolean; isTrashed?: boolean; isDuplicate?: boolean; @@ -64,6 +64,7 @@ export interface AssetBuilderOptions { exifInfo?: boolean; status?: AssetStatus; assetType?: AssetType; + visibility?: AssetVisibility; } export interface TimeBucketOptions extends AssetBuilderOptions { @@ -258,8 +259,7 @@ export class AssetRepository { .where('asset_job_status.previewAt', 'is not', null) .where(sql`(assets."localDateTime" at time zone 'UTC')::date`, '=', sql`today.date`) .where('assets.ownerId', '=', anyUuid(ownerIds)) - .where('assets.isVisible', '=', true) - .where('assets.isArchived', '=', false) + .where('assets.visibility', '=', AssetVisibility.TIMELINE) .where((eb) => eb.exists((qb) => qb @@ -348,7 +348,7 @@ export class AssetRepository { .select(['deviceAssetId']) .where('ownerId', '=', asUuid(ownerId)) .where('deviceId', '=', deviceId) - .where('isVisible', '=', true) + .where('visibility', '!=', AssetVisibility.HIDDEN) .where('deletedAt', 'is', null) .execute(); @@ -393,7 +393,7 @@ export class AssetRepository { .whereRef('stacked.stackId', '=', 'asset_stack.id') .whereRef('stacked.id', '!=', 'asset_stack.primaryAssetId') .where('stacked.deletedAt', 'is', null) - .where('stacked.isArchived', '=', false) + .where('stacked.visibility', '=', AssetVisibility.TIMELINE) .groupBy('asset_stack.id') .as('stacked_assets'), (join) => join.on('asset_stack.id', 'is not', null), @@ -503,7 +503,7 @@ export class AssetRepository { .executeTakeFirst(); } - getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise { + getStatistics(ownerId: string, { visibility, isFavorite, isTrashed }: AssetStatsOptions): Promise { return this.db .selectFrom('assets') .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.AUDIO).as(AssetType.AUDIO)) @@ -511,8 +511,8 @@ export class AssetRepository { .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO)) .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER)) .where('ownerId', '=', asUuid(ownerId)) - .where('isVisible', '=', true) - .$if(isArchived !== undefined, (qb) => qb.where('isArchived', '=', isArchived!)) + .$if(visibility === undefined, withDefaultVisibility) + .$if(!!visibility, (qb) => qb.where('assets.visibility', '=', visibility!)) .$if(isFavorite !== undefined, (qb) => qb.where('isFavorite', '=', isFavorite!)) .$if(!!isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .where('deletedAt', isTrashed ? 'is not' : 'is', null) @@ -525,7 +525,7 @@ export class AssetRepository { .selectAll('assets') .$call(withExif) .where('ownerId', '=', anyUuid(userIds)) - .where('isVisible', '=', true) + .where('visibility', '!=', AssetVisibility.HIDDEN) .where('deletedAt', 'is', null) .orderBy((eb) => eb.fn('random')) .limit(take) @@ -542,7 +542,8 @@ export class AssetRepository { .select(truncatedDate(options.size).as('timeBucket')) .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) - .where('assets.isVisible', '=', true) + .$if(options.visibility === undefined, withDefaultVisibility) + .$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!)) .$if(!!options.albumId, (qb) => qb .innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId') @@ -559,7 +560,6 @@ export class AssetRepository { .where((eb) => eb.or([eb('assets.stackId', 'is', null), eb(eb.table('asset_stack'), 'is not', null)])), ) .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) - .$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) .$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!)) .$if(options.isDuplicate !== undefined, (qb) => @@ -594,7 +594,6 @@ export class AssetRepository { ) .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) - .$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) .$if(!!options.withStacked, (qb) => qb @@ -610,7 +609,7 @@ export class AssetRepository { .select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount')) .whereRef('stacked.stackId', '=', 'asset_stack.id') .where('stacked.deletedAt', 'is', null) - .where('stacked.isArchived', '=', false) + .where('stacked.visibility', '!=', AssetVisibility.ARCHIVE) .groupBy('asset_stack.id') .as('stacked_assets'), (join) => join.on('asset_stack.id', 'is not', null), @@ -624,7 +623,8 @@ export class AssetRepository { .$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) - .where('assets.isVisible', '=', true) + .$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(); @@ -658,7 +658,7 @@ export class AssetRepository { .where('assets.duplicateId', 'is not', null) .$narrowType<{ duplicateId: NotNull }>() .where('assets.deletedAt', 'is', null) - .where('assets.isVisible', '=', true) + .where('assets.visibility', '!=', AssetVisibility.HIDDEN) .where('assets.stackId', 'is', null) .groupBy('assets.duplicateId'), ) @@ -703,8 +703,7 @@ export class AssetRepository { .select(['assetId as data', 'exif.city as value']) .$narrowType<{ value: NotNull }>() .where('ownerId', '=', asUuid(ownerId)) - .where('isVisible', '=', true) - .where('isArchived', '=', false) + .where('visibility', '=', AssetVisibility.TIMELINE) .where('type', '=', AssetType.IMAGE) .where('deletedAt', 'is', null) .limit(maxFields) @@ -743,7 +742,7 @@ export class AssetRepository { ) .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).$castTo().as('stack')) .where('assets.ownerId', '=', asUuid(ownerId)) - .where('assets.isVisible', '=', true) + .where('assets.visibility', '!=', AssetVisibility.HIDDEN) .where('assets.updatedAt', '<=', updatedUntil) .$if(!!lastId, (qb) => qb.where('assets.id', '>', lastId!)) .orderBy('assets.id') @@ -771,7 +770,7 @@ export class AssetRepository { ) .select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo()).as('stack')) .where('assets.ownerId', '=', anyUuid(options.userIds)) - .where('assets.isVisible', '=', true) + .where('assets.visibility', '!=', AssetVisibility.HIDDEN) .where('assets.updatedAt', '>', options.updatedAfter) .limit(options.limit) .execute(); diff --git a/server/src/repositories/download.repository.ts b/server/src/repositories/download.repository.ts index c9c62c90c..4c4bed07f 100644 --- a/server/src/repositories/download.repository.ts +++ b/server/src/repositories/download.repository.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { Kysely } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DB } from 'src/db'; +import { AssetVisibility } from 'src/enum'; import { anyUuid } from 'src/utils/database'; const builder = (db: Kysely) => @@ -31,6 +32,9 @@ export class DownloadRepository { } downloadUserId(userId: string) { - return builder(this.db).where('assets.ownerId', '=', userId).where('assets.isVisible', '=', true).stream(); + return builder(this.db) + .where('assets.ownerId', '=', userId) + .where('assets.visibility', '!=', AssetVisibility.HIDDEN) + .stream(); } } diff --git a/server/src/repositories/library.repository.ts b/server/src/repositories/library.repository.ts index fd9dd81b7..b6c5ebbe0 100644 --- a/server/src/repositories/library.repository.ts +++ b/server/src/repositories/library.repository.ts @@ -4,7 +4,7 @@ import { InjectKysely } from 'nestjs-kysely'; import { DB, Libraries } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { LibraryStatsResponseDto } from 'src/dtos/library.dto'; -import { AssetType } from 'src/enum'; +import { AssetType, AssetVisibility } from 'src/enum'; export enum AssetSyncResult { DO_NOTHING, @@ -77,13 +77,17 @@ export class LibraryRepository { .select((eb) => eb.fn .countAll() - .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)])) + .filterWhere((eb) => + eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.visibility', '!=', AssetVisibility.HIDDEN)]), + ) .as('photos'), ) .select((eb) => eb.fn .countAll() - .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)])) + .filterWhere((eb) => + eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.visibility', '!=', AssetVisibility.HIDDEN)]), + ) .as('videos'), ) .select((eb) => eb.fn.coalesce((eb) => eb.fn.sum('exif.fileSizeInByte'), eb.val(0)).as('usage')) diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index f9998ad17..3f559442a 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -8,7 +8,7 @@ import readLine from 'node:readline'; import { citiesFile } from 'src/constants'; import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { SystemMetadataKey } from 'src/enum'; +import { AssetVisibility, SystemMetadataKey } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; @@ -75,9 +75,11 @@ export class MapRepository { } @GenerateSql({ params: [[DummyValue.UUID], [DummyValue.UUID]] }) - getMapMarkers(ownerIds: string[], albumIds: string[], options: MapMarkerSearchOptions = {}) { - const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options; - + getMapMarkers( + ownerIds: string[], + albumIds: string[], + { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore }: MapMarkerSearchOptions = {}, + ) { return this.db .selectFrom('assets') .innerJoin('exif', (builder) => @@ -88,8 +90,17 @@ export class MapRepository { ) .select(['id', 'exif.latitude as lat', 'exif.longitude as lon', 'exif.city', 'exif.state', 'exif.country']) .$narrowType<{ lat: NotNull; lon: NotNull }>() - .where('isVisible', '=', true) - .$if(isArchived !== undefined, (q) => q.where('isArchived', '=', isArchived!)) + .$if(isArchived === true, (qb) => + qb.where((eb) => + eb.or([ + eb('assets.visibility', '=', AssetVisibility.TIMELINE), + eb('assets.visibility', '=', AssetVisibility.ARCHIVE), + ]), + ), + ) + .$if(isArchived === false || isArchived === undefined, (qb) => + qb.where('assets.visibility', '=', AssetVisibility.TIMELINE), + ) .$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!)) .$if(fileCreatedAfter !== undefined, (q) => q.where('fileCreatedAt', '>=', fileCreatedAfter!)) .$if(fileCreatedBefore !== undefined, (q) => q.where('fileCreatedAt', '<=', fileCreatedBefore!)) diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 0383a54a2..b55537bdb 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -4,7 +4,7 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFaces, DB, FaceSearch, Person } from 'src/db'; import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; -import { AssetFileType, SourceType } from 'src/enum'; +import { AssetFileType, AssetVisibility, SourceType } from 'src/enum'; import { removeUndefinedKeys } from 'src/utils/database'; import { paginationHelper, PaginationOptions } from 'src/utils/pagination'; @@ -157,7 +157,7 @@ export class PersonRepository { .innerJoin('assets', (join) => join .onRef('asset_faces.assetId', '=', 'assets.id') - .on('assets.isArchived', '=', false) + .on('assets.visibility', '!=', AssetVisibility.ARCHIVE) .on('assets.deletedAt', 'is', null), ) .where('person.ownerId', '=', userId) @@ -248,7 +248,7 @@ export class PersonRepository { jsonObjectFrom( eb .selectFrom('assets') - .select(['assets.ownerId', 'assets.isArchived', 'assets.fileCreatedAt']) + .select(['assets.ownerId', 'assets.visibility', 'assets.fileCreatedAt']) .whereRef('assets.id', '=', 'asset_faces.assetId'), ).as('asset'), ) @@ -346,7 +346,7 @@ export class PersonRepository { join .onRef('assets.id', '=', 'asset_faces.assetId') .on('asset_faces.personId', '=', personId) - .on('assets.isArchived', '=', false) + .on('assets.visibility', '!=', AssetVisibility.ARCHIVE) .on('assets.deletedAt', 'is', null), ) .select((eb) => eb.fn.count(eb.fn('distinct', ['assets.id'])).as('count')) @@ -369,7 +369,7 @@ export class PersonRepository { join .onRef('assets.id', '=', 'asset_faces.assetId') .on('assets.deletedAt', 'is', null) - .on('assets.isArchived', '=', false), + .on('assets.visibility', '!=', AssetVisibility.ARCHIVE), ) .select((eb) => eb.fn.count(eb.fn('distinct', ['person.id'])).as('total')) .select((eb) => diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index b991ecc78..4e6b6e0fc 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto'; import { DB, Exif } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { MapAsset } from 'src/dtos/asset-response.dto'; -import { AssetStatus, AssetType } from 'src/enum'; +import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { anyUuid, asUuid, searchAssetBuilder, vectorIndexQuery } from 'src/utils/database'; import { paginationHelper } from 'src/utils/pagination'; @@ -26,17 +26,16 @@ export interface SearchUserIdOptions { export type SearchIdOptions = SearchAssetIdOptions & SearchUserIdOptions; export interface SearchStatusOptions { - isArchived?: boolean; isEncoded?: boolean; isFavorite?: boolean; isMotion?: boolean; isOffline?: boolean; - isVisible?: boolean; isNotInAlbum?: boolean; type?: AssetType; status?: AssetStatus; withArchived?: boolean; withDeleted?: boolean; + visibility?: AssetVisibility; } export interface SearchOneToOneRelationOptions { @@ -276,7 +275,7 @@ export class SearchRepository { .innerJoin('smart_search', 'assets.id', 'smart_search.assetId') .where('assets.ownerId', '=', anyUuid(userIds)) .where('assets.deletedAt', 'is', null) - .where('assets.isVisible', '=', true) + .where('assets.visibility', '!=', AssetVisibility.HIDDEN) .where('assets.type', '=', type) .where('assets.id', '!=', asUuid(assetId)) .where('assets.stackId', 'is', null) @@ -367,8 +366,7 @@ export class SearchRepository { .select(['city', 'assetId']) .innerJoin('assets', 'assets.id', 'exif.assetId') .where('assets.ownerId', '=', anyUuid(userIds)) - .where('assets.isVisible', '=', true) - .where('assets.isArchived', '=', false) + .where('assets.visibility', '=', AssetVisibility.TIMELINE) .where('assets.type', '=', AssetType.IMAGE) .where('assets.deletedAt', 'is', null) .orderBy('city') @@ -384,8 +382,7 @@ export class SearchRepository { .select(['city', 'assetId']) .innerJoin('assets', 'assets.id', 'exif.assetId') .where('assets.ownerId', '=', anyUuid(userIds)) - .where('assets.isVisible', '=', true) - .where('assets.isArchived', '=', false) + .where('assets.visibility', '=', AssetVisibility.TIMELINE) .where('assets.type', '=', AssetType.IMAGE) .where('assets.deletedAt', 'is', null) .whereRef('exif.city', '>', 'cte.city') @@ -518,7 +515,7 @@ export class SearchRepository { .distinctOn(field) .innerJoin('assets', 'assets.id', 'exif.assetId') .where('ownerId', '=', anyUuid(userIds)) - .where('isVisible', '=', true) + .where('visibility', '!=', AssetVisibility.HIDDEN) .where('deletedAt', 'is', null) .where(field, 'is not', null); } diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index e2e396f7b..4d7671ca9 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -6,7 +6,7 @@ import { InjectKysely } from 'nestjs-kysely'; import { columns } from 'src/database'; import { DB, UserMetadata as DbUserMetadata } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { AssetType, UserStatus } from 'src/enum'; +import { AssetType, AssetVisibility, UserStatus } from 'src/enum'; import { UserTable } from 'src/schema/tables/user.table'; import { UserMetadata, UserMetadataItem } from 'src/types'; import { asUuid } from 'src/utils/database'; @@ -205,13 +205,19 @@ export class UserRepository { eb.fn .countAll() .filterWhere((eb) => - eb.and([eb('assets.type', '=', sql.lit(AssetType.IMAGE)), eb('assets.isVisible', '=', sql.lit(true))]), + eb.and([ + eb('assets.type', '=', sql.lit(AssetType.IMAGE)), + eb('assets.visibility', '!=', sql.lit(AssetVisibility.HIDDEN)), + ]), ) .as('photos'), eb.fn .countAll() .filterWhere((eb) => - eb.and([eb('assets.type', '=', sql.lit(AssetType.VIDEO)), eb('assets.isVisible', '=', sql.lit(true))]), + eb.and([ + eb('assets.type', '=', sql.lit(AssetType.VIDEO)), + eb('assets.visibility', '!=', sql.lit(AssetVisibility.HIDDEN)), + ]), ) .as('videos'), eb.fn diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts index e32933065..03e8b3763 100644 --- a/server/src/repositories/view-repository.ts +++ b/server/src/repositories/view-repository.ts @@ -2,6 +2,7 @@ import { Kysely } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DB } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; +import { AssetVisibility } from 'src/enum'; import { asUuid, withExif } from 'src/utils/database'; export class ViewRepository { @@ -14,8 +15,7 @@ export class ViewRepository { .select((eb) => eb.fn('substring', ['assets.originalPath', eb.val('^(.*/)[^/]*$')]).as('directoryPath')) .distinct() .where('ownerId', '=', asUuid(userId)) - .where('isVisible', '=', true) - .where('isArchived', '=', false) + .where('visibility', '=', AssetVisibility.TIMELINE) .where('deletedAt', 'is', null) .where('fileCreatedAt', 'is not', null) .where('fileModifiedAt', 'is not', null) @@ -34,8 +34,7 @@ export class ViewRepository { .selectAll('assets') .$call(withExif) .where('ownerId', '=', asUuid(userId)) - .where('isVisible', '=', true) - .where('isArchived', '=', false) + .where('visibility', '=', AssetVisibility.TIMELINE) .where('deletedAt', 'is', null) .where('fileCreatedAt', 'is not', null) .where('fileModifiedAt', 'is not', null) diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index c62681d04..1800f08c1 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -1,3 +1,4 @@ +import { AssetVisibility } from 'src/enum'; import { asset_face_source_type, assets_status_enum } from 'src/schema/enums'; import { assets_delete_audit, @@ -45,7 +46,12 @@ import { UserAuditTable } from 'src/schema/tables/user-audit.table'; import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; import { UserTable } from 'src/schema/tables/user.table'; import { VersionHistoryTable } from 'src/schema/tables/version-history.table'; -import { ConfigurationParameter, Database, Extensions } from 'src/sql-tools'; +import { ConfigurationParameter, Database, Extensions, registerEnum } from 'src/sql-tools'; + +export const asset_visibility_enum = registerEnum({ + name: 'asset_visibility_enum', + values: Object.values(AssetVisibility), +}); @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql']) @ConfigurationParameter({ name: 'search_path', value: () => '"$user", public, vectors', scope: 'database' }) diff --git a/server/src/schema/migrations/1745902563899-AddAssetVisibilityColumn.ts b/server/src/schema/migrations/1745902563899-AddAssetVisibilityColumn.ts new file mode 100644 index 000000000..6fe9dab1a --- /dev/null +++ b/server/src/schema/migrations/1745902563899-AddAssetVisibilityColumn.ts @@ -0,0 +1,37 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE TYPE "asset_visibility_enum" AS ENUM ('archive','timeline','hidden');`.execute(db); + await sql`ALTER TABLE "assets" + ADD "visibility" asset_visibility_enum NOT NULL DEFAULT 'timeline';`.execute(db); + + await sql` + UPDATE "assets" + SET "visibility" = CASE + WHEN "isArchived" THEN 'archive'::asset_visibility_enum + WHEN "isVisible" THEN 'timeline'::asset_visibility_enum + ELSE 'hidden'::asset_visibility_enum + END; + `.execute(db); + + await sql`ALTER TABLE "assets" DROP COLUMN "isVisible";`.execute(db); + await sql`ALTER TABLE "assets" DROP COLUMN "isArchived";`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "assets" ADD COLUMN "isArchived" BOOLEAN NOT NULL DEFAULT FALSE;`.execute(db); + await sql`ALTER TABLE "assets" ADD COLUMN "isVisible" BOOLEAN NOT NULL DEFAULT TRUE;`.execute(db); + + await sql` + UPDATE "assets" + SET + "isArchived" = ("visibility" = 'archive'::asset_visibility_enum), + "isVisible" = CASE + WHEN "visibility" = 'timeline'::asset_visibility_enum THEN TRUE + WHEN "visibility" = 'archive'::asset_visibility_enum THEN TRUE + ELSE FALSE + END; + `.execute(db); + await sql`ALTER TABLE "assets" DROP COLUMN "visibility";`.execute(db); + await sql`DROP TYPE "asset_visibility_enum";`.execute(db); +} diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index 19ec8d2ef..4552ac158 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -1,5 +1,6 @@ import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { AssetStatus, AssetType } from 'src/enum'; +import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { asset_visibility_enum } from 'src/schema'; import { assets_status_enum } from 'src/schema/enums'; import { assets_delete_audit } from 'src/schema/functions'; import { LibraryTable } from 'src/schema/tables/library.table'; @@ -95,9 +96,6 @@ export class AssetTable { @Column({ type: 'bytea', index: true }) checksum!: Buffer; // sha1 checksum - @Column({ type: 'boolean', default: true }) - isVisible!: boolean; - @ForeignKeyColumn(() => AssetTable, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' }) livePhotoVideoId!: string | null; @@ -107,9 +105,6 @@ export class AssetTable { @CreateDateColumn() createdAt!: Date; - @Column({ type: 'boolean', default: false }) - isArchived!: boolean; - @Column({ index: true }) originalFileName!: string; @@ -145,4 +140,7 @@ export class AssetTable { @UpdateIdColumn({ indexName: 'IDX_assets_update_id' }) updateId?: string; + + @Column({ enum: asset_visibility_enum, default: AssetVisibility.TIMELINE }) + visibility!: AssetVisibility; } diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index d25067f1c..8490e8aae 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -9,7 +9,7 @@ import { AssetFile } from 'src/database'; import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto'; import { MapAsset } from 'src/dtos/asset-response.dto'; -import { AssetFileType, AssetStatus, AssetType, CacheControl, JobName } from 'src/enum'; +import { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AssetMediaService } from 'src/services/asset-media.service'; import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database'; @@ -142,7 +142,6 @@ const createDto = Object.freeze({ fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), isFavorite: false, - isArchived: false, duration: '0:00:00.000000', }) as AssetMediaCreateDto; @@ -164,7 +163,6 @@ const assetEntity = Object.freeze({ fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), updatedAt: new Date('2022-06-19T23:41:36.910Z'), isFavorite: false, - isArchived: false, encodedVideoPath: '', duration: '0:00:00.000000', files: [] as AssetFile[], @@ -437,7 +435,10 @@ describe(AssetMediaService.name, () => { }); it('should hide the linked motion asset', async () => { - mocks.asset.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, isVisible: true }); + mocks.asset.getById.mockResolvedValueOnce({ + ...assetStub.livePhotoMotionAsset, + visibility: AssetVisibility.TIMELINE, + }); mocks.asset.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); await expect( @@ -452,7 +453,10 @@ describe(AssetMediaService.name, () => { }); expect(mocks.asset.getById).toHaveBeenCalledWith('live-photo-motion-asset'); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false }); + expect(mocks.asset.update).toHaveBeenCalledWith({ + id: 'live-photo-motion-asset', + visibility: AssetVisibility.HIDDEN, + }); }); it('should handle a sidecar file', async () => { diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 78e23fa80..87d617ede 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -21,7 +21,7 @@ import { UploadFieldName, } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetStatus, AssetType, CacheControl, JobName, Permission, StorageFolder } from 'src/enum'; +import { AssetStatus, AssetType, AssetVisibility, CacheControl, JobName, Permission, StorageFolder } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { BaseService } from 'src/services/base.service'; import { UploadFile } from 'src/types'; @@ -146,7 +146,6 @@ export class AssetMediaService extends BaseService { { userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId }, ); } - const asset = await this.create(auth.user.id, dto, file, sidecarFile); await this.userRepository.updateUsage(auth.user.id, file.size); @@ -416,9 +415,8 @@ export class AssetMediaService extends BaseService { type: mimeTypes.assetType(file.originalPath), isFavorite: dto.isFavorite, - isArchived: dto.isArchived ?? false, duration: dto.duration || null, - isVisible: dto.isVisible ?? true, + visibility: dto.visibility ?? AssetVisibility.TIMELINE, livePhotoVideoId: dto.livePhotoVideoId, originalFileName: file.originalName, sidecarPath: sidecarFile?.originalPath, diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index b677a6588..1e4cfddcf 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -2,7 +2,7 @@ import { BadRequestException } from '@nestjs/common'; import { DateTime } from 'luxon'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; -import { AssetStatus, AssetType, JobName, JobStatus } from 'src/enum'; +import { AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; import { AssetService } from 'src/services/asset.service'; import { assetStub } from 'test/fixtures/asset.stub'; @@ -46,14 +46,22 @@ describe(AssetService.name, () => { describe('getStatistics', () => { it('should get the statistics for a user, excluding archived assets', async () => { mocks.asset.getStatistics.mockResolvedValue(stats); - await expect(sut.getStatistics(authStub.admin, { isArchived: false })).resolves.toEqual(statResponse); - expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: false }); + await expect(sut.getStatistics(authStub.admin, { visibility: AssetVisibility.TIMELINE })).resolves.toEqual( + statResponse, + ); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { + visibility: AssetVisibility.TIMELINE, + }); }); it('should get the statistics for a user for archived assets', async () => { mocks.asset.getStatistics.mockResolvedValue(stats); - await expect(sut.getStatistics(authStub.admin, { isArchived: true })).resolves.toEqual(statResponse); - expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: true }); + await expect(sut.getStatistics(authStub.admin, { visibility: AssetVisibility.ARCHIVE })).resolves.toEqual( + statResponse, + ); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { + visibility: AssetVisibility.ARCHIVE, + }); }); it('should get the statistics for a user for favorite assets', async () => { @@ -192,9 +200,9 @@ describe(AssetService.name, () => { describe('update', () => { it('should require asset write access for the id', async () => { - await expect(sut.update(authStub.admin, 'asset-1', { isArchived: false })).rejects.toBeInstanceOf( - BadRequestException, - ); + await expect( + sut.update(authStub.admin, 'asset-1', { visibility: AssetVisibility.TIMELINE }), + ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.asset.update).not.toHaveBeenCalled(); }); @@ -242,7 +250,10 @@ describe(AssetService.name, () => { id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(mocks.asset.update).not.toHaveBeenCalledWith({ + id: assetStub.livePhotoMotionAsset.id, + visibility: AssetVisibility.TIMELINE, + }); expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, @@ -263,7 +274,10 @@ describe(AssetService.name, () => { id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(mocks.asset.update).not.toHaveBeenCalledWith({ + id: assetStub.livePhotoMotionAsset.id, + visibility: AssetVisibility.TIMELINE, + }); expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, @@ -284,7 +298,10 @@ describe(AssetService.name, () => { id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(mocks.asset.update).not.toHaveBeenCalledWith({ + id: assetStub.livePhotoMotionAsset.id, + visibility: AssetVisibility.TIMELINE, + }); expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, @@ -296,7 +313,7 @@ describe(AssetService.name, () => { mocks.asset.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, ownerId: authStub.admin.user.id, - isVisible: true, + visibility: AssetVisibility.TIMELINE, }); mocks.asset.getById.mockResolvedValueOnce(assetStub.image); mocks.asset.update.mockResolvedValue(assetStub.image); @@ -305,7 +322,10 @@ describe(AssetService.name, () => { livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); + expect(mocks.asset.update).toHaveBeenCalledWith({ + id: assetStub.livePhotoMotionAsset.id, + visibility: AssetVisibility.HIDDEN, + }); expect(mocks.event.emit).toHaveBeenCalledWith('asset.hide', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, @@ -335,7 +355,10 @@ describe(AssetService.name, () => { id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: null, }); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(mocks.asset.update).toHaveBeenCalledWith({ + id: assetStub.livePhotoMotionAsset.id, + visibility: assetStub.livePhotoStillAsset.visibility, + }); expect(mocks.event.emit).toHaveBeenCalledWith('asset.show', { assetId: assetStub.livePhotoMotionAsset.id, userId: userStub.admin.id, @@ -361,7 +384,6 @@ describe(AssetService.name, () => { await expect( sut.updateAll(authStub.admin, { ids: ['asset-1'], - isArchived: false, }), ).rejects.toBeInstanceOf(BadRequestException); }); @@ -369,9 +391,11 @@ describe(AssetService.name, () => { it('should update all assets', async () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); + await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], visibility: AssetVisibility.ARCHIVE }); - expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); + expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { + visibility: AssetVisibility.ARCHIVE, + }); }); it('should not update Assets table if no relevant fields are provided', async () => { @@ -381,7 +405,6 @@ describe(AssetService.name, () => { ids: ['asset-1'], latitude: 0, longitude: 0, - isArchived: undefined, isFavorite: undefined, duplicateId: undefined, rating: undefined, @@ -389,14 +412,14 @@ describe(AssetService.name, () => { expect(mocks.asset.updateAll).not.toHaveBeenCalled(); }); - it('should update Assets table if isArchived field is provided', async () => { + it('should update Assets table if visibility field is provided', async () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.updateAll(authStub.admin, { ids: ['asset-1'], latitude: 0, longitude: 0, - isArchived: undefined, + visibility: undefined, isFavorite: false, duplicateId: undefined, rating: undefined, @@ -416,7 +439,6 @@ describe(AssetService.name, () => { latitude: 30, longitude: 50, dateTimeOriginal, - isArchived: undefined, isFavorite: false, duplicateId: undefined, rating: undefined, @@ -439,7 +461,6 @@ describe(AssetService.name, () => { ids: ['asset-1'], latitude: 0, longitude: 0, - isArchived: undefined, isFavorite: undefined, duplicateId: null, rating: undefined, diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 604713054..3ab6fcb8a 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -92,8 +92,12 @@ export class AssetService extends BaseService { const asset = await this.assetRepository.update({ id, ...rest }); - if (previousMotion) { - await onAfterUnlink(repos, { userId: auth.user.id, livePhotoVideoId: previousMotion.id }); + if (previousMotion && asset) { + await onAfterUnlink(repos, { + userId: auth.user.id, + livePhotoVideoId: previousMotion.id, + visibility: asset.visibility, + }); } if (!asset) { @@ -115,7 +119,7 @@ export class AssetService extends BaseService { } if ( - options.isArchived !== undefined || + options.visibility !== undefined || options.isFavorite !== undefined || options.duplicateId !== undefined || options.rating !== undefined diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index ed8f2cf17..3f08e36a2 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,4 +1,4 @@ -import { AssetFileType, AssetType, JobName, JobStatus } from 'src/enum'; +import { AssetFileType, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; import { assetStub } from 'test/fixtures/asset.stub'; @@ -22,11 +22,11 @@ const hasEmbedding = { updateId: 'update-1', }, ], - isVisible: true, stackId: null, type: AssetType.IMAGE, duplicateId: null, embedding: '[1, 2, 3, 4]', + visibility: AssetVisibility.TIMELINE, }; const hasDupe = { @@ -207,7 +207,10 @@ describe(SearchService.name, () => { it('should skip if asset is not visible', async () => { const id = assetStub.livePhotoMotionAsset.id; - mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, isVisible: false }); + mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ + ...hasEmbedding, + visibility: AssetVisibility.HIDDEN, + }); const result = await sut.handleSearchDuplicates({ id }); diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 41e3f13c4..b5e4f573f 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -4,7 +4,7 @@ import { OnJob } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DuplicateResponseDto } from 'src/dtos/duplicate.dto'; -import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum'; +import { AssetFileType, AssetVisibility, JobName, JobStatus, QueueName } from 'src/enum'; import { AssetDuplicateResult } from 'src/repositories/search.repository'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; @@ -65,7 +65,7 @@ export class DuplicateService extends BaseService { return JobStatus.SKIPPED; } - if (!asset.isVisible) { + if (asset.visibility == AssetVisibility.HIDDEN) { this.logger.debug(`Asset ${id} is not visible, skipping`); return JobStatus.SKIPPED; } diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index cf9b87f4e..fd573d9b9 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -6,6 +6,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; import { AssetType, + AssetVisibility, BootstrapEventPriority, ImmichWorker, JobCommand, @@ -301,7 +302,7 @@ export class JobService extends BaseService { } await this.jobRepository.queueAll(jobs); - if (asset.isVisible) { + if (asset.visibility === AssetVisibility.TIMELINE || asset.visibility === AssetVisibility.ARCHIVE) { this.eventRepository.clientSend('on_upload_success', asset.ownerId, mapAsset(asset)); } diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 546dcc930..adc8c4b90 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -8,6 +8,7 @@ import { AssetFileType, AssetPathType, AssetType, + AssetVisibility, AudioCodec, Colorspace, JobName, @@ -152,7 +153,7 @@ export class MediaService extends BaseService { return JobStatus.FAILED; } - if (!asset.isVisible) { + if (asset.visibility === AssetVisibility.HIDDEN) { this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`); return JobStatus.SKIPPED; } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index b048923b3..28cb42a16 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -4,7 +4,7 @@ import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; import { defaults } from 'src/config'; import { MapAsset } from 'src/dtos/asset-response.dto'; -import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum'; +import { AssetType, AssetVisibility, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { MetadataService } from 'src/services/metadata.service'; import { assetStub } from 'test/fixtures/asset.stub'; @@ -504,7 +504,10 @@ describe(MetadataService.name, () => { }); it('should not apply motion photos if asset is video', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ + ...assetStub.livePhotoMotionAsset, + visibility: AssetVisibility.TIMELINE, + }); mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); @@ -513,7 +516,7 @@ describe(MetadataService.name, () => { expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith( - expect.objectContaining({ assetType: AssetType.VIDEO, isVisible: false }), + expect.objectContaining({ assetType: AssetType.VIDEO, visibility: AssetVisibility.HIDDEN }), ); }); @@ -580,7 +583,7 @@ describe(MetadataService.name, () => { fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, id: fileStub.livePhotoMotion.uuid, - isVisible: false, + visibility: AssetVisibility.HIDDEN, libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, originalFileName: 'asset_1.mp4', @@ -638,7 +641,7 @@ describe(MetadataService.name, () => { fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, id: fileStub.livePhotoMotion.uuid, - isVisible: false, + visibility: AssetVisibility.HIDDEN, libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, originalFileName: 'asset_1.mp4', @@ -696,7 +699,7 @@ describe(MetadataService.name, () => { fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, id: fileStub.livePhotoMotion.uuid, - isVisible: false, + visibility: AssetVisibility.HIDDEN, libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, originalFileName: 'asset_1.mp4', @@ -773,14 +776,17 @@ describe(MetadataService.name, () => { MicroVideoOffset: 1, }); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); - mocks.asset.getByChecksum.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true }); + mocks.asset.getByChecksum.mockResolvedValue({ + ...assetStub.livePhotoMotionAsset, + visibility: AssetVisibility.TIMELINE, + }); const video = randomBytes(512); mocks.storage.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, - isVisible: false, + visibility: AssetVisibility.HIDDEN, }); expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, @@ -1301,7 +1307,9 @@ describe(MetadataService.name, () => { expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled(); - expect(mocks.asset.update).not.toHaveBeenCalledWith(expect.objectContaining({ isVisible: false })); + expect(mocks.asset.update).not.toHaveBeenCalledWith( + expect.objectContaining({ visibility: AssetVisibility.HIDDEN }), + ); expect(mocks.album.removeAsset).not.toHaveBeenCalled(); }); @@ -1320,7 +1328,9 @@ describe(MetadataService.name, () => { libraryId: null, type: AssetType.IMAGE, }); - expect(mocks.asset.update).not.toHaveBeenCalledWith(expect.objectContaining({ isVisible: false })); + expect(mocks.asset.update).not.toHaveBeenCalledWith( + expect.objectContaining({ visibility: AssetVisibility.HIDDEN }), + ); expect(mocks.album.removeAsset).not.toHaveBeenCalled(); }); @@ -1342,7 +1352,10 @@ describe(MetadataService.name, () => { id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); + expect(mocks.asset.update).toHaveBeenCalledWith({ + id: assetStub.livePhotoMotionAsset.id, + visibility: AssetVisibility.HIDDEN, + }); expect(mocks.album.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 17f3325f9..3497b808d 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -14,6 +14,7 @@ import { AssetFaces, Exif, Person } from 'src/db'; import { OnEvent, OnJob } from 'src/decorators'; import { AssetType, + AssetVisibility, DatabaseLock, ExifOrientation, ImmichWorker, @@ -156,7 +157,7 @@ export class MetadataService extends BaseService { const [photoAsset, motionAsset] = asset.type === AssetType.IMAGE ? [asset, match] : [match, asset]; await Promise.all([ this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }), - this.assetRepository.update({ id: motionAsset.id, isVisible: false }), + this.assetRepository.update({ id: motionAsset.id, visibility: AssetVisibility.HIDDEN }), this.albumRepository.removeAsset(motionAsset.id), ]); @@ -527,8 +528,11 @@ export class MetadataService extends BaseService { }); // Hide the motion photo video asset if it's not already hidden to prepare for linking - if (motionAsset.isVisible) { - await this.assetRepository.update({ id: motionAsset.id, isVisible: false }); + if (motionAsset.visibility === AssetVisibility.TIMELINE) { + await this.assetRepository.update({ + id: motionAsset.id, + visibility: AssetVisibility.HIDDEN, + }); this.logger.log(`Hid unlinked motion photo video asset (${motionAsset.id})`); } } else { @@ -544,7 +548,7 @@ export class MetadataService extends BaseService { ownerId: asset.ownerId, originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId), originalFileName: `${path.parse(asset.originalFileName).name}.mp4`, - isVisible: false, + visibility: AssetVisibility.HIDDEN, deviceAssetId: 'NONE', deviceId: 'NONE', }); @@ -863,7 +867,7 @@ export class MetadataService extends BaseService { return JobStatus.FAILED; } - if (!isSync && (!asset.isVisible || asset.sidecarPath) && !asset.isExternal) { + if (!isSync && (asset.visibility === AssetVisibility.HIDDEN || asset.sidecarPath) && !asset.isExternal) { return JobStatus.FAILED; } diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 227ea3c1c..77a9c7030 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -26,6 +26,7 @@ import { } from 'src/dtos/person.dto'; import { AssetType, + AssetVisibility, CacheControl, ImageFormat, JobName, @@ -296,7 +297,7 @@ export class PersonService extends BaseService { return JobStatus.FAILED; } - if (!asset.isVisible) { + if (asset.visibility === AssetVisibility.HIDDEN) { return JobStatus.SKIPPED; } @@ -484,7 +485,9 @@ export class PersonService extends BaseService { this.logger.debug(`Face ${id} has ${matches.length} matches`); - const isCore = matches.length >= machineLearning.facialRecognition.minFaces && !face.asset.isArchived; + const isCore = + matches.length >= machineLearning.facialRecognition.minFaces && + face.asset.visibility === AssetVisibility.TIMELINE; if (!isCore && !deferred) { this.logger.debug(`Deferring non-core face ${id} for later processing`); await this.jobRepository.queue({ name: JobName.FACIAL_RECOGNITION, data: { id, deferred: true } }); diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 5ee5dac57..f3702c201 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { OnEvent, OnJob } from 'src/decorators'; -import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; +import { AssetVisibility, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; @@ -104,7 +104,7 @@ export class SmartInfoService extends BaseService { return JobStatus.FAILED; } - if (!asset.isVisible) { + if (asset.visibility === AssetVisibility.HIDDEN) { return JobStatus.SKIPPED; } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index c88348b39..6ad488c48 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -14,7 +14,7 @@ import { SyncAckSetDto, SyncStreamDto, } from 'src/dtos/sync.dto'; -import { DatabaseAction, EntityType, Permission, SyncEntityType, SyncRequestType } from 'src/enum'; +import { AssetVisibility, DatabaseAction, EntityType, Permission, SyncEntityType, SyncRequestType } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { SyncAck } from 'src/types'; import { getMyPartnerIds } from 'src/utils/asset.util'; @@ -262,7 +262,10 @@ export class SyncService extends BaseService { needsFullSync: false, upserted: upserted // do not return archived assets for partner users - .filter((a) => a.ownerId === auth.user.id || (a.ownerId !== auth.user.id && !a.isArchived)) + .filter( + (a) => + a.ownerId === auth.user.id || (a.ownerId !== auth.user.id && a.visibility === AssetVisibility.TIMELINE), + ) .map((a) => mapAsset(a, { auth, diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index c6a09d2fd..1447594d4 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -1,4 +1,5 @@ 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'; @@ -54,7 +55,7 @@ describe(TimelineService.name, () => { sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', - isArchived: true, + visibility: AssetVisibility.ARCHIVE, userId: authStub.admin.user.id, }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); @@ -63,7 +64,7 @@ describe(TimelineService.name, () => { expect.objectContaining({ size: TimeBucketSize.DAY, timeBucket: 'bucket', - isArchived: true, + visibility: AssetVisibility.ARCHIVE, userIds: [authStub.admin.user.id], }), ); @@ -77,7 +78,7 @@ describe(TimelineService.name, () => { sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', - isArchived: false, + visibility: AssetVisibility.TIMELINE, userId: authStub.admin.user.id, withPartners: true, }), @@ -85,7 +86,7 @@ describe(TimelineService.name, () => { expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { size: TimeBucketSize.DAY, timeBucket: 'bucket', - isArchived: false, + visibility: AssetVisibility.TIMELINE, withPartners: true, userIds: [authStub.admin.user.id], }); @@ -120,7 +121,7 @@ describe(TimelineService.name, () => { const buckets = await sut.getTimeBucket(auth, { size: TimeBucketSize.DAY, timeBucket: 'bucket', - isArchived: true, + visibility: AssetVisibility.ARCHIVE, albumId: 'album-id', }); @@ -129,7 +130,7 @@ describe(TimelineService.name, () => { expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { size: TimeBucketSize.DAY, timeBucket: 'bucket', - isArchived: true, + visibility: AssetVisibility.ARCHIVE, albumId: 'album-id', }); }); @@ -154,12 +155,12 @@ describe(TimelineService.name, () => { ); }); - it('should throw an error if withParners is true and isArchived true or undefined', async () => { + 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', - isArchived: true, + visibility: AssetVisibility.ARCHIVE, withPartners: true, userId: authStub.admin.user.id, }), @@ -169,7 +170,7 @@ describe(TimelineService.name, () => { sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', - isArchived: undefined, + visibility: undefined, withPartners: true, userId: authStub.admin.user.id, }), diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index 4c2332afa..c0cd4786a 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -2,7 +2,7 @@ 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 { Permission } from 'src/enum'; +import { AssetVisibility, Permission } from 'src/enum'; import { TimeBucketOptions } from 'src/repositories/asset.repository'; import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; @@ -55,7 +55,7 @@ export class TimelineService extends BaseService { if (dto.userId) { await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: [dto.userId] }); - if (dto.isArchived !== false) { + if (dto.visibility === AssetVisibility.ARCHIVE) { await this.requireAccess({ auth, permission: Permission.ARCHIVE_READ, ids: [dto.userId] }); } } @@ -65,7 +65,7 @@ export class TimelineService extends BaseService { } if (dto.withPartners) { - const requestedArchived = dto.isArchived === true || dto.isArchived === undefined; + const requestedArchived = dto.visibility === AssetVisibility.ARCHIVE || dto.visibility === undefined; const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false; const requestedTrash = dto.isTrashed === true; diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 8905f8416..0f5432da4 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -4,7 +4,7 @@ import { AssetFile } from 'src/database'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetFileType, AssetType, Permission } from 'src/enum'; +import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AccessRepository } from 'src/repositories/access.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; @@ -150,8 +150,8 @@ export const onBeforeLink = async ( throw new BadRequestException('Live photo video does not belong to the user'); } - if (motionAsset?.isVisible) { - await assetRepository.update({ id: livePhotoVideoId, isVisible: false }); + if (motionAsset && motionAsset.visibility === AssetVisibility.TIMELINE) { + await assetRepository.update({ id: livePhotoVideoId, visibility: AssetVisibility.HIDDEN }); await eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId }); } }; @@ -174,9 +174,9 @@ export const onBeforeUnlink = async ( export const onAfterUnlink = async ( { asset: assetRepository, event: eventRepository }: AssetHookRepositories, - { userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string }, + { userId, livePhotoVideoId, visibility }: { userId: string; livePhotoVideoId: string; visibility: AssetVisibility }, ) => { - await assetRepository.update({ id: livePhotoVideoId, isVisible: true }); + await assetRepository.update({ id: livePhotoVideoId, visibility }); await eventRepository.emit('asset.show', { assetId: livePhotoVideoId, userId }); }; diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 985605eb0..bacdf06d6 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -17,7 +17,7 @@ import { parse } from 'pg-connection-string'; import postgres, { Notice } from 'postgres'; import { columns, Exif, Person } from 'src/database'; import { DB } from 'src/db'; -import { AssetFileType, DatabaseExtension, DatabaseSslMode } from 'src/enum'; +import { AssetFileType, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum'; import { TimeBucketSize } from 'src/repositories/asset.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { DatabaseConnectionParams, VectorExtension } from 'src/types'; @@ -155,6 +155,15 @@ export function toJson(qb: SelectQueryBuilder) { + return qb.where((qb) => + qb.or([ + qb('assets.visibility', '=', AssetVisibility.TIMELINE), + qb('assets.visibility', '=', AssetVisibility.ARCHIVE), + ]), + ); +} + export function withExif(qb: SelectQueryBuilder) { return qb .leftJoin('exif', 'assets.id', 'exif.assetId') @@ -280,12 +289,14 @@ const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); /** TODO: This should only be used for search-related queries, not as a general purpose query builder */ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderOptions) { - options.isArchived ??= options.withArchived ? undefined : false; options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline); + const visibility = options.visibility == null ? AssetVisibility.TIMELINE : options.visibility; + 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!)) .$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!)) @@ -356,8 +367,6 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!!options.type, (qb) => qb.where('assets.type', '=', options.type!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) .$if(options.isOffline !== undefined, (qb) => qb.where('assets.isOffline', '=', options.isOffline!)) - .$if(options.isVisible !== undefined, (qb) => qb.where('assets.isVisible', '=', options.isVisible!)) - .$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!)) .$if(options.isEncoded !== undefined, (qb) => qb.where('assets.encodedVideoPath', options.isEncoded ? 'is not' : 'is', null), ) diff --git a/server/src/validation.ts b/server/src/validation.ts index 29e402826..26367aeff 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -12,6 +12,7 @@ import { IsArray, IsBoolean, IsDate, + IsEnum, IsHexColor, IsNotEmpty, IsOptional, @@ -29,6 +30,7 @@ import { import { CronJob } from 'cron'; import { DateTime } from 'luxon'; import sanitize from 'sanitize-filename'; +import { AssetVisibility } from 'src/enum'; import { isIP, isIPRange } from 'validator'; @Injectable() @@ -146,6 +148,17 @@ export const ValidateDate = (options?: DateOptions) => { return applyDecorators(...decorators); }; +type AssetVisibilityOptions = { optional?: boolean }; +export const ValidateAssetVisibility = (options?: AssetVisibilityOptions) => { + const { optional } = { optional: false, ...options }; + const decorators = [IsEnum(AssetVisibility), ApiProperty({ enumName: 'AssetVisibility', enum: AssetVisibility })]; + + if (optional) { + decorators.push(Optional()); + } + return applyDecorators(...decorators); +}; + type BooleanOptions = { optional?: boolean }; export const ValidateBoolean = (options?: BooleanOptions) => { const { optional } = { optional: false, ...options }; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index d1b8e7cf2..a64194361 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -1,6 +1,6 @@ import { AssetFace, AssetFile, Exif } from 'src/database'; import { MapAsset } from 'src/dtos/asset-response.dto'; -import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; +import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { StorageAsset } from 'src/types'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; @@ -74,9 +74,7 @@ export const assetStub = { updatedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, - isArchived: false, duration: null, - isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, sharedLinks: [], @@ -90,6 +88,7 @@ export const assetStub = { libraryId: null, stackId: null, updateId: '42', + visibility: AssetVisibility.TIMELINE, }), noWebpPath: Object.freeze({ @@ -111,9 +110,7 @@ export const assetStub = { updatedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, - isArchived: false, duration: null, - isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, sharedLinks: [], @@ -130,6 +127,7 @@ export const assetStub = { libraryId: null, stackId: null, updateId: '42', + visibility: AssetVisibility.TIMELINE, }), noThumbhash: Object.freeze({ @@ -151,9 +149,7 @@ export const assetStub = { updatedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, - isArchived: false, duration: null, - isVisible: true, isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, @@ -167,6 +163,7 @@ export const assetStub = { libraryId: null, stackId: null, updateId: '42', + visibility: AssetVisibility.TIMELINE, }), primaryImage: Object.freeze({ @@ -188,9 +185,7 @@ export const assetStub = { updatedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, - isArchived: false, duration: null, - isVisible: true, isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, @@ -214,6 +209,7 @@ export const assetStub = { isOffline: false, updateId: '42', libraryId: null, + visibility: AssetVisibility.TIMELINE, }), image: Object.freeze({ @@ -235,9 +231,7 @@ export const assetStub = { updatedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: new Date('2025-01-01T01:02:03.456Z'), isFavorite: true, - isArchived: false, duration: null, - isVisible: true, isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, @@ -257,6 +251,7 @@ export const assetStub = { duplicateId: null, isOffline: false, stack: null, + visibility: AssetVisibility.TIMELINE, }), trashed: Object.freeze({ @@ -278,9 +273,7 @@ export const assetStub = { deletedAt: new Date('2023-02-24T05:06:29.716Z'), localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: false, - isArchived: false, duration: null, - isVisible: true, isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, @@ -299,6 +292,7 @@ export const assetStub = { libraryId: null, stackId: null, updateId: '42', + visibility: AssetVisibility.TIMELINE, }), trashedOffline: Object.freeze({ @@ -321,10 +315,8 @@ export const assetStub = { deletedAt: new Date('2023-02-24T05:06:29.716Z'), localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: false, - isArchived: false, duration: null, libraryId: 'library-id', - isVisible: true, isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, @@ -341,6 +333,7 @@ export const assetStub = { isOffline: true, stackId: null, updateId: '42', + visibility: AssetVisibility.TIMELINE, }), archived: Object.freeze({ id: 'asset-id', @@ -361,9 +354,7 @@ export const assetStub = { updatedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, - isArchived: true, duration: null, - isVisible: true, isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, @@ -382,6 +373,7 @@ export const assetStub = { libraryId: null, stackId: null, updateId: '42', + visibility: AssetVisibility.TIMELINE, }), external: Object.freeze({ @@ -403,10 +395,8 @@ export const assetStub = { updatedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, - isArchived: false, isExternal: true, duration: null, - isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, libraryId: 'library-id', @@ -423,6 +413,7 @@ export const assetStub = { updateId: '42', stackId: null, stack: null, + visibility: AssetVisibility.TIMELINE, }), image1: Object.freeze({ @@ -445,9 +436,7 @@ export const assetStub = { deletedAt: null, localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, - isArchived: false, duration: null, - isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, isExternal: false, @@ -464,6 +453,7 @@ export const assetStub = { stackId: null, libraryId: null, stack: null, + visibility: AssetVisibility.TIMELINE, }), imageFrom2015: Object.freeze({ @@ -485,10 +475,8 @@ export const assetStub = { updatedAt: new Date('2015-02-23T05:06:29.716Z'), localDateTime: new Date('2015-02-23T05:06:29.716Z'), isFavorite: true, - isArchived: false, isExternal: false, duration: null, - isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, sharedLinks: [], @@ -501,6 +489,7 @@ export const assetStub = { deletedAt: null, duplicateId: null, isOffline: false, + visibility: AssetVisibility.TIMELINE, }), video: Object.freeze({ @@ -523,10 +512,8 @@ export const assetStub = { updatedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, - isArchived: false, isExternal: false, duration: null, - isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, sharedLinks: [], @@ -543,6 +530,7 @@ export const assetStub = { updateId: '42', libraryId: null, stackId: null, + visibility: AssetVisibility.TIMELINE, }), livePhotoMotionAsset: Object.freeze({ @@ -551,7 +539,6 @@ export const assetStub = { originalPath: fileStub.livePhotoMotion.originalPath, ownerId: authStub.user1.user.id, type: AssetType.VIDEO, - isVisible: false, fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), exifInfo: { @@ -559,6 +546,7 @@ export const assetStub = { timeZone: `America/New_York`, }, libraryId: null, + visibility: AssetVisibility.HIDDEN, } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }), livePhotoStillAsset: Object.freeze({ @@ -568,7 +556,6 @@ export const assetStub = { ownerId: authStub.user1.user.id, type: AssetType.IMAGE, livePhotoVideoId: 'live-photo-motion-asset', - isVisible: true, fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), exifInfo: { @@ -577,6 +564,7 @@ export const assetStub = { }, files, faces: [] as AssetFace[], + visibility: AssetVisibility.TIMELINE, } as MapAsset & { faces: AssetFace[] }), livePhotoWithOriginalFileName: Object.freeze({ @@ -587,7 +575,6 @@ export const assetStub = { ownerId: authStub.user1.user.id, type: AssetType.IMAGE, livePhotoVideoId: 'live-photo-motion-asset', - isVisible: true, fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), exifInfo: { @@ -596,6 +583,7 @@ export const assetStub = { }, libraryId: null, faces: [] as AssetFace[], + visibility: AssetVisibility.TIMELINE, } as MapAsset & { faces: AssetFace[] }), withLocation: Object.freeze({ @@ -618,10 +606,8 @@ export const assetStub = { updatedAt: new Date('2023-02-22T05:06:29.716Z'), localDateTime: new Date('2020-12-31T23:59:00.000Z'), isFavorite: false, - isArchived: false, isExternal: false, duration: null, - isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, updateId: 'foo', @@ -642,6 +628,7 @@ export const assetStub = { duplicateId: null, isOffline: false, tags: [], + visibility: AssetVisibility.TIMELINE, }), sidecar: Object.freeze({ @@ -663,10 +650,8 @@ export const assetStub = { updatedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, - isArchived: false, isExternal: false, duration: null, - isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, sharedLinks: [], @@ -679,6 +664,7 @@ export const assetStub = { updateId: 'foo', libraryId: null, stackId: null, + visibility: AssetVisibility.TIMELINE, }), sidecarWithoutExt: Object.freeze({ @@ -700,10 +686,8 @@ export const assetStub = { updatedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, - isArchived: false, isExternal: false, duration: null, - isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, sharedLinks: [], @@ -713,6 +697,7 @@ export const assetStub = { deletedAt: null, duplicateId: null, isOffline: false, + visibility: AssetVisibility.TIMELINE, }), hasEncodedVideo: Object.freeze({ @@ -735,10 +720,8 @@ export const assetStub = { updatedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, - isArchived: false, isExternal: false, duration: null, - isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, sharedLinks: [], @@ -754,6 +737,7 @@ export const assetStub = { libraryId: null, stackId: null, stack: null, + visibility: AssetVisibility.TIMELINE, }), hasFileExtension: Object.freeze({ @@ -775,10 +759,8 @@ export const assetStub = { updatedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, - isArchived: false, isExternal: true, duration: null, - isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, libraryId: 'library-id', @@ -792,6 +774,7 @@ export const assetStub = { } as Exif, duplicateId: null, isOffline: false, + visibility: AssetVisibility.TIMELINE, }), imageDng: Object.freeze({ @@ -813,9 +796,7 @@ export const assetStub = { updatedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, - isArchived: false, duration: null, - isVisible: true, isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, @@ -834,6 +815,7 @@ export const assetStub = { updateId: '42', libraryId: null, stackId: null, + visibility: AssetVisibility.TIMELINE, }), imageHif: Object.freeze({ @@ -855,9 +837,7 @@ export const assetStub = { updatedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, - isArchived: false, duration: null, - isVisible: true, isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, @@ -876,5 +856,6 @@ export const assetStub = { updateId: '42', libraryId: null, stackId: null, + visibility: AssetVisibility.TIMELINE, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index a4d83863c..fc4b74ba2 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -4,7 +4,7 @@ import { AssetResponseDto, MapAsset } from 'src/dtos/asset-response.dto'; import { ExifResponseDto } from 'src/dtos/exif.dto'; import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto'; import { mapUser } from 'src/dtos/user.dto'; -import { AssetOrder, AssetStatus, AssetType, SharedLinkType } from 'src/enum'; +import { AssetOrder, AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -206,7 +206,6 @@ export const sharedLinkStub = { thumbhash: null, encodedVideoPath: '', duration: null, - isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, originalFileName: 'asset_1.jpeg', @@ -251,6 +250,7 @@ export const sharedLinkStub = { updateId: '42', libraryId: null, stackId: null, + visibility: AssetVisibility.TIMELINE, }, ], }, diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 89b192181..6f4f46c07 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -5,7 +5,7 @@ import { createHash, randomBytes } from 'node:crypto'; import { Writable } from 'node:stream'; import { AssetFace } from 'src/database'; import { AssetJobStatus, Assets, DB, FaceSearch, Person, Sessions } from 'src/db'; -import { AssetType, SourceType } from 'src/enum'; +import { AssetType, AssetVisibility, SourceType } from 'src/enum'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; @@ -227,16 +227,37 @@ const getRepositoryMock = (key: K) => { case 'database': { return automock(DatabaseRepository, { - args: [undefined, { setContext: () => {} }, { getEnv: () => ({ database: { vectorExtension: '' } }) }], + args: [ + undefined, + { + setContext: () => {}, + }, + { getEnv: () => ({ database: { vectorExtension: '' } }) }, + ], }); } case 'email': { - return automock(EmailRepository, { args: [{ setContext: () => {} }] }); + return automock(EmailRepository, { + args: [ + { + setContext: () => {}, + }, + ], + }); } case 'job': { - return automock(JobRepository, { args: [undefined, undefined, undefined, { setContext: () => {} }] }); + return automock(JobRepository, { + args: [ + undefined, + undefined, + undefined, + { + setContext: () => {}, + }, + ], + }); } case 'logger': { @@ -345,11 +366,11 @@ const assetInsert = (asset: Partial> = {}) => { type: AssetType.IMAGE, originalPath: '/path/to/something.jpg', ownerId: '@immich.cloud', - isVisible: true, isFavorite: false, fileCreatedAt: now, fileModifiedAt: now, localDateTime: now, + visibility: AssetVisibility.TIMELINE, }; return { diff --git a/server/test/medium/specs/services/sync.service.spec.ts b/server/test/medium/specs/services/sync.service.spec.ts index 98df296cb..67cfeafdb 100644 --- a/server/test/medium/specs/services/sync.service.spec.ts +++ b/server/test/medium/specs/services/sync.service.spec.ts @@ -456,9 +456,9 @@ describe(SyncService.name, () => { fileCreatedAt: asset.fileCreatedAt, fileModifiedAt: asset.fileModifiedAt, isFavorite: asset.isFavorite, - isVisible: asset.isVisible, localDateTime: asset.localDateTime, type: asset.type, + visibility: asset.visibility, }, type: 'AssetV1', }, @@ -573,9 +573,9 @@ describe(SyncService.name, () => { fileCreatedAt: date, fileModifiedAt: date, isFavorite: false, - isVisible: true, localDateTime: date, type: asset.type, + visibility: asset.visibility, }, type: SyncEntityType.PartnerAssetV1, }, diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 81ada65b6..94ae3b74a 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -15,7 +15,7 @@ import { } from 'src/database'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum'; +import { AssetStatus, AssetType, AssetVisibility, MemoryType, Permission, UserStatus } from 'src/enum'; import { OnThisDayData } from 'src/types'; export const newUuid = () => randomUUID() as string; @@ -202,11 +202,9 @@ const assetFactory = (asset: Partial = {}) => ({ encodedVideoPath: null, fileCreatedAt: newDate(), fileModifiedAt: newDate(), - isArchived: false, isExternal: false, isFavorite: false, isOffline: false, - isVisible: true, libraryId: null, livePhotoVideoId: null, localDateTime: newDate(), @@ -217,6 +215,7 @@ const assetFactory = (asset: Partial = {}) => ({ stackId: null, thumbhash: null, type: AssetType.IMAGE, + visibility: AssetVisibility.TIMELINE, ...asset, }); diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte index 576c1af54..d5c9b02a2 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte @@ -1,8 +1,8 @@