From 9ff664ed36bbf29fa4d09b969b8f6531ece4e6c9 Mon Sep 17 00:00:00 2001 From: xCJPECKOVERx Date: Mon, 18 Aug 2025 20:42:47 -0400 Subject: [PATCH] feat(web): Add to Multiple Albums (#20072) * Multi add to album picker: - update modal for multi select - Update add-to-album and add-to-album-action to work with new array return from AlbumPickerModal - Add asset-utils.addAssetsToAlbums (incomplete) * initial addToAlbums endpoint * - fix endpoint - add test * - update return type - make open-api * - simplify return dto - handle notification * - fix returns - clean up * - update i18n - format & check * - checks * - correct successId count - fix assets_cannot_be_added language call * tests * foromat * refactor * - update successful add message to included total attempted * - fix web test - format i18n * - fix open-api * - fix imports to resolve checks * - PR suggestions * open-api * refactor addAssetsToAlbums * refactor it again * - fix error returns and tests * - swap icon for IconButton - don't nest the buttons * open-api * - Cleanup multi-select button to match Thumbnail * merge and openapi * - remove onclick from icon element * - fix double onClose call with keyboard shortcuts * - spelling and formatting - apply new api permission * - open-api * chore: styling * translation --------- Co-authored-by: Alex --- i18n/en.json | 6 + mobile/openapi/README.md | Bin 39258 -> 39531 bytes mobile/openapi/lib/api.dart | Bin 14096 -> 14227 bytes mobile/openapi/lib/api/albums_api.dart | Bin 20429 -> 22643 bytes mobile/openapi/lib/api_client.dart | Bin 35186 -> 35479 bytes mobile/openapi/lib/api_helper.dart | Bin 6866 -> 6978 bytes .../lib/model/albums_add_assets_dto.dart | Bin 0 -> 3387 bytes .../model/albums_add_assets_response_dto.dart | Bin 0 -> 4382 bytes .../lib/model/bulk_id_error_reason.dart | Bin 0 -> 3035 bytes open-api/immich-openapi-specs.json | 119 +++++++ open-api/typescript-sdk/src/fetch-client.ts | 36 ++ .../src/controllers/album.controller.spec.ts | 7 + server/src/controllers/album.controller.ts | 8 + server/src/dtos/album.dto.ts | 19 + server/src/services/album.service.spec.ts | 332 ++++++++++++++++++ server/src/services/album.service.ts | 41 ++- .../actions/add-to-album-action.svelte | 19 +- .../asset-viewer/album-list-item.svelte | 160 +++++++-- .../photos-page/actions/add-to-album.svelte | 20 +- .../album-selection-utils.spec.ts | 22 +- .../album-selection/album-selection-utils.ts | 4 + .../new-album-list-item.svelte | 6 +- web/src/lib/modals/AlbumPickerModal.svelte | 71 +++- web/src/lib/utils/asset-utils.ts | 48 +++ 24 files changed, 863 insertions(+), 55 deletions(-) create mode 100644 mobile/openapi/lib/model/albums_add_assets_dto.dart create mode 100644 mobile/openapi/lib/model/albums_add_assets_response_dto.dart create mode 100644 mobile/openapi/lib/model/bulk_id_error_reason.dart diff --git a/i18n/en.json b/i18n/en.json index 95fddca5d..3988cfdca 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -28,6 +28,9 @@ "add_to_album": "Add to album", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "add_to_album_toggle": "Toggle selection for {album}", + "add_to_albums": "Add to albums", + "add_to_albums_count": "Add to albums ({count})", "add_to_shared_album": "Add to shared album", "add_url": "Add URL", "added_to_archive": "Added to archive", @@ -497,7 +500,9 @@ "assets": "Assets", "assets_added_count": "Added {count, plural, one {# asset} other {# assets}}", "assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album", + "assets_added_to_albums_count": "Added {assetTotal} assets to {albumTotal} albums", "assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album", + "assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums", "assets_count": "{count, plural, one {# asset} other {# assets}}", "assets_deleted_permanently": "{count} asset(s) deleted permanently", "assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server", @@ -514,6 +519,7 @@ "assets_trashed_count": "Trashed {count, plural, one {# asset} other {# assets}}", "assets_trashed_from_server": "{count} asset(s) trashed from the Immich server", "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} already part of the album", + "assets_were_part_of_albums_count": "{count, plural, one {Asset was} other {Assets were}} already part of the albums", "authorized_devices": "Authorized Devices", "automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", "automatic_endpoint_switching_title": "Automatic URL switching", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index f0673e70b9ca9bf10c582edbc68b4147329a3f95..d07f13f7a3af6ab0d134aea59d5d3f0ac79fd028 100644 GIT binary patch delta 175 zcmcb$iRtwgrVYltlTY&SG8Rv^=aroNo`+YYSW}@!K}#zjG(<~FK|e7ksWi8E@_v4q z%?7;g!RC%BDUQX(sU^iOCHb)$Df!9z`i@{F$P#+FDVkggx(d;7smTX2Bqj&MvvL$e p)lJS%6W?5v@SRQBwWuh+C@3|tI6n_+pi^m1wr2{G_~NK^BLK->J^%m! delta 24 gcmaF8h3VENrVYltn`iO51aA&Y{KU4oD&wOe0FBNIDgXcg diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8ecb9cd5f55032ad20341c00ee45cf58b69cab72..f5f353c968ebb43e1407b034fb88f6decdca98c3 100644 GIT binary patch delta 57 zcmbP`H#vX9Vov77l$1#V@{{*-vV)kD8}u|l?9Gcg^H_OOi;D7#;)_xfi}Uj)3v!BV J=HzGB003Hc6HNdB delta 17 ZcmbQ7KOt|!V$RL~IP+LHPvQTs4ggD}2nGNE diff --git a/mobile/openapi/lib/api/albums_api.dart b/mobile/openapi/lib/api/albums_api.dart index 10674b894f2dac9c2d73f719ffd94df6dbcae8b6..a45083669c402b8f8f3aa2127e13677b13348369 100644 GIT binary patch delta 363 zcmX>*pYihs#tmP&ChM89O^#txn7mdtOvy1PsWi9PF(t*ZxHz?>*rg;tRv{5ZY;rwQ zu6}WNW=V!eNlAfcURu5erY3Yfne0ZB-^)3(CW1_z9H?$1q+pAtrg*Y~OZQ}Pg_WCi zShE?4b4YLin$Ad+uJE5!n_fZ4^c;yOCuFfeTV9OP>@Vspe(uh8~Z)R a$-WW+T!_$`JXb;-nd|ErwfPskX->9hifd6(eo;_rVsU<+rb0EB0uU6XmXsFdDPYKNR^Z$u2ms2S B7Ek~H delta 12 TcmX?PcFA-@Jm==~oW+6wC8Gr= diff --git a/mobile/openapi/lib/model/albums_add_assets_dto.dart b/mobile/openapi/lib/model/albums_add_assets_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..bdbf68980ca27f4e7c06cfc2c4768e0ceecbb015 GIT binary patch literal 3387 zcmb_fU2oeq6n*!vxGjcS##DLTQ{m2TgT)!rwK33U4})O{j6~b)WYHz58b+G`zI*v0 z$(6Et!vZvs$os`}&b?yCCefFmjikgn^G#CuBA%XqTmy#%1ZM*3;W2mfEn=q$0f0p1C1e)(vW zC^s_*fqHg)5WDk6e&I?Xbm)I4WkH8m%t!^(X|%Ze#507%Y`Gf2bOsLMj1>_T8;6~* zU)>XkUZ9>mvnNP%&iC`r6)S`}Cdf1^bYx@Kr;h8gM{tIhKL~pHCx=7(OrkWf;Rd!$ zZ-13J$5Ys1YUz?lCd!z+6R zuog=-3|WUOG(XYdw}!rymZ4v9hRXJW4ND6TB+GxT6qtMcO0pstd4J`;B5b$LSE+5@UGUW&0_rl=>a3cUIIkSbqotaKfGqlw_># z?S}Qp>79ak;Jeu(n~O{W$&((|v4U%yv+S;*LD2lXhKNLTDiPKgkJ^RJqETWbO`&L` zL#_@Al>x zqN2iZz(+o~JmB}qnl}4EgI0W)xSKbjezYe-sy#$RWTWaQQ`?X2prlheO2YEoX}{&^ z0H&cqaZcd&ULA1-;A|8-Y&;k38QHCU20OOzWh4~qpg}7F-A(Vf2ZULeq;%>4KIZ;M z?5*9@ar0PP#QHmJHiQOZPv}M0x|#6c_)0U~;w0S_jrl*c(z^P{;n5x%P=JlQGL zT|N?`s)ny6s2D=iPU@$)R#{K)W3rYw#&EmkwGP7n7XT%v@#4ae>edm&t zWLvh=^`WUn-p_Ln?7xLE(UZpxTVj7ix2Pq zIffWZzDb0(z27>&zBuB~bebzIM-!z-lQffED)Pd}WF#{=m${9~?{vOU+V0d4*;1^` zXi?aamj4#1hI%gM_`47q|68dIjBB%7JyyA~BDbS7fx~o?O6}CmPG?z~OEbz;K82W_ ziG1|p`6!+VZH|uM&XSstTF6{1(hmN;JUWUqVGNyT<6>^kljPhOY0b4X3;2~6R?+=d5hD{^JI4cqf)Dt-o zMP})n$O=jA!le1s8950%-IFSU(}-qjDZfb_IgzQ%r508u!Mst0MhLNj<#}N&O+=Q_ zSj1lm6-V4`8-)%blU*%bF*)zIAO^UoNhFCE+Ytcb-jkqn0DWu&GDeSC;$i*0$i99^ zF1do&09WO&$(7R~KUONEP(-)zMj<~9Q_y#tjJ~i+Q?J(^jK9bjbO*}%lzM&g$0rt9 zn;l2x&iC*B7a^GaRHdg+;e@*s}Y1{|rKZ4DO*0;P#nv0CSV2OIvkiLI->Ch!7G1RR_z^yyz zxo+3k+E(4pVoh~>%d4gnawnwY3wPXeU7(D-iplNP#@wej@IW#{U%W55R%TIaFcX68 zW}*Sf+H6H_KPY$rIdiQ2z%85`(oYN~5_v*?W*bVa@lDmxM5}qQI&^&&vupx^7LUCl zN_!VMID8{mrY1D6a8Kx>0m}&{uN&IBh|!G9h}+Z%Gu)=}2;+XH5{#!hOk zl)aoU?5ea5I$;dAgA=vn>j10P`z7H#8G8rLL0t$W6QE-j)KqTtDBkv|b#O`XgkRJ2 zz;g#-!HqHmEvq=eb_rjX%3sck>pY=G>+SvkhRpVzl`0YOP(f;aA{;PS4hcq~EDB;d zSQ97L^EKOdw$V}RW>&sWMi_>83Gs`^-wihEi&ecUE8O9)RGKm`M%d@}e=6-vcAUmq z-5#kpz#DqSuX4o`(&>Bd)`cz$zEO6lR)-G&;FBA^!%O*#Zq^b9y%BhMM$g0kXC^|%Ek4X$hgB;fbfSn^fD zz%3sfK_71hSNskN+;I1Y>Sg=~>sA-V{=EZc$F0y$rdH$T+6o)xB;rrpG112U1D!gm AVgLXD literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/bulk_id_error_reason.dart b/mobile/openapi/lib/model/bulk_id_error_reason.dart new file mode 100644 index 0000000000000000000000000000000000000000..cdaf70217e92a0abf4d8ae5a242ebbd0d8d64a1f GIT binary patch literal 3035 zcma)8ZExE)5dQ98aRG`(0X%uzr^2o4W{9)7=#rv!J`9E-&=O^Hp-7FSVi-mK`|e0d zaV*(efFzc6Z+z~#=QtV-MkBa>Sls{g`|NJ^?Rqi0f!q0)*${5-;qHC`f8O8BZ~wYL zGqU`YG2i z|ECfL-4!eFZ^;b)cidPEZfw7Ms+Dz2xkO}`P~?Ie?{0gONujt+q*fa=b6cj8cRwfT zmKi%3U^)jn2VHW-N)hAl`@vw4N@gwmR>{YCHZw+>Z=6}J;Ku-9gb>U_e!(0E28!>1 ztEzyVa9eO&4yDmgSX&0mCnhVtiqX#(-HjP1k=qIzd&4VC9x^O2N~JPxsF(Rv?(C0w|r7tyOOjcdHSbGXWg36D@k2EA^=K z?sW3{J$crh_#&*Q2$K>@jis+8571c0#w-uG4kGPa!wD@D0xPIBeL`z&WBq$9l0Z`NA+|am)*DqN_OL zsm^$Gz|%St0n~FEkof)Wamv4uU_^{G=p7?8Cy+B~`SAJ5Pguif!)+zq39Rt2 zgqFx5qL_eV;XC#AEzol;?45SAqICNvD2CMrqA)F(nz~1(FYlYgVcPom*+Pi^p59bL zs3JIBOh8pq9>xR+2Z>phweST$fMW}pgOn*if>QTZfS-jIuauLV^iUR@uH|Cr12deoWoZ5i8s=ooF<7(ok*s@y z#HS?mh*!SfIZK^3`~N{gqH5CCKb=lasp?T06xlG)Cq(#^d98~9yr?rIKrnbIr4K>E zTiv+v^I~y7EF083MOI5PUJ-Dtdt}mcY-jijbLoF8q6WtCEQUYyj^o-G(qJ8>_A`1ucOCmbDhRoW zl@AIp3E|qn!=ep`+~Rnt*HF+xJSqt5+yn0OY!5p+4Mx2FItN_sxatvjMCo8Thxx?A z;uzv)Mkz47z!jM_h>$&AWDHsuj(ba>;8&J*DhAdPgO%mWil;AVq^QacF{U;2QPX*9 zc_VvmLs8kNovrGTmXRTJM8x9FBHf0!htiTV%i(b4j_X!-ON!gQ5`>cksCIw7aLTr-|CTo7aIK=VToUbFxoLPZvicm>yHQ_)4$U+kjG zhx$g~Z*YMmIGSN}#_)z367CUAjx&-?(oglFCWk5ej`We-p&)lf@(`/albums/assets${QS.query(QS.explode({ + key, + slug + }))}`, oazapfts.json({ + ...opts, + method: "PUT", + body: albumsAddAssetsDto + }))); +} /** * This endpoint requires the `album.statistics` permission. */ @@ -4553,6 +4583,12 @@ export enum AssetTypeEnum { Audio = "AUDIO", Other = "OTHER" } +export enum BulkIdErrorReason { + Duplicate = "duplicate", + NoPermission = "no_permission", + NotFound = "not_found", + Unknown = "unknown" +} export enum Error { Duplicate = "duplicate", NoPermission = "no_permission", diff --git a/server/src/controllers/album.controller.spec.ts b/server/src/controllers/album.controller.spec.ts index 9b8a19c12..d13227555 100644 --- a/server/src/controllers/album.controller.spec.ts +++ b/server/src/controllers/album.controller.spec.ts @@ -65,6 +65,13 @@ describe(AlbumController.name, () => { }); }); + describe('PUT /albums/assets', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/albums/assets`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + describe('PATCH /albums/:id', () => { it('should be an authenticated route', async () => { await request(ctx.getHttpServer()).patch(`/albums/${factory.uuid()}`).send({ albumName: 'New album name' }); diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index a331fc04f..47f8b5603 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -4,6 +4,8 @@ import { AddUsersDto, AlbumInfoDto, AlbumResponseDto, + AlbumsAddAssetsDto, + AlbumsAddAssetsResponseDto, AlbumStatisticsResponseDto, CreateAlbumDto, GetAlbumsDto, @@ -77,6 +79,12 @@ export class AlbumController { return this.service.addAssets(auth, id, dto); } + @Put('assets') + @Authenticated({ permission: Permission.AlbumAssetCreate, sharedLink: true }) + addAssetsToAlbums(@Auth() auth: AuthDto, @Body() dto: AlbumsAddAssetsDto): Promise { + return this.service.addAssetsToAlbums(auth, dto); + } + @Delete(':id/assets') @Authenticated({ permission: Permission.AlbumAssetDelete }) removeAssetFromAlbum( diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 3a88ba5be..73630b63c 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -3,6 +3,7 @@ import { Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator'; import _ from 'lodash'; import { AlbumUser, AuthSharedLink, User } from 'src/database'; +import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; @@ -54,6 +55,24 @@ export class CreateAlbumDto { assetIds?: string[]; } +export class AlbumsAddAssetsDto { + @ValidateUUID({ each: true }) + albumIds!: string[]; + + @ValidateUUID({ each: true }) + assetIds!: string[]; +} + +export class AlbumsAddAssetsResponseDto { + success!: boolean; + @ApiProperty({ type: 'integer' }) + albumSuccessCount!: number; + @ApiProperty({ type: 'integer' }) + assetSuccessCount!: number; + @ValidateEnum({ enum: BulkIdErrorReason, name: 'BulkIdErrorReason', optional: true }) + error?: BulkIdErrorReason; +} + export class UpdateAlbumDto { @Optional() @IsString() diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 6f07a31dd..f3ba57d74 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -776,6 +776,338 @@ describe(AlbumService.name, () => { }); }); + describe('addAssetsToAlbums', () => { + it('should allow the owner to add assets', async () => { + mocks.access.album.checkOwnerAccess + .mockResolvedValueOnce(new Set(['album-123'])) + .mockResolvedValueOnce(new Set(['album-321'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); + + await expect( + sut.addAssetsToAlbums(authStub.admin, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 }); + + expect(mocks.album.update).toHaveBeenCalledTimes(2); + expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { + id: 'album-123', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-1', + }); + expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', { + id: 'album-321', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-1', + }); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); + }); + + it('should not set the thumbnail if the album has one already', async () => { + mocks.access.album.checkOwnerAccess + .mockResolvedValueOnce(new Set(['album-123'])) + .mockResolvedValueOnce(new Set(['album-321'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })) + .mockResolvedValueOnce(_.cloneDeep({ ...albumStub.oneAsset, albumThumbnailAssetId: 'asset-id' })); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); + + await expect( + sut.addAssetsToAlbums(authStub.admin, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 }); + + expect(mocks.album.update).toHaveBeenCalledTimes(2); + expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { + id: 'album-123', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-id', + }); + expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', { + id: 'album-321', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-id', + }); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); + }); + + it('should allow a shared user to add assets', async () => { + mocks.access.album.checkSharedAlbumAccess + .mockResolvedValueOnce(new Set(['album-123'])) + .mockResolvedValueOnce(new Set(['album-321'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); + + await expect( + sut.addAssetsToAlbums(authStub.user1, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 }); + + expect(mocks.album.update).toHaveBeenCalledTimes(2); + expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { + id: 'album-123', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-1', + }); + expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', { + id: 'album-321', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-1', + }); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { + id: 'album-123', + recipientId: 'admin_id', + }); + expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { + id: 'album-321', + recipientId: 'admin_id', + }); + }); + + it('should not allow a shared user with viewer access to add assets', async () => { + mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithAdmin)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); + + await expect( + sut.addAssetsToAlbums(authStub.user2, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ + success: false, + albumSuccessCount: 0, + assetSuccessCount: 0, + error: BulkIdErrorReason.UNKNOWN, + }); + + expect(mocks.album.update).not.toHaveBeenCalled(); + }); + + it('should not allow a shared link user to add assets to multiple albums', async () => { + mocks.access.album.checkSharedLinkAccess + .mockResolvedValueOnce(new Set(['album-123'])) + .mockResolvedValueOnce(new Set()); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); + + await expect( + sut.addAssetsToAlbums(authStub.adminSharedLink, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 }); + + expect(mocks.album.update).toHaveBeenCalledTimes(1); + expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { + id: 'album-123', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-1', + }); + expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { + id: 'album-123', + recipientId: 'user-id', + }); + expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith( + authStub.adminSharedLink.sharedLink?.id, + new Set(['album-123']), + ); + }); + + it('should allow adding assets shared via partner sharing', async () => { + mocks.access.album.checkOwnerAccess + .mockResolvedValueOnce(new Set(['album-123'])) + .mockResolvedValueOnce(new Set(['album-321'])); + mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); + + await expect( + sut.addAssetsToAlbums(authStub.admin, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 }); + + expect(mocks.album.update).toHaveBeenCalledTimes(2); + expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { + id: 'album-123', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-1', + }); + expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', { + id: 'album-321', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-1', + }); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith( + authStub.admin.user.id, + new Set(['asset-1', 'asset-2', 'asset-3']), + ); + }); + + it('should skip some duplicate assets', async () => { + mocks.access.album.checkOwnerAccess + .mockResolvedValueOnce(new Set(['album-123'])) + .mockResolvedValueOnce(new Set(['album-321'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds + .mockResolvedValueOnce(new Set(['asset-1', 'asset-2', 'asset-3'])) + .mockResolvedValueOnce(new Set()); + + await expect( + sut.addAssetsToAlbums(authStub.admin, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 }); + + expect(mocks.album.update).toHaveBeenCalledTimes(1); + expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-321', { + id: 'album-321', + updatedAt: expect.any(Date), + albumThumbnailAssetId: 'asset-1', + }); + expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1); + expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); + }); + + it('should skip all duplicate assets', async () => { + mocks.access.album.checkOwnerAccess + .mockResolvedValueOnce(new Set(['album-123'])) + .mockResolvedValueOnce(new Set(['album-321'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset)); + mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + + await expect( + sut.addAssetsToAlbums(authStub.admin, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2'], + }), + ).resolves.toEqual({ + success: false, + albumSuccessCount: 0, + assetSuccessCount: 0, + error: BulkIdErrorReason.DUPLICATE, + }); + + expect(mocks.album.update).not.toHaveBeenCalled(); + expect(mocks.album.addAssetIds).not.toHaveBeenCalled(); + }); + + it('should skip assets not shared with user', async () => { + mocks.access.album.checkSharedAlbumAccess + .mockResolvedValueOnce(new Set(['album-123'])) + .mockResolvedValueOnce(new Set(['album-321'])); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple)); + mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); + + await expect( + sut.addAssetsToAlbums(authStub.admin, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ + success: false, + albumSuccessCount: 0, + assetSuccessCount: 0, + error: BulkIdErrorReason.UNKNOWN, + }); + + expect(mocks.album.update).not.toHaveBeenCalled(); + expect(mocks.album.addAssetIds).not.toHaveBeenCalled(); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( + authStub.admin.user.id, + new Set(['asset-1', 'asset-2', 'asset-3']), + false, + ); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith( + authStub.admin.user.id, + new Set(['asset-1', 'asset-2', 'asset-3']), + ); + }); + + it('should not allow unauthorized access to the albums', async () => { + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple)); + + await expect( + sut.addAssetsToAlbums(authStub.admin, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ + success: false, + albumSuccessCount: 0, + assetSuccessCount: 0, + error: BulkIdErrorReason.UNKNOWN, + }); + + expect(mocks.album.update).not.toHaveBeenCalled(); + expect(mocks.album.addAssetIds).not.toHaveBeenCalled(); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalled(); + expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalled(); + }); + + it('should not allow unauthorized shared link access to the album', async () => { + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset)); + + await expect( + sut.addAssetsToAlbums(authStub.adminSharedLink, { + albumIds: ['album-123', 'album-321'], + assetIds: ['asset-1', 'asset-2', 'asset-3'], + }), + ).resolves.toEqual({ + success: false, + albumSuccessCount: 0, + assetSuccessCount: 0, + error: BulkIdErrorReason.UNKNOWN, + }); + + expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled(); + }); + }); + describe('removeAssets', () => { it('should allow the owner to remove assets', async () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 90aefa6d7..32832f0dd 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -3,6 +3,8 @@ import { AddUsersDto, AlbumInfoDto, AlbumResponseDto, + AlbumsAddAssetsDto, + AlbumsAddAssetsResponseDto, AlbumStatisticsResponseDto, CreateAlbumDto, GetAlbumsDto, @@ -13,7 +15,7 @@ import { UpdateAlbumDto, UpdateAlbumUserDto, } from 'src/dtos/album.dto'; -import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; +import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { Permission } from 'src/enum'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; @@ -186,6 +188,43 @@ export class AlbumService extends BaseService { return results; } + async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise { + const results: AlbumsAddAssetsResponseDto = { + success: false, + albumSuccessCount: 0, + assetSuccessCount: 0, + error: BulkIdErrorReason.DUPLICATE, + }; + const successfulAssetIds: Set = new Set(); + for (const albumId of dto.albumIds) { + try { + const albumResults = await this.addAssets(auth, albumId, { ids: dto.assetIds }); + + let success = false; + for (const res of albumResults) { + if (res.success) { + success = true; + results.success = true; + results.error = undefined; + successfulAssetIds.add(res.id); + } else if (results.error && res.error !== BulkIdErrorReason.DUPLICATE) { + results.error = BulkIdErrorReason.UNKNOWN; + } + } + if (success) { + results.albumSuccessCount++; + } + } catch { + if (results.error) { + results.error = BulkIdErrorReason.UNKNOWN; + } + } + } + results.assetSuccessCount = successfulAssetIds.size; + + return results; + } + async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { await this.requireAccess({ auth, permission: Permission.AlbumAssetDelete, ids: [id] }); diff --git a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte index dca0b7918..2c6ac54ef 100644 --- a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte @@ -4,7 +4,7 @@ import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import { AssetAction } from '$lib/constants'; import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte'; - import { addAssetsToAlbum } from '$lib/utils/asset-utils'; + import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import type { AssetResponseDto } from '@immich/sdk'; import { modalManager } from '@immich/ui'; @@ -20,14 +20,23 @@ let { asset, onAction, shared = false }: Props = $props(); const onClick = async () => { - const album = await modalManager.show(AlbumPickerModal, { shared }); + const albums = await modalManager.show(AlbumPickerModal, { shared }); - if (!album) { + if (!albums || albums.length === 0) { return; } - await addAssetsToAlbum(album.id, [asset.id]); - onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album }); + if (albums.length === 1) { + const album = albums[0]; + await addAssetsToAlbum(album.id, [asset.id]); + onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album }); + } else { + await addAssetsToAlbums( + albums.map((a) => a.id), + [asset.id], + ); + onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album: albums[0] }); + } }; diff --git a/web/src/lib/components/asset-viewer/album-list-item.svelte b/web/src/lib/components/asset-viewer/album-list-item.svelte index 7751bd09d..bf2e34b7c 100644 --- a/web/src/lib/components/asset-viewer/album-list-item.svelte +++ b/web/src/lib/components/asset-viewer/album-list-item.svelte @@ -1,8 +1,11 @@ - + + {albumNameArray[0]}{albumNameArray[1]}{albumNameArray[2]} + + + + + + + {#if mouseOver || multiSelected} + + {/if} + diff --git a/web/src/lib/components/photos-page/actions/add-to-album.svelte b/web/src/lib/components/photos-page/actions/add-to-album.svelte index 5ec2e879c..13a26cd13 100644 --- a/web/src/lib/components/photos-page/actions/add-to-album.svelte +++ b/web/src/lib/components/photos-page/actions/add-to-album.svelte @@ -2,7 +2,7 @@ import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte'; import type { OnAddToAlbum } from '$lib/utils/actions'; - import { addAssetsToAlbum } from '$lib/utils/asset-utils'; + import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils'; import { modalManager } from '@immich/ui'; import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -18,15 +18,23 @@ const { getAssets } = getAssetControlContext(); const onClick = async () => { - const album = await modalManager.show(AlbumPickerModal, { shared }); - - if (!album) { + const albums = await modalManager.show(AlbumPickerModal, { shared }); + if (!albums || albums.length === 0) { return; } const assetIds = [...getAssets()].map(({ id }) => id); - await addAssetsToAlbum(album.id, assetIds); - onAddToAlbum(assetIds, album.id); + if (albums.length === 1) { + const album = albums[0]; + await addAssetsToAlbum(album.id, assetIds); + onAddToAlbum(assetIds, album.id); + } else { + await addAssetsToAlbums( + albums.map(({ id }) => id), + assetIds, + ); + onAddToAlbum(assetIds, albums[0].id); + } }; diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts index 242809d58..a078e5576 100644 --- a/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts @@ -24,19 +24,26 @@ const createAlbumRow = (album: AlbumResponseDto, selected: boolean) => ({ type: AlbumModalRowType.ALBUM_ITEM, album, selected, + multiSelected: false, }); describe('Album Modal', () => { it('non-shared with no albums configured yet shows message and new', () => { const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); - const modalRows = converter.toModalRows('', [], [], -1); + const modalRows = converter.toModalRows('', [], [], -1, []); expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_yet')]); }); it('non-shared with no matching albums shows message and new', () => { const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); - const modalRows = converter.toModalRows('matches_nothing', [], [albumFactory.build({ albumName: 'Holidays' })], -1); + const modalRows = converter.toModalRows( + 'matches_nothing', + [], + [albumFactory.build({ albumName: 'Holidays' })], + -1, + [], + ); expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_with_name_yet')]); }); @@ -44,7 +51,7 @@ describe('Album Modal', () => { it('non-shared displays single albums', () => { const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); - const modalRows = converter.toModalRows('', [], [holidayAlbum], -1); + const modalRows = converter.toModalRows('', [], [holidayAlbum], -1, []); expect(modalRows).toStrictEqual([ createNewAlbumRow(false), @@ -64,6 +71,7 @@ describe('Album Modal', () => { [holidayAlbum, constructionAlbum], [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], -1, + [], ); expect(modalRows).toStrictEqual([ @@ -90,6 +98,7 @@ describe('Album Modal', () => { [holidayAlbum, constructionAlbum], [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], -1, + [], ); expect(modalRows).toStrictEqual([ @@ -112,6 +121,7 @@ describe('Album Modal', () => { [holidayAlbum, constructionAlbum], [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], -1, + [], ); expect(modalRows).toStrictEqual([ @@ -125,7 +135,7 @@ describe('Album Modal', () => { const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); - const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0); + const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0, []); expect(modalRows).toStrictEqual([ createNewAlbumRow(true), @@ -141,7 +151,7 @@ describe('Album Modal', () => { const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); - const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1); + const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1, []); expect(modalRows).toStrictEqual([ createNewAlbumRow(false), @@ -157,7 +167,7 @@ describe('Album Modal', () => { const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); - const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3); + const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3, []); expect(modalRows).toStrictEqual([ createNewAlbumRow(false), diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts index 73f289eb1..f016916f7 100644 --- a/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts @@ -16,6 +16,7 @@ export enum AlbumModalRowType { export type AlbumModalRow = { type: AlbumModalRowType; selected?: boolean; + multiSelected?: boolean; text?: string; album?: AlbumResponseDto; }; @@ -41,6 +42,7 @@ export class AlbumModalRowConverter { recentAlbums: AlbumResponseDto[], albums: AlbumResponseDto[], selectedRowIndex: number, + multiSelectedAlbumIds: string[], ): AlbumModalRow[] { // only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal. const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : []; @@ -64,6 +66,7 @@ export class AlbumModalRowConverter { rows.push({ type: AlbumModalRowType.ALBUM_ITEM, selected: selectedRowIndex === i + selectedOffsetDueToNewAlbumRow, + multiSelected: multiSelectedAlbumIds.includes(album.id), album, }); } @@ -81,6 +84,7 @@ export class AlbumModalRowConverter { rows.push({ type: AlbumModalRowType.ALBUM_ITEM, selected: selectedRowIndex === i + selectedOffsetDueToNewAndRecents, + multiSelected: multiSelectedAlbumIds.includes(album.id), album, }); } diff --git a/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte b/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte index d8be0e2a3..a1adc3ef4 100644 --- a/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte +++ b/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte @@ -1,9 +1,9 @@