ソースを参照

feat(server/web): album description (#3558)

* feat(server): add album description

* chore: open api

* fix: tests

* show and edit description on the web

* fix test

* remove unused code

* type event

* format fix

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Jason Rasmussen 1 年間 前
コミット
2f26a7edae
28 ファイル変更287 行追加41 行削除
  1. 18 0
      cli/src/api/open-api/api.ts
  2. 1 0
      mobile/openapi/doc/AlbumResponseDto.md
  3. 1 0
      mobile/openapi/doc/CreateAlbumDto.md
  4. 1 0
      mobile/openapi/doc/UpdateAlbumDto.md
  5. 9 1
      mobile/openapi/lib/model/album_response_dto.dart
  6. 18 1
      mobile/openapi/lib/model/create_album_dto.dart
  7. 20 3
      mobile/openapi/lib/model/update_album_dto.dart
  8. 5 0
      mobile/openapi/test/album_response_dto_test.dart
  9. 5 0
      mobile/openapi/test/create_album_dto_test.dart
  10. 5 0
      mobile/openapi/test/update_album_dto_test.dart
  11. 10 0
      server/immich-openapi-specs.json
  12. 7 25
      server/src/domain/album/album-response.dto.ts
  13. 1 0
      server/src/domain/album/album.service.spec.ts
  14. 2 0
      server/src/domain/album/album.service.ts
  15. 5 1
      server/src/domain/album/dto/album-create.dto.ts
  16. 6 3
      server/src/domain/album/dto/album-update.dto.ts
  17. 4 4
      server/src/immich/controllers/album.controller.ts
  18. 3 0
      server/src/infra/entities/album.entity.ts
  19. 13 0
      server/src/infra/migrations/1691209138541-AddAlbumDescription.ts
  20. 1 1
      server/src/infra/repositories/typesense.repository.ts
  21. 2 1
      server/src/infra/typesense-schemas/album.schema.ts
  22. 30 1
      server/test/e2e/album.e2e-spec.ts
  23. 10 0
      server/test/fixtures/album.stub.ts
  24. 2 0
      server/test/fixtures/shared-link.stub.ts
  25. 18 0
      web/src/api/open-api/api.ts
  26. 46 0
      web/src/lib/components/album-page/album-viewer.svelte
  27. 43 0
      web/src/lib/components/album-page/edit-description-modal.svelte
  28. 1 0
      web/src/test-data/factories/album-factory.ts

+ 18 - 0
cli/src/api/open-api/api.ts

@@ -210,6 +210,12 @@ export interface AlbumResponseDto {
      * @memberof AlbumResponseDto
      */
     'createdAt': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof AlbumResponseDto
+     */
+    'description': string;
     /**
      * 
      * @type {string}
@@ -865,6 +871,12 @@ export interface CreateAlbumDto {
      * @memberof CreateAlbumDto
      */
     'assetIds'?: Array<string>;
+    /**
+     * 
+     * @type {string}
+     * @memberof CreateAlbumDto
+     */
+    'description'?: string;
     /**
      * 
      * @type {Array<string>}
@@ -2843,6 +2855,12 @@ export interface UpdateAlbumDto {
      * @memberof UpdateAlbumDto
      */
     'albumThumbnailAssetId'?: string;
+    /**
+     * 
+     * @type {string}
+     * @memberof UpdateAlbumDto
+     */
+    'description'?: string;
 }
 /**
  * 

+ 1 - 0
mobile/openapi/doc/AlbumResponseDto.md

@@ -13,6 +13,7 @@ Name | Type | Description | Notes
 **assetCount** | **int** |  | 
 **assets** | [**List<AssetResponseDto>**](AssetResponseDto.md) |  | [default to const []]
 **createdAt** | [**DateTime**](DateTime.md) |  | 
+**description** | **String** |  | 
 **id** | **String** |  | 
 **lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) |  | [optional] 
 **owner** | [**UserResponseDto**](UserResponseDto.md) |  | 

+ 1 - 0
mobile/openapi/doc/CreateAlbumDto.md

@@ -10,6 +10,7 @@ Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 **albumName** | **String** |  | 
 **assetIds** | **List<String>** |  | [optional] [default to const []]
+**description** | **String** |  | [optional] 
 **sharedWithUserIds** | **List<String>** |  | [optional] [default to const []]
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

+ 1 - 0
mobile/openapi/doc/UpdateAlbumDto.md

@@ -10,6 +10,7 @@ Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 **albumName** | **String** |  | [optional] 
 **albumThumbnailAssetId** | **String** |  | [optional] 
+**description** | **String** |  | [optional] 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 

+ 9 - 1
mobile/openapi/lib/model/album_response_dto.dart

@@ -18,6 +18,7 @@ class AlbumResponseDto {
     required this.assetCount,
     this.assets = const [],
     required this.createdAt,
+    required this.description,
     required this.id,
     this.lastModifiedAssetTimestamp,
     required this.owner,
@@ -37,6 +38,8 @@ class AlbumResponseDto {
 
   DateTime createdAt;
 
+  String description;
+
   String id;
 
   ///
@@ -64,6 +67,7 @@ class AlbumResponseDto {
      other.assetCount == assetCount &&
      other.assets == assets &&
      other.createdAt == createdAt &&
+     other.description == description &&
      other.id == id &&
      other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp &&
      other.owner == owner &&
@@ -80,6 +84,7 @@ class AlbumResponseDto {
     (assetCount.hashCode) +
     (assets.hashCode) +
     (createdAt.hashCode) +
+    (description.hashCode) +
     (id.hashCode) +
     (lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) +
     (owner.hashCode) +
@@ -89,7 +94,7 @@ class AlbumResponseDto {
     (updatedAt.hashCode);
 
   @override
-  String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, id=$id, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, updatedAt=$updatedAt]';
+  String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, id=$id, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, updatedAt=$updatedAt]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -102,6 +107,7 @@ class AlbumResponseDto {
       json[r'assetCount'] = this.assetCount;
       json[r'assets'] = this.assets;
       json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
+      json[r'description'] = this.description;
       json[r'id'] = this.id;
     if (this.lastModifiedAssetTimestamp != null) {
       json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String();
@@ -129,6 +135,7 @@ class AlbumResponseDto {
         assetCount: mapValueOfType<int>(json, r'assetCount')!,
         assets: AssetResponseDto.listFromJson(json[r'assets']),
         createdAt: mapDateTime(json, r'createdAt', r'')!,
+        description: mapValueOfType<String>(json, r'description')!,
         id: mapValueOfType<String>(json, r'id')!,
         lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''),
         owner: UserResponseDto.fromJson(json[r'owner'])!,
@@ -188,6 +195,7 @@ class AlbumResponseDto {
     'assetCount',
     'assets',
     'createdAt',
+    'description',
     'id',
     'owner',
     'ownerId',

+ 18 - 1
mobile/openapi/lib/model/create_album_dto.dart

@@ -15,6 +15,7 @@ class CreateAlbumDto {
   CreateAlbumDto({
     required this.albumName,
     this.assetIds = const [],
+    this.description,
     this.sharedWithUserIds = const [],
   });
 
@@ -22,12 +23,21 @@ class CreateAlbumDto {
 
   List<String> assetIds;
 
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  String? description;
+
   List<String> sharedWithUserIds;
 
   @override
   bool operator ==(Object other) => identical(this, other) || other is CreateAlbumDto &&
      other.albumName == albumName &&
      other.assetIds == assetIds &&
+     other.description == description &&
      other.sharedWithUserIds == sharedWithUserIds;
 
   @override
@@ -35,15 +45,21 @@ class CreateAlbumDto {
     // ignore: unnecessary_parenthesis
     (albumName.hashCode) +
     (assetIds.hashCode) +
+    (description == null ? 0 : description!.hashCode) +
     (sharedWithUserIds.hashCode);
 
   @override
-  String toString() => 'CreateAlbumDto[albumName=$albumName, assetIds=$assetIds, sharedWithUserIds=$sharedWithUserIds]';
+  String toString() => 'CreateAlbumDto[albumName=$albumName, assetIds=$assetIds, description=$description, sharedWithUserIds=$sharedWithUserIds]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
       json[r'albumName'] = this.albumName;
       json[r'assetIds'] = this.assetIds;
+    if (this.description != null) {
+      json[r'description'] = this.description;
+    } else {
+    //  json[r'description'] = null;
+    }
       json[r'sharedWithUserIds'] = this.sharedWithUserIds;
     return json;
   }
@@ -60,6 +76,7 @@ class CreateAlbumDto {
         assetIds: json[r'assetIds'] is Iterable
             ? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
             : const [],
+        description: mapValueOfType<String>(json, r'description'),
         sharedWithUserIds: json[r'sharedWithUserIds'] is Iterable
             ? (json[r'sharedWithUserIds'] as Iterable).cast<String>().toList(growable: false)
             : const [],

+ 20 - 3
mobile/openapi/lib/model/update_album_dto.dart

@@ -15,6 +15,7 @@ class UpdateAlbumDto {
   UpdateAlbumDto({
     this.albumName,
     this.albumThumbnailAssetId,
+    this.description,
   });
 
   ///
@@ -33,19 +34,29 @@ class UpdateAlbumDto {
   ///
   String? albumThumbnailAssetId;
 
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  String? description;
+
   @override
   bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumDto &&
      other.albumName == albumName &&
-     other.albumThumbnailAssetId == albumThumbnailAssetId;
+     other.albumThumbnailAssetId == albumThumbnailAssetId &&
+     other.description == description;
 
   @override
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     (albumName == null ? 0 : albumName!.hashCode) +
-    (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode);
+    (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
+    (description == null ? 0 : description!.hashCode);
 
   @override
-  String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId]';
+  String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -59,6 +70,11 @@ class UpdateAlbumDto {
     } else {
     //  json[r'albumThumbnailAssetId'] = null;
     }
+    if (this.description != null) {
+      json[r'description'] = this.description;
+    } else {
+    //  json[r'description'] = null;
+    }
     return json;
   }
 
@@ -72,6 +88,7 @@ class UpdateAlbumDto {
       return UpdateAlbumDto(
         albumName: mapValueOfType<String>(json, r'albumName'),
         albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
+        description: mapValueOfType<String>(json, r'description'),
       );
     }
     return null;

+ 5 - 0
mobile/openapi/test/album_response_dto_test.dart

@@ -41,6 +41,11 @@ void main() {
       // TODO
     });
 
+    // String description
+    test('to test the property `description`', () async {
+      // TODO
+    });
+
     // String id
     test('to test the property `id`', () async {
       // TODO

+ 5 - 0
mobile/openapi/test/create_album_dto_test.dart

@@ -26,6 +26,11 @@ void main() {
       // TODO
     });
 
+    // String description
+    test('to test the property `description`', () async {
+      // TODO
+    });
+
     // List<String> sharedWithUserIds (default value: const [])
     test('to test the property `sharedWithUserIds`', () async {
       // TODO

+ 5 - 0
mobile/openapi/test/update_album_dto_test.dart

@@ -26,6 +26,11 @@ void main() {
       // TODO
     });
 
+    // String description
+    test('to test the property `description`', () async {
+      // TODO
+    });
+
 
   });
 

+ 10 - 0
server/immich-openapi-specs.json

@@ -4754,6 +4754,9 @@
             "format": "date-time",
             "type": "string"
           },
+          "description": {
+            "type": "string"
+          },
           "id": {
             "type": "string"
           },
@@ -4786,6 +4789,7 @@
           "id",
           "ownerId",
           "albumName",
+          "description",
           "createdAt",
           "updatedAt",
           "albumThumbnailAssetId",
@@ -5264,6 +5268,9 @@
             },
             "type": "array"
           },
+          "description": {
+            "type": "string"
+          },
           "sharedWithUserIds": {
             "items": {
               "format": "uuid",
@@ -6903,6 +6910,9 @@
           "albumThumbnailAssetId": {
             "format": "uuid",
             "type": "string"
+          },
+          "description": {
+            "type": "string"
           }
         },
         "type": "object"

+ 7 - 25
server/src/domain/album/album-response.dto.ts

@@ -7,6 +7,7 @@ export class AlbumResponseDto {
   id!: string;
   ownerId!: string;
   albumName!: string;
+  description!: string;
   createdAt!: Date;
   updatedAt!: Date;
   albumThumbnailAssetId!: string | null;
@@ -19,7 +20,7 @@ export class AlbumResponseDto {
   lastModifiedAssetTimestamp?: Date;
 }
 
-export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
+const _map = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => {
   const sharedUsers: UserResponseDto[] = [];
 
   entity.sharedUsers?.forEach((user) => {
@@ -29,6 +30,7 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
 
   return {
     albumName: entity.albumName,
+    description: entity.description,
     albumThumbnailAssetId: entity.albumThumbnailAssetId,
     createdAt: entity.createdAt,
     updatedAt: entity.updatedAt,
@@ -37,33 +39,13 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
     owner: mapUser(entity.owner),
     sharedUsers,
     shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0,
-    assets: entity.assets?.map((asset) => mapAsset(asset)) || [],
+    assets: withAssets ? entity.assets?.map((asset) => mapAsset(asset)) || [] : [],
     assetCount: entity.assets?.length || 0,
   };
-}
-
-export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto {
-  const sharedUsers: UserResponseDto[] = [];
+};
 
-  entity.sharedUsers?.forEach((user) => {
-    const userDto = mapUser(user);
-    sharedUsers.push(userDto);
-  });
-
-  return {
-    albumName: entity.albumName,
-    albumThumbnailAssetId: entity.albumThumbnailAssetId,
-    createdAt: entity.createdAt,
-    updatedAt: entity.updatedAt,
-    id: entity.id,
-    ownerId: entity.ownerId,
-    owner: mapUser(entity.owner),
-    sharedUsers,
-    shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0,
-    assets: [],
-    assetCount: entity.assets?.length || 0,
-  };
-}
+export const mapAlbum = (entity: AlbumEntity) => _map(entity, true);
+export const mapAlbumExcludeAssetInfo = (entity: AlbumEntity) => _map(entity, false);
 
 export class AlbumCountResponseDto {
   @ApiProperty({ type: 'integer' })

+ 1 - 0
server/src/domain/album/album.service.spec.ts

@@ -156,6 +156,7 @@ describe(AlbumService.name, () => {
 
       await expect(sut.create(authStub.admin, { albumName: 'Empty album' })).resolves.toEqual({
         albumName: 'Empty album',
+        description: '',
         albumThumbnailAssetId: null,
         assetCount: 0,
         assets: [],

+ 2 - 0
server/src/domain/album/album.service.ts

@@ -94,6 +94,7 @@ export class AlbumService {
     const album = await this.albumRepository.create({
       ownerId: authUser.id,
       albumName: dto.albumName,
+      description: dto.description,
       sharedUsers: dto.sharedWithUserIds?.map((value) => ({ id: value } as UserEntity)) ?? [],
       assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)),
       albumThumbnailAssetId: dto.assetIds?.[0] || null,
@@ -118,6 +119,7 @@ export class AlbumService {
     const updatedAlbum = await this.albumRepository.update({
       id: album.id,
       albumName: dto.albumName,
+      description: dto.description,
       albumThumbnailAssetId: dto.albumThumbnailAssetId,
     });
 

+ 5 - 1
server/src/domain/album/dto/album-create.dto.ts

@@ -1,5 +1,5 @@
 import { ApiProperty } from '@nestjs/swagger';
-import { IsNotEmpty, IsString } from 'class-validator';
+import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
 import { ValidateUUID } from '../../domain.util';
 
 export class CreateAlbumDto {
@@ -8,6 +8,10 @@ export class CreateAlbumDto {
   @ApiProperty()
   albumName!: string;
 
+  @IsString()
+  @IsOptional()
+  description?: string;
+
   @ValidateUUID({ optional: true, each: true })
   sharedWithUserIds?: string[];
 

+ 6 - 3
server/src/domain/album/dto/album-update.dto.ts

@@ -1,12 +1,15 @@
-import { ApiProperty } from '@nestjs/swagger';
-import { IsOptional } from 'class-validator';
+import { IsOptional, IsString } from 'class-validator';
 import { ValidateUUID } from '../../domain.util';
 
 export class UpdateAlbumDto {
   @IsOptional()
-  @ApiProperty()
+  @IsString()
   albumName?: string;
 
+  @IsOptional()
+  @IsString()
+  description?: string;
+
   @ValidateUUID({ optional: true })
   albumThumbnailAssetId?: string;
 }

+ 4 - 4
server/src/immich/controllers/album.controller.ts

@@ -5,8 +5,8 @@ import {
   AuthUserDto,
   BulkIdResponseDto,
   BulkIdsDto,
-  CreateAlbumDto,
-  UpdateAlbumDto,
+  CreateAlbumDto as CreateDto,
+  UpdateAlbumDto as UpdateDto,
 } from '@app/domain';
 import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto';
 import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
@@ -34,7 +34,7 @@ export class AlbumController {
   }
 
   @Post()
-  createAlbum(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateAlbumDto) {
+  createAlbum(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateDto) {
     return this.service.create(authUser, dto);
   }
 
@@ -45,7 +45,7 @@ export class AlbumController {
   }
 
   @Patch(':id')
-  updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateAlbumDto) {
+  updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateDto) {
     return this.service.update(authUser, id, dto);
   }
 

+ 3 - 0
server/src/infra/entities/album.entity.ts

@@ -27,6 +27,9 @@ export class AlbumEntity {
   @Column({ default: 'Untitled Album' })
   albumName!: string;
 
+  @Column({ type: 'text', default: '' })
+  description!: string;
+
   @CreateDateColumn({ type: 'timestamptz' })
   createdAt!: Date;
 

+ 13 - 0
server/src/infra/migrations/1691209138541-AddAlbumDescription.ts

@@ -0,0 +1,13 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddAlbumDescription1691209138541 implements MigrationInterface {
+  name = 'AddAlbumDescription1691209138541';
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`ALTER TABLE "albums" ADD "description" text NOT NULL DEFAULT ''`);
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "description"`);
+  }
+}

+ 1 - 1
server/src/infra/repositories/typesense.repository.ts

@@ -234,7 +234,7 @@ export class TypesenseRepository implements ISearchRepository {
       .documents()
       .search({
         q: query,
-        query_by: 'albumName',
+        query_by: ['albumName', 'description'].join(','),
         filter_by: this.getAlbumFilters(filters),
       });
 

+ 2 - 1
server/src/infra/typesense-schemas/album.schema.ts

@@ -1,11 +1,12 @@
 import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
 
-export const albumSchemaVersion = 1;
+export const albumSchemaVersion = 2;
 export const albumSchema: CollectionCreateSchema = {
   name: `albums-v${albumSchemaVersion}`,
   fields: [
     { name: 'ownerId', type: 'string', facet: false },
     { name: 'albumName', type: 'string', facet: false, sort: true },
+    { name: 'description', type: 'string', facet: false },
     { name: 'createdAt', type: 'string', facet: false, sort: true },
     { name: 'updatedAt', type: 'string', facet: false, sort: true },
   ],

+ 30 - 1
server/test/e2e/album.e2e-spec.ts

@@ -4,7 +4,7 @@ import { SharedLinkType } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
 import { Test, TestingModule } from '@nestjs/testing';
 import request from 'supertest';
-import { errorStub } from '../fixtures';
+import { errorStub, uuidStub } from '../fixtures';
 import { api, db } from '../test-utils';
 
 const user1SharedUser = 'user1SharedUser';
@@ -193,6 +193,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
         updatedAt: expect.any(String),
         ownerId: user1.userId,
         albumName: 'New album',
+        description: '',
         albumThumbnailAssetId: null,
         shared: false,
         sharedUsers: [],
@@ -202,4 +203,32 @@ describe(`${AlbumController.name} (e2e)`, () => {
       });
     });
   });
+
+  describe('PATCH /album/:id', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(server)
+        .patch(`/album/${uuidStub.notFound}`)
+        .send({ albumName: 'New album name' });
+      expect(status).toBe(401);
+      expect(body).toEqual(errorStub.unauthorized);
+    });
+
+    it('should update an album', async () => {
+      const album = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' });
+      const { status, body } = await request(server)
+        .patch(`/album/${album.id}`)
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({
+          albumName: 'New album name',
+          description: 'An album description',
+        });
+      expect(status).toBe(200);
+      expect(body).toEqual({
+        ...album,
+        updatedAt: expect.any(String),
+        albumName: 'New album name',
+        description: 'An album description',
+      });
+    });
+  });
 });

+ 10 - 0
server/test/fixtures/album.stub.ts

@@ -7,6 +7,7 @@ export const albumStub = {
   empty: Object.freeze<AlbumEntity>({
     id: 'album-1',
     albumName: 'Empty album',
+    description: '',
     ownerId: authStub.admin.id,
     owner: userStub.admin,
     assets: [],
@@ -20,6 +21,7 @@ export const albumStub = {
   sharedWithUser: Object.freeze<AlbumEntity>({
     id: 'album-2',
     albumName: 'Empty album shared with user',
+    description: '',
     ownerId: authStub.admin.id,
     owner: userStub.admin,
     assets: [],
@@ -33,6 +35,7 @@ export const albumStub = {
   sharedWithMultiple: Object.freeze<AlbumEntity>({
     id: 'album-3',
     albumName: 'Empty album shared with users',
+    description: '',
     ownerId: authStub.admin.id,
     owner: userStub.admin,
     assets: [],
@@ -46,6 +49,7 @@ export const albumStub = {
   sharedWithAdmin: Object.freeze<AlbumEntity>({
     id: 'album-3',
     albumName: 'Empty album shared with admin',
+    description: '',
     ownerId: authStub.user1.id,
     owner: userStub.user1,
     assets: [],
@@ -59,6 +63,7 @@ export const albumStub = {
   oneAsset: Object.freeze<AlbumEntity>({
     id: 'album-4',
     albumName: 'Album with one asset',
+    description: '',
     ownerId: authStub.admin.id,
     owner: userStub.admin,
     assets: [assetStub.image],
@@ -72,6 +77,7 @@ export const albumStub = {
   twoAssets: Object.freeze<AlbumEntity>({
     id: 'album-4a',
     albumName: 'Album with two assets',
+    description: '',
     ownerId: authStub.admin.id,
     owner: userStub.admin,
     assets: [assetStub.image, assetStub.withLocation],
@@ -85,6 +91,7 @@ export const albumStub = {
   emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({
     id: 'album-5',
     albumName: 'Empty album with invalid thumbnail',
+    description: '',
     ownerId: authStub.admin.id,
     owner: userStub.admin,
     assets: [],
@@ -98,6 +105,7 @@ export const albumStub = {
   emptyWithValidThumbnail: Object.freeze<AlbumEntity>({
     id: 'album-5',
     albumName: 'Empty album with invalid thumbnail',
+    description: '',
     ownerId: authStub.admin.id,
     owner: userStub.admin,
     assets: [],
@@ -111,6 +119,7 @@ export const albumStub = {
   oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({
     id: 'album-6',
     albumName: 'Album with one asset and invalid thumbnail',
+    description: '',
     ownerId: authStub.admin.id,
     owner: userStub.admin,
     assets: [assetStub.image],
@@ -124,6 +133,7 @@ export const albumStub = {
   oneAssetValidThumbnail: Object.freeze<AlbumEntity>({
     id: 'album-6',
     albumName: 'Album with one asset and invalid thumbnail',
+    description: '',
     ownerId: authStub.admin.id,
     owner: userStub.admin,
     assets: [assetStub.image],

+ 2 - 0
server/test/fixtures/shared-link.stub.ts

@@ -68,6 +68,7 @@ const assetResponse: AssetResponseDto = {
 
 const albumResponse: AlbumResponseDto = {
   albumName: 'Test Album',
+  description: '',
   albumThumbnailAssetId: null,
   createdAt: today,
   updatedAt: today,
@@ -146,6 +147,7 @@ export const sharedLinkStub = {
       ownerId: authStub.admin.id,
       owner: userStub.admin,
       albumName: 'Test Album',
+      description: '',
       createdAt: today,
       updatedAt: today,
       albumThumbnailAsset: null,

+ 18 - 0
web/src/api/open-api/api.ts

@@ -210,6 +210,12 @@ export interface AlbumResponseDto {
      * @memberof AlbumResponseDto
      */
     'createdAt': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof AlbumResponseDto
+     */
+    'description': string;
     /**
      * 
      * @type {string}
@@ -865,6 +871,12 @@ export interface CreateAlbumDto {
      * @memberof CreateAlbumDto
      */
     'assetIds'?: Array<string>;
+    /**
+     * 
+     * @type {string}
+     * @memberof CreateAlbumDto
+     */
+    'description'?: string;
     /**
      * 
      * @type {Array<string>}
@@ -2843,6 +2855,12 @@ export interface UpdateAlbumDto {
      * @memberof UpdateAlbumDto
      */
     'albumThumbnailAssetId'?: string;
+    /**
+     * 
+     * @type {string}
+     * @memberof UpdateAlbumDto
+     */
+    'description'?: string;
 }
 /**
  * 

+ 46 - 0
web/src/lib/components/album-page/album-viewer.svelte

@@ -44,6 +44,7 @@
   import { handleError } from '../../utils/handle-error';
   import { downloadArchive } from '../../utils/asset-utils';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
+  import EditDescriptionModal from './edit-description-modal.svelte';
 
   export let album: AlbumResponseDto;
   export let sharedLink: SharedLinkResponseDto | undefined = undefined;
@@ -73,6 +74,7 @@
   let isShowAlbumOptions = false;
   let isShowThumbnailSelection = false;
   let isShowDeleteConfirmation = false;
+  let isEditingDescription = false;
 
   let backUrl = '/albums';
   let currentAlbumName = '';
@@ -298,6 +300,27 @@
   const handleSelectAll = () => {
     multiSelectAsset = new Set(album.assets);
   };
+
+  const descriptionUpdatedHandler = (description: string) => {
+    try {
+      api.albumApi.updateAlbumInfo({
+        id: album.id,
+        updateAlbumDto: {
+          description,
+        },
+      });
+
+      album.description = description;
+    } catch (e) {
+      console.error('Error [descriptionUpdatedHandler] ', e);
+      notificationController.show({
+        type: NotificationType.Error,
+        message: 'Error setting album description, check console for more details',
+      });
+    }
+
+    isEditingDescription = false;
+  };
 </script>
 
 <section class="bg-immich-bg dark:bg-immich-dark-bg" class:hidden={isShowThumbnailSelection}>
@@ -405,6 +428,7 @@
   {/if}
 
   <section class="my-[160px] flex flex-col px-6 sm:px-12 md:px-24 lg:px-40">
+    <!-- ALBUM TITLE -->
     <input
       on:keydown={(e) => {
         if (e.key == 'Enter') {
@@ -421,8 +445,10 @@
       bind:value={album.albumName}
       disabled={!isOwned}
       bind:this={titleInput}
+      title="Edit Title"
     />
 
+    <!-- ALBUM SUMMARY -->
     {#if album.assetCount > 0}
       <span class="my-4 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
         <p class="">{getDateRange()}</p>
@@ -448,6 +474,17 @@
       </div>
     {/if}
 
+    <!-- ALBUM DESCRIPTION -->
+    <button
+      class="mb-12 mt-6 w-full border-b-2 border-transparent pb-2 text-left text-lg font-medium transition-colors hover:border-b-2 dark:text-gray-300"
+      on:click={() => (isEditingDescription = true)}
+      class:hover:border-gray-400={isOwned}
+      disabled={!isOwned}
+      title="Edit description"
+    >
+      {album.description || 'Add description'}
+    </button>
+
     {#if album.assetCount > 0 && !isShowAssetSelection}
       <GalleryViewer assets={album.assets} {sharedLink} bind:selectedAssets={multiSelectAsset} />
     {:else}
@@ -490,6 +527,7 @@
 {#if isShowShareLinkModal}
   <CreateSharedLinkModal on:close={() => (isShowShareLinkModal = false)} shareType={SharedLinkType.Album} {album} />
 {/if}
+
 {#if isShowShareInfoModal}
   <ShareInfoModal on:close={() => (isShowShareInfoModal = false)} {album} on:user-deleted={sharedUserDeletedHandler} />
 {/if}
@@ -515,3 +553,11 @@
     </svelte:fragment>
   </ConfirmDialogue>
 {/if}
+
+{#if isEditingDescription}
+  <EditDescriptionModal
+    {album}
+    on:close={() => (isEditingDescription = false)}
+    on:updated={({ detail: description }) => descriptionUpdatedHandler(description)}
+  />
+{/if}

+ 43 - 0
web/src/lib/components/album-page/edit-description-modal.svelte

@@ -0,0 +1,43 @@
+<script lang="ts">
+  import { createEventDispatcher } from 'svelte';
+  import type { AlbumResponseDto } from '@api';
+  import FullScreenModal from '../shared-components/full-screen-modal.svelte';
+  import Button from '../elements/buttons/button.svelte';
+
+  const dispatch = createEventDispatcher<{
+    close: void;
+    updated: string;
+  }>();
+  export let album: AlbumResponseDto;
+
+  let description = album.description;
+
+  const handleSave = () => {
+    dispatch('updated', description);
+  };
+</script>
+
+<FullScreenModal on:clickOutside={() => dispatch('close')}>
+  <div
+    class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
+  >
+    <div
+      class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
+    >
+      <h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Edit description</h1>
+    </div>
+
+    <form on:submit|preventDefault={handleSave} autocomplete="off">
+      <div class="m-4 flex flex-col gap-2">
+        <label class="immich-form-label" for="email">Description</label>
+        <!-- svelte-ignore a11y-autofocus -->
+        <input class="immich-form-input" id="name" name="name" type="text" bind:value={description} autofocus />
+      </div>
+
+      <div class="mt-8 flex w-full gap-4 px-4">
+        <Button color="gray" fullwidth on:click={() => dispatch('close')}>Cancel</Button>
+        <Button type="submit" fullwidth>Ok</Button>
+      </div>
+    </form>
+  </div>
+</FullScreenModal>

+ 1 - 0
web/src/test-data/factories/album-factory.ts

@@ -5,6 +5,7 @@ import { userFactory } from './user-factory';
 
 export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
   albumName: Sync.each(() => faker.commerce.product()),
+  description: '',
   albumThumbnailAssetId: null,
   assetCount: Sync.each((i) => i % 5),
   assets: [],