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 <alex.tran1502@gmail.com>
This commit is contained in:
xCJPECKOVERx 2025-08-18 20:42:47 -04:00 committed by GitHub
parent e00556a34a
commit 9ff664ed36
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 863 additions and 55 deletions

View File

@ -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",

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -940,6 +940,67 @@
"description": "This endpoint requires the `album.create` permission."
}
},
"/albums/assets": {
"put": {
"operationId": "addAssetsToAlbums",
"parameters": [
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlbumsAddAssetsDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlbumsAddAssetsResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Albums"
],
"x-immich-permission": "albumAsset.create",
"description": "This endpoint requires the `albumAsset.create` permission."
}
},
"/albums/statistics": {
"get": {
"operationId": "getAlbumStatistics",
@ -9921,6 +9982,55 @@
],
"type": "string"
},
"AlbumsAddAssetsDto": {
"properties": {
"albumIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
"assetIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
}
},
"required": [
"albumIds",
"assetIds"
],
"type": "object"
},
"AlbumsAddAssetsResponseDto": {
"properties": {
"albumSuccessCount": {
"type": "integer"
},
"assetSuccessCount": {
"type": "integer"
},
"error": {
"allOf": [
{
"$ref": "#/components/schemas/BulkIdErrorReason"
}
]
},
"success": {
"type": "boolean"
}
},
"required": [
"albumSuccessCount",
"assetSuccessCount",
"success"
],
"type": "object"
},
"AlbumsResponse": {
"properties": {
"defaultAssetOrder": {
@ -10877,6 +10987,15 @@
},
"type": "object"
},
"BulkIdErrorReason": {
"enum": [
"duplicate",
"no_permission",
"not_found",
"unknown"
],
"type": "string"
},
"BulkIdResponseDto": {
"properties": {
"error": {

View File

@ -384,6 +384,16 @@ export type CreateAlbumDto = {
assetIds?: string[];
description?: string;
};
export type AlbumsAddAssetsDto = {
albumIds: string[];
assetIds: string[];
};
export type AlbumsAddAssetsResponseDto = {
albumSuccessCount: number;
assetSuccessCount: number;
error?: BulkIdErrorReason;
success: boolean;
};
export type AlbumStatisticsResponseDto = {
notShared: number;
owned: number;
@ -1864,6 +1874,26 @@ export function createAlbum({ createAlbumDto }: {
body: createAlbumDto
})));
}
/**
* This endpoint requires the `albumAsset.create` permission.
*/
export function addAssetsToAlbums({ key, slug, albumsAddAssetsDto }: {
key?: string;
slug?: string;
albumsAddAssetsDto: AlbumsAddAssetsDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AlbumsAddAssetsResponseDto;
}>(`/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",

View File

@ -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' });

View File

@ -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<AlbumsAddAssetsResponseDto> {
return this.service.addAssetsToAlbums(auth, dto);
}
@Delete(':id/assets')
@Authenticated({ permission: Permission.AlbumAssetDelete })
removeAssetFromAlbum(

View File

@ -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()

View File

@ -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']));

View File

@ -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<AlbumsAddAssetsResponseDto> {
const results: AlbumsAddAssetsResponseDto = {
success: false,
albumSuccessCount: 0,
assetSuccessCount: 0,
error: BulkIdErrorReason.DUPLICATE,
};
const successfulAssetIds: Set<string> = 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<BulkIdResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AlbumAssetDelete, ids: [id] });

View File

@ -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] });
}
};
</script>

View File

@ -1,8 +1,11 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { normalizeSearchString } from '$lib/utils/string-utils.js';
import { type AlbumResponseDto } from '@immich/sdk';
import { mdiCheckCircle } from '@mdi/js';
import type { Action } from 'svelte/action';
import AlbumListItemDetails from './album-list-item-details.svelte';
@ -10,10 +13,19 @@
album: AlbumResponseDto;
searchQuery?: string;
selected: boolean;
multiSelected?: boolean;
onAlbumClick: () => void;
onMultiSelect: () => void;
}
let { album, searchQuery = '', selected = false, onAlbumClick }: Props = $props();
let {
album,
searchQuery = '',
selected = false,
multiSelected = false,
onAlbumClick,
onMultiSelect,
}: Props = $props();
const scrollIntoViewIfSelected: Action = (node) => {
$effect(() => {
@ -37,33 +49,127 @@
albumName.slice(findIndex + findLength),
];
});
const handleMultiSelectClicked = (e?: MouseEvent) => {
e?.stopPropagation();
e?.preventDefault();
onMultiSelect();
};
let usingMobileDevice = $derived(mobileDevice.pointerCoarse);
let mouseOver = $state(false);
const onMouseEnter = () => {
if (usingMobileDevice) {
return;
}
mouseOver = true;
};
const onMouseLeave = () => {
mouseOver = false;
};
let timer: ReturnType<typeof setTimeout> | null = null;
const preventContextMenu = (evt: Event) => evt.preventDefault();
const disposeables: (() => void)[] = [];
const clearLongPressTimer = () => {
if (!timer) {
return;
}
clearTimeout(timer);
timer = null;
for (const dispose of disposeables) {
dispose();
}
disposeables.length = 0;
};
function longPress(element: HTMLElement, { onLongPress }: { onLongPress: () => void }) {
let didPress = false;
const start = () => {
didPress = false;
// 350ms for longpress. For reference: iOS uses 500ms for default long press, or 200ms for fast long press.
timer = setTimeout(() => {
onLongPress();
element.addEventListener('contextmenu', preventContextMenu, { once: true });
disposeables.push(() => element.removeEventListener('contextmenu', preventContextMenu));
didPress = true;
}, 350);
};
const click = (e: MouseEvent) => {
if (!didPress) {
return;
}
e.stopPropagation();
e.preventDefault();
};
element.addEventListener('click', click);
element.addEventListener('pointerdown', start, true);
element.addEventListener('pointerup', clearLongPressTimer, { capture: true, passive: true });
return {
destroy: () => {
element.removeEventListener('click', click);
element.removeEventListener('pointerdown', start, true);
element.removeEventListener('pointerup', clearLongPressTimer, true);
},
};
}
</script>
<button
type="button"
onclick={onAlbumClick}
use:scrollIntoViewIfSelected
class="flex w-full gap-4 px-6 py-2 text-start transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
class:bg-gray-200={selected}
class:dark:bg-gray-700={selected}
<div
role="group"
class={[
'relative flex w-full text-start justify-between transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl my-2 hover:cursor-pointer',
{ 'bg-primary/10 hover:bg-primary/10': multiSelected },
]}
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
>
<span class="h-12 w-12 shrink-0 rounded-xl bg-slate-300">
{#if album.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
alt={album.albumName}
class="h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg"
data-testid="album-image"
draggable="false"
/>
{/if}
</span>
<span class="flex h-12 flex-col items-start justify-center overflow-hidden">
<span class="w-full shrink overflow-hidden text-ellipsis whitespace-nowrap"
>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span
>
<span class="flex gap-1 text-sm">
<AlbumListItemDetails {album} />
<button
type="button"
onclick={onAlbumClick}
use:scrollIntoViewIfSelected
class="flex gap-4 px-2 py-2 text-start"
class:bg-gray-200={selected}
class:dark:bg-gray-700={selected}
use:longPress={{ onLongPress: () => handleMultiSelectClicked() }}
>
<span class="h-16 w-16 shrink-0 rounded-xl bg-slate-300">
{#if album.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
alt={album.albumName}
class={['h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg']}
data-testid="album-image"
draggable="false"
/>
{/if}
</span>
</span>
</button>
<span class="flex h-full flex-col items-start justify-center overflow-hidden">
<span class="w-full shrink overflow-hidden text-ellipsis whitespace-nowrap"
>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span
>
<span class="flex gap-1 text-sm">
<AlbumListItemDetails {album} />
</span>
</span>
</button>
{#if mouseOver || multiSelected}
<button
type="button"
onclick={handleMultiSelectClicked}
class="p-3 focus:outline-none hover:cursor-pointer"
role="checkbox"
tabindex={-1}
aria-checked={selected}
>
{#if multiSelected}
<div class="rounded-full">
<Icon path={mdiCheckCircle} size="24" class="text-primary" />
</div>
{:else}
<Icon path={mdiCheckCircle} size="24" class="text-gray-300 hover:text-primary/75" />
{/if}
</button>
{/if}
</div>

View File

@ -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);
}
};
</script>

View File

@ -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),

View File

@ -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,
});
}

View File

@ -1,9 +1,9 @@
<script lang="ts">
import type { Action } from 'svelte/action';
import { mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import Icon from '$lib/components/elements/icon.svelte';
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
import { mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { Action } from 'svelte/action';
interface Props {
searchQuery?: string;

View File

@ -6,8 +6,8 @@
isSelectableRowType,
} from '$lib/components/shared-components/album-selection/album-selection-utils';
import { albumViewSettings } from '$lib/stores/preferences.store';
import { type AlbumResponseDto, createAlbum, getAllAlbums } from '@immich/sdk';
import { Modal, ModalBody } from '@immich/ui';
import { createAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
import { Button, Modal, ModalBody } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import AlbumListItem from '../components/asset-viewer/album-list-item.svelte';
@ -21,7 +21,7 @@
interface Props {
shared: boolean;
onClose: (album?: AlbumResponseDto) => void;
onClose: (albums?: AlbumResponseDto[]) => void;
}
let { shared, onClose }: Props = $props();
@ -32,13 +32,54 @@
loading = false;
});
const multiSelectedAlbumIds: string[] = $state([]);
const multiSelectActive = $derived(multiSelectedAlbumIds.length > 0);
const rowConverter = new AlbumModalRowConverter(shared, $albumViewSettings.sortBy, $albumViewSettings.sortOrder);
const albumModalRows = $derived(rowConverter.toModalRows(search, recentAlbums, albums, selectedRowIndex));
const albumModalRows = $derived(
rowConverter.toModalRows(search, recentAlbums, albums, selectedRowIndex, multiSelectedAlbumIds),
);
const selectableRowCount = $derived(albumModalRows.filter((row) => isSelectableRowType(row.type)).length);
const onNewAlbum = async (name: string) => {
const album = await createAlbum({ createAlbumDto: { albumName: name } });
onClose(album);
onClose([album]);
};
const handleAlbumClick = (album?: AlbumResponseDto) => {
if (multiSelectActive) {
handleMultiSelect(album);
return;
}
if (album) {
onClose([album]);
return;
}
onClose();
};
const handleMultiSelect = (album?: AlbumResponseDto) => {
const selectedAlbum = album ?? albumModalRows.find(({ selected }) => selected)?.album;
if (!selectedAlbum) {
return;
}
const index = multiSelectedAlbumIds.indexOf(selectedAlbum.id);
if (index === -1) {
multiSelectedAlbumIds.push(selectedAlbum.id);
return;
}
multiSelectedAlbumIds.splice(index, 1);
};
const handleMultiSubmit = () => {
const albums = new Set(albumModalRows.filter((row) => row.multiSelected).map(({ album }) => album!));
if (albums.size > 0) {
onClose([...albums]);
} else {
onClose();
}
};
const onEnter = async () => {
@ -53,8 +94,12 @@
break;
}
case AlbumModalRowType.ALBUM_ITEM: {
if (multiSelectActive) {
handleMultiSubmit();
break;
}
if (item.album) {
onClose(item.album);
onClose([item.album]);
}
break;
}
@ -88,6 +133,11 @@
await onEnter();
break;
}
case 'm': {
e.preventDefault();
handleMultiSelect();
break;
}
default: {
selectedRowIndex = -1;
}
@ -133,13 +183,20 @@
<AlbumListItem
album={row.album}
selected={row.selected || false}
multiSelected={row.multiSelected}
searchQuery={search}
onAlbumClick={() => onClose(row.album)}
onAlbumClick={() => handleAlbumClick(row.album)}
onMultiSelect={() => handleMultiSelect(row.album)}
/>
{/if}
{/each}
</div>
{/if}
</div>
{#if multiSelectActive}
<Button size="small" shape="round" fullWidth onclick={handleMultiSubmit}
>{$t('add_to_albums_count', { values: { count: multiSelectedAlbumIds.length } })}</Button
>
{/if}
</ModalBody>
</Modal>

View File

@ -16,7 +16,9 @@ import { navigate } from '$lib/utils/navigation';
import { asQueryString } from '$lib/utils/shared-links';
import {
addAssetsToAlbum as addAssets,
addAssetsToAlbums as addToAlbums,
AssetVisibility,
BulkIdErrorReason,
bulkTagAssets,
createStack,
deleteAssets,
@ -74,6 +76,52 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[], show
}
};
export const addAssetsToAlbums = async (albumIds: string[], assetIds: string[], showNotification = true) => {
const result = await addToAlbums({
...authManager.params,
albumsAddAssetsDto: {
albumIds,
assetIds,
},
});
if (!showNotification) {
return result;
}
if (showNotification) {
const $t = get(t);
if (result.error === BulkIdErrorReason.Duplicate) {
notificationController.show({
type: NotificationType.Info,
timeout: 5000,
message: $t('assets_were_part_of_albums_count', { values: { count: assetIds.length } }),
});
return result;
}
if (result.error) {
notificationController.show({
type: NotificationType.Info,
timeout: 5000,
message: $t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } }),
});
return result;
}
notificationController.show({
type: NotificationType.Info,
timeout: 5000,
message: $t('assets_added_to_albums_count', {
values: {
albumTotal: albumIds.length,
assetTotal: assetIds.length,
},
}),
});
return result;
}
};
export const tagAssets = async ({
assetIds,
tagIds,