Browse Source

Refactor API for albums feature (#155)

* Rename "shared" to "album"

Prepare moving "SharedAlbums" to "Albums"

* Update server album API endpoints

* Update mobile app album endpoints

Also add `putRequest` to mobile network.service

* Add GET album collection filter

- allow to filter by owner = 'mine' | 'their'
- make sharedWithUserIds no longer required when creating an album

* Rename remaining variables to "album"

* Add ParseMeUUIDPipe to validate uuid or `me`

* Add album params validation

* Update todo in mobile album service.

* Setup e2e testing

* Add user e2e tests

* Rename database host env variable to DB_HOST

* Add some `Album` e2e tests

Also fix issues found with the tests

* Force push (try to recover DB_HOST env)

* Rename db host env variable to `DB_HOSTNAME`

* Remove unnecessary `initDb` from test-utils

The current database.config is running the migrations:
`migrationsRun: true`

* Remove `initDb` usage from album e2e test

* Update GET albums filter to `shared`

- add filter by all / shared / not shared
- add response DTOs
- add GET albums e2e tests

* Update album e2e tests for user.service changes

* Update mobile app to use album response DTOs

* Refactor album-service DB into album-registry

- DB logic refactored into album-repository making it easier to test
- add some album-service unit tests
- add `clearMocks` to jest configuration

* Finish implementing album.service unit tests

* Rename response DTO

Make them consistent with rest of the project naming

* Update debug log messages in mobile network service

* Rename table `shared_albums` to `albums`

* Rename table `asset_shared_album`

* Rename Albums `sharedAssets` to `assets`

* Update tests to match updated "delete" response

* Fixed asset cannot be compared in Set by adding Equatable package

* Remove hero effect to fixed janky animation

Co-authored-by: Alex <alex.tran1502@gmail.com>
Jaime Baez 3 years ago
parent
commit
517a3363d6
43 changed files with 1486 additions and 725 deletions
  1. 16 16
      mobile/lib/modules/sharing/models/shared_album.model.dart
  2. 0 50
      mobile/lib/modules/sharing/models/shared_asset.model.dart
  3. 0 76
      mobile/lib/modules/sharing/models/shared_user.model.dart
  4. 2 2
      mobile/lib/modules/sharing/providers/suggested_shared_users.provider.dart
  5. 26 18
      mobile/lib/modules/sharing/services/shared_album.service.dart
  6. 49 52
      mobile/lib/modules/sharing/ui/selection_thumbnail_image.dart
  7. 22 25
      mobile/lib/modules/sharing/ui/shared_album_thumbnail_image.dart
  8. 8 8
      mobile/lib/modules/sharing/views/album_viewer_page.dart
  9. 6 6
      mobile/lib/modules/sharing/views/select_additional_user_for_sharing_page.dart
  10. 5 5
      mobile/lib/modules/sharing/views/select_user_for_sharing_page.dart
  11. 23 45
      mobile/lib/shared/models/immich_asset.model.dart
  12. 23 8
      mobile/lib/shared/models/user.model.dart
  13. 23 4
      mobile/lib/shared/services/network.service.dart
  14. 3 3
      mobile/lib/shared/services/user.service.dart
  15. 0 56
      mobile/pubspec.lock
  16. 228 0
      server/apps/immich/src/api-v1/album/album-repository.ts
  17. 105 0
      server/apps/immich/src/api-v1/album/album.controller.ts
  18. 23 0
      server/apps/immich/src/api-v1/album/album.module.ts
  19. 414 0
      server/apps/immich/src/api-v1/album/album.service.spec.ts
  20. 113 0
      server/apps/immich/src/api-v1/album/album.service.ts
  21. 0 4
      server/apps/immich/src/api-v1/album/dto/add-assets.dto.ts
  22. 0 3
      server/apps/immich/src/api-v1/album/dto/add-users.dto.ts
  23. 12 0
      server/apps/immich/src/api-v1/album/dto/create-album.dto.ts
  24. 21 0
      server/apps/immich/src/api-v1/album/dto/get-albums.dto.ts
  25. 0 3
      server/apps/immich/src/api-v1/album/dto/remove-assets.dto.ts
  26. 1 4
      server/apps/immich/src/api-v1/album/dto/update-album.dto.ts
  27. 28 0
      server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts
  28. 39 0
      server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts
  29. 49 0
      server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts
  30. 15 0
      server/apps/immich/src/api-v1/asset/response-dto/smart-info-response.dto.ts
  31. 0 13
      server/apps/immich/src/api-v1/sharing/dto/create-shared-album.dto.ts
  32. 0 61
      server/apps/immich/src/api-v1/sharing/sharing.controller.ts
  33. 0 24
      server/apps/immich/src/api-v1/sharing/sharing.module.ts
  34. 0 199
      server/apps/immich/src/api-v1/sharing/sharing.service.ts
  35. 11 0
      server/apps/immich/src/api-v1/validation/parse-me-uuid-pipe.ts
  36. 2 2
      server/apps/immich/src/app.module.ts
  37. 161 0
      server/apps/immich/test/album.e2e-spec.ts
  38. 27 0
      server/libs/database/src/entities/album.entity.ts
  39. 6 6
      server/libs/database/src/entities/asset-album.entity.ts
  40. 0 27
      server/libs/database/src/entities/shared-album.entity.ts
  41. 5 5
      server/libs/database/src/entities/user-album.entity.ts
  42. 19 0
      server/libs/database/src/migrations/1655401127251-RenameSharedAlbums.ts
  43. 1 0
      server/package.json

+ 16 - 16
mobile/lib/modules/sharing/models/shared_album.model.dart

@@ -2,8 +2,8 @@ import 'dart:convert';
 
 import 'package:collection/collection.dart';
 
-import 'package:immich_mobile/modules/sharing/models/shared_asset.model.dart';
-import 'package:immich_mobile/modules/sharing/models/shared_user.model.dart';
+import 'package:immich_mobile/shared/models/immich_asset.model.dart';
+import 'package:immich_mobile/shared/models/user.model.dart';
 
 class SharedAlbum {
   final String id;
@@ -11,8 +11,8 @@ class SharedAlbum {
   final String albumName;
   final String createdAt;
   final String? albumThumbnailAssetId;
-  final List<SharedUsers> sharedUsers;
-  final List<SharedAssets>? sharedAssets;
+  final List<User> sharedUsers;
+  final List<ImmichAsset>? assets;
 
   SharedAlbum({
     required this.id,
@@ -21,7 +21,7 @@ class SharedAlbum {
     required this.createdAt,
     required this.albumThumbnailAssetId,
     required this.sharedUsers,
-    this.sharedAssets,
+    this.assets,
   });
 
   SharedAlbum copyWith({
@@ -30,8 +30,8 @@ class SharedAlbum {
     String? albumName,
     String? createdAt,
     String? albumThumbnailAssetId,
-    List<SharedUsers>? sharedUsers,
-    List<SharedAssets>? sharedAssets,
+    List<User>? sharedUsers,
+    List<ImmichAsset>? assets,
   }) {
     return SharedAlbum(
       id: id ?? this.id,
@@ -40,7 +40,7 @@ class SharedAlbum {
       createdAt: createdAt ?? this.createdAt,
       albumThumbnailAssetId: albumThumbnailAssetId ?? this.albumThumbnailAssetId,
       sharedUsers: sharedUsers ?? this.sharedUsers,
-      sharedAssets: sharedAssets ?? this.sharedAssets,
+      assets: assets ?? this.assets,
     );
   }
 
@@ -55,8 +55,8 @@ class SharedAlbum {
       result.addAll({'albumThumbnailAssetId': albumThumbnailAssetId});
     }
     result.addAll({'sharedUsers': sharedUsers.map((x) => x.toMap()).toList()});
-    if (sharedAssets != null) {
-      result.addAll({'sharedAssets': sharedAssets!.map((x) => x.toMap()).toList()});
+    if (assets != null) {
+      result.addAll({'assets': assets!.map((x) => x.toMap()).toList()});
     }
 
     return result;
@@ -69,9 +69,9 @@ class SharedAlbum {
       albumName: map['albumName'] ?? '',
       createdAt: map['createdAt'] ?? '',
       albumThumbnailAssetId: map['albumThumbnailAssetId'],
-      sharedUsers: List<SharedUsers>.from(map['sharedUsers']?.map((x) => SharedUsers.fromMap(x))),
-      sharedAssets: map['sharedAssets'] != null
-          ? List<SharedAssets>.from(map['sharedAssets']?.map((x) => SharedAssets.fromMap(x)))
+      sharedUsers: List<User>.from(map['sharedUsers']?.map((x) => User.fromMap(x))),
+      assets: map['assets'] != null
+          ? List<ImmichAsset>.from(map['assets']?.map((x) => ImmichAsset.fromMap(x)))
           : null,
     );
   }
@@ -82,7 +82,7 @@ class SharedAlbum {
 
   @override
   String toString() {
-    return 'SharedAlbum(id: $id, ownerId: $ownerId, albumName: $albumName, createdAt: $createdAt, albumThumbnailAssetId: $albumThumbnailAssetId, sharedUsers: $sharedUsers, sharedAssets: $sharedAssets)';
+    return 'SharedAlbum(id: $id, ownerId: $ownerId, albumName: $albumName, createdAt: $createdAt, albumThumbnailAssetId: $albumThumbnailAssetId, sharedUsers: $sharedUsers, assets: $assets)';
   }
 
   @override
@@ -97,7 +97,7 @@ class SharedAlbum {
         other.createdAt == createdAt &&
         other.albumThumbnailAssetId == albumThumbnailAssetId &&
         listEquals(other.sharedUsers, sharedUsers) &&
-        listEquals(other.sharedAssets, sharedAssets);
+        listEquals(other.assets, assets);
   }
 
   @override
@@ -108,6 +108,6 @@ class SharedAlbum {
         createdAt.hashCode ^
         albumThumbnailAssetId.hashCode ^
         sharedUsers.hashCode ^
-        sharedAssets.hashCode;
+        assets.hashCode;
   }
 }

+ 0 - 50
mobile/lib/modules/sharing/models/shared_asset.model.dart

@@ -1,50 +0,0 @@
-import 'dart:convert';
-
-import 'package:immich_mobile/shared/models/immich_asset.model.dart';
-
-class SharedAssets {
-  final ImmichAsset assetInfo;
-
-  SharedAssets({
-    required this.assetInfo,
-  });
-
-  SharedAssets copyWith({
-    ImmichAsset? assetInfo,
-  }) {
-    return SharedAssets(
-      assetInfo: assetInfo ?? this.assetInfo,
-    );
-  }
-
-  Map<String, dynamic> toMap() {
-    final result = <String, dynamic>{};
-
-    result.addAll({'assetInfo': assetInfo.toMap()});
-
-    return result;
-  }
-
-  factory SharedAssets.fromMap(Map<String, dynamic> map) {
-    return SharedAssets(
-      assetInfo: ImmichAsset.fromMap(map['assetInfo']),
-    );
-  }
-
-  String toJson() => json.encode(toMap());
-
-  factory SharedAssets.fromJson(String source) => SharedAssets.fromMap(json.decode(source));
-
-  @override
-  String toString() => 'SharedAssets(assetInfo: $assetInfo)';
-
-  @override
-  bool operator ==(Object other) {
-    if (identical(this, other)) return true;
-
-    return other is SharedAssets && other.assetInfo == assetInfo;
-  }
-
-  @override
-  int get hashCode => assetInfo.hashCode;
-}

+ 0 - 76
mobile/lib/modules/sharing/models/shared_user.model.dart

@@ -1,76 +0,0 @@
-import 'dart:convert';
-
-import 'package:immich_mobile/shared/models/user_info.model.dart';
-
-class SharedUsers {
-  final int id;
-  final String albumId;
-  final String sharedUserId;
-  final UserInfo userInfo;
-
-  SharedUsers({
-    required this.id,
-    required this.albumId,
-    required this.sharedUserId,
-    required this.userInfo,
-  });
-
-  SharedUsers copyWith({
-    int? id,
-    String? albumId,
-    String? sharedUserId,
-    UserInfo? userInfo,
-  }) {
-    return SharedUsers(
-      id: id ?? this.id,
-      albumId: albumId ?? this.albumId,
-      sharedUserId: sharedUserId ?? this.sharedUserId,
-      userInfo: userInfo ?? this.userInfo,
-    );
-  }
-
-  Map<String, dynamic> toMap() {
-    final result = <String, dynamic>{};
-
-    result.addAll({'id': id});
-    result.addAll({'albumId': albumId});
-    result.addAll({'sharedUserId': sharedUserId});
-    result.addAll({'userInfo': userInfo.toMap()});
-
-    return result;
-  }
-
-  factory SharedUsers.fromMap(Map<String, dynamic> map) {
-    return SharedUsers(
-      id: map['id']?.toInt() ?? 0,
-      albumId: map['albumId'] ?? '',
-      sharedUserId: map['sharedUserId'] ?? '',
-      userInfo: UserInfo.fromMap(map['userInfo']),
-    );
-  }
-
-  String toJson() => json.encode(toMap());
-
-  factory SharedUsers.fromJson(String source) => SharedUsers.fromMap(json.decode(source));
-
-  @override
-  String toString() {
-    return 'SharedUsers(id: $id, albumId: $albumId, sharedUserId: $sharedUserId, userInfo: $userInfo)';
-  }
-
-  @override
-  bool operator ==(Object other) {
-    if (identical(this, other)) return true;
-
-    return other is SharedUsers &&
-        other.id == id &&
-        other.albumId == albumId &&
-        other.sharedUserId == sharedUserId &&
-        other.userInfo == userInfo;
-  }
-
-  @override
-  int get hashCode {
-    return id.hashCode ^ albumId.hashCode ^ sharedUserId.hashCode ^ userInfo.hashCode;
-  }
-}

+ 2 - 2
mobile/lib/modules/sharing/providers/suggested_shared_users.provider.dart

@@ -1,8 +1,8 @@
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/shared/models/user_info.model.dart';
+import 'package:immich_mobile/shared/models/user.model.dart';
 import 'package:immich_mobile/shared/services/user.service.dart';
 
-final suggestedSharedUsersProvider = FutureProvider.autoDispose<List<UserInfo>>((ref) async {
+final suggestedSharedUsersProvider = FutureProvider.autoDispose<List<User>>((ref) async {
   UserService userService = UserService();
 
   return await userService.getAllUsersInfo();

+ 26 - 18
mobile/lib/modules/sharing/services/shared_album.service.dart

@@ -12,9 +12,10 @@ class SharedAlbumService {
 
   Future<List<SharedAlbum>> getAllSharedAlbum() async {
     try {
-      var res = await _networkService.getRequest(url: 'shared/allSharedAlbums');
+      var res = await _networkService.getRequest(url: 'album?shared=true');
       List<dynamic> decodedData = jsonDecode(res.toString());
-      List<SharedAlbum> result = List.from(decodedData.map((e) => SharedAlbum.fromMap(e)));
+      List<SharedAlbum> result =
+          List.from(decodedData.map((e) => SharedAlbum.fromMap(e)));
 
       return result;
     } catch (e) {
@@ -24,9 +25,10 @@ class SharedAlbumService {
     return [];
   }
 
-  Future<bool> createSharedAlbum(String albumName, Set<ImmichAsset> assets, List<String> sharedUserIds) async {
+  Future<bool> createSharedAlbum(String albumName, Set<ImmichAsset> assets,
+      List<String> sharedUserIds) async {
     try {
-      var res = await _networkService.postRequest(url: 'shared/createAlbum', data: {
+      var res = await _networkService.postRequest(url: 'album', data: {
         "albumName": albumName,
         "sharedWithUserIds": sharedUserIds,
         "assetIds": assets.map((asset) => asset.id).toList(),
@@ -45,7 +47,7 @@ class SharedAlbumService {
 
   Future<SharedAlbum> getAlbumDetail(String albumId) async {
     try {
-      var res = await _networkService.getRequest(url: 'shared/$albumId');
+      var res = await _networkService.getRequest(url: 'album/$albumId');
       dynamic decodedData = jsonDecode(res.toString());
       SharedAlbum result = SharedAlbum.fromMap(decodedData);
 
@@ -55,9 +57,11 @@ class SharedAlbumService {
     }
   }
 
-  Future<bool> addAdditionalAssetToAlbum(Set<ImmichAsset> assets, String albumId) async {
+  Future<bool> addAdditionalAssetToAlbum(
+      Set<ImmichAsset> assets, String albumId) async {
     try {
-      var res = await _networkService.postRequest(url: 'shared/addAssets', data: {
+      var res =
+          await _networkService.putRequest(url: 'album/$albumId/assets', data: {
         "albumId": albumId,
         "assetIds": assets.map((asset) => asset.id).toList(),
       });
@@ -73,10 +77,11 @@ class SharedAlbumService {
     }
   }
 
-  Future<bool> addAdditionalUserToAlbum(List<String> sharedUserIds, String albumId) async {
+  Future<bool> addAdditionalUserToAlbum(
+      List<String> sharedUserIds, String albumId) async {
     try {
-      var res = await _networkService.postRequest(url: 'shared/addUsers', data: {
-        "albumId": albumId,
+      var res =
+          await _networkService.putRequest(url: 'album/$albumId/users', data: {
         "sharedUserIds": sharedUserIds,
       });
 
@@ -93,7 +98,7 @@ class SharedAlbumService {
 
   Future<bool> deleteAlbum(String albumId) async {
     try {
-      Response res = await _networkService.deleteRequest(url: 'shared/$albumId');
+      Response res = await _networkService.deleteRequest(url: 'album/$albumId');
 
       if (res.statusCode != 200) {
         return false;
@@ -108,7 +113,8 @@ class SharedAlbumService {
 
   Future<bool> leaveAlbum(String albumId) async {
     try {
-      Response res = await _networkService.deleteRequest(url: 'shared/leaveAlbum/$albumId');
+      Response res =
+          await _networkService.deleteRequest(url: 'album/$albumId/user/me');
 
       if (res.statusCode != 200) {
         return false;
@@ -121,10 +127,11 @@ class SharedAlbumService {
     }
   }
 
-  Future<bool> removeAssetFromAlbum(String albumId, List<String> assetIds) async {
+  Future<bool> removeAssetFromAlbum(
+      String albumId, List<String> assetIds) async {
     try {
-      Response res = await _networkService.deleteRequest(url: 'shared/removeAssets/', data: {
-        "albumId": albumId,
+      Response res = await _networkService
+          .deleteRequest(url: 'album/$albumId/assets', data: {
         "assetIds": assetIds,
       });
 
@@ -139,10 +146,11 @@ class SharedAlbumService {
     }
   }
 
-  Future<bool> changeTitleAlbum(String albumId, String ownerId, String newAlbumTitle) async {
+  Future<bool> changeTitleAlbum(
+      String albumId, String ownerId, String newAlbumTitle) async {
     try {
-      Response res = await _networkService.patchRequest(url: 'shared/updateInfo', data: {
-        "albumId": albumId,
+      Response res =
+          await _networkService.patchRequest(url: 'album/$albumId/', data: {
         "ownerId": ownerId,
         "albumName": newAlbumTitle,
       });

+ 49 - 52
mobile/lib/modules/sharing/ui/selection_thumbnail_image.dart

@@ -86,63 +86,60 @@ class SelectionThumbnailImage extends HookConsumerWidget {
           }
         }
       },
-      child: Hero(
-        tag: asset.id,
-        child: Stack(
-          children: [
-            Container(
-              decoration: BoxDecoration(border: drawBorderColor()),
-              child: CachedNetworkImage(
-                cacheKey: "${asset.id}-${cacheKey.value}",
-                width: 150,
-                height: 150,
-                memCacheHeight: asset.type == 'IMAGE' ? 150 : 150,
-                fit: BoxFit.cover,
-                imageUrl: thumbnailRequestUrl,
-                httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
-                fadeInDuration: const Duration(milliseconds: 250),
-                progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
-                  scale: 0.2,
-                  child: CircularProgressIndicator(value: downloadProgress.progress),
-                ),
-                errorWidget: (context, url, error) {
-                  return Icon(
-                    Icons.image_not_supported_outlined,
-                    color: Theme.of(context).primaryColor,
-                  );
-                },
+      child: Stack(
+        children: [
+          Container(
+            decoration: BoxDecoration(border: drawBorderColor()),
+            child: CachedNetworkImage(
+              cacheKey: "${asset.id}-${cacheKey.value}",
+              width: 150,
+              height: 150,
+              memCacheHeight: asset.type == 'IMAGE' ? 150 : 150,
+              fit: BoxFit.cover,
+              imageUrl: thumbnailRequestUrl,
+              httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
+              fadeInDuration: const Duration(milliseconds: 250),
+              progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
+                scale: 0.2,
+                child: CircularProgressIndicator(value: downloadProgress.progress),
               ),
+              errorWidget: (context, url, error) {
+                return Icon(
+                  Icons.image_not_supported_outlined,
+                  color: Theme.of(context).primaryColor,
+                );
+              },
             ),
-            Padding(
-              padding: const EdgeInsets.all(3.0),
-              child: Align(
-                alignment: Alignment.topLeft,
-                child: _buildSelectionIcon(asset),
-              ),
+          ),
+          Padding(
+            padding: const EdgeInsets.all(3.0),
+            child: Align(
+              alignment: Alignment.topLeft,
+              child: _buildSelectionIcon(asset),
             ),
-            asset.type == 'IMAGE'
-                ? Container()
-                : Positioned(
-                    bottom: 5,
-                    right: 5,
-                    child: Row(
-                      children: [
-                        Text(
-                          asset.duration.toString().substring(0, 7),
-                          style: const TextStyle(
-                            color: Colors.white,
-                            fontSize: 10,
-                          ),
-                        ),
-                        const Icon(
-                          Icons.play_circle_outline_rounded,
+          ),
+          asset.type == 'IMAGE'
+              ? Container()
+              : Positioned(
+                  bottom: 5,
+                  right: 5,
+                  child: Row(
+                    children: [
+                      Text(
+                        asset.duration.toString().substring(0, 7),
+                        style: const TextStyle(
                           color: Colors.white,
+                          fontSize: 10,
                         ),
-                      ],
-                    ),
-                  )
-          ],
-        ),
+                      ),
+                      const Icon(
+                        Icons.play_circle_outline_rounded,
+                        color: Colors.white,
+                      ),
+                    ],
+                  ),
+                )
+        ],
       ),
     );
   }

+ 22 - 25
mobile/lib/modules/sharing/ui/shared_album_thumbnail_image.dart

@@ -23,32 +23,29 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
       onTap: () {
         // debugPrint("View ${asset.id}");
       },
-      child: Hero(
-        tag: asset.id,
-        child: Stack(
-          children: [
-            CachedNetworkImage(
-              cacheKey: "${asset.id}-${cacheKey.value}",
-              width: 500,
-              height: 500,
-              memCacheHeight: asset.type == 'IMAGE' ? 500 : 500,
-              fit: BoxFit.cover,
-              imageUrl: thumbnailRequestUrl,
-              httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
-              fadeInDuration: const Duration(milliseconds: 250),
-              progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
-                scale: 0.2,
-                child: CircularProgressIndicator(value: downloadProgress.progress),
-              ),
-              errorWidget: (context, url, error) {
-                return Icon(
-                  Icons.image_not_supported_outlined,
-                  color: Theme.of(context).primaryColor,
-                );
-              },
+      child: Stack(
+        children: [
+          CachedNetworkImage(
+            cacheKey: "${asset.id}-${cacheKey.value}",
+            width: 500,
+            height: 500,
+            memCacheHeight: asset.type == 'IMAGE' ? 500 : 500,
+            fit: BoxFit.cover,
+            imageUrl: thumbnailRequestUrl,
+            httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
+            fadeInDuration: const Duration(milliseconds: 250),
+            progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
+              scale: 0.2,
+              child: CircularProgressIndicator(value: downloadProgress.progress),
             ),
-          ],
-        ),
+            errorWidget: (context, url, error) {
+              return Icon(
+                Icons.image_not_supported_outlined,
+                color: Theme.of(context).primaryColor,
+              );
+            },
+          ),
+        ],
       ),
     );
   }

+ 8 - 8
mobile/lib/modules/sharing/views/album_viewer_page.dart

@@ -36,10 +36,10 @@ class AlbumViewerPage extends HookConsumerWidget {
     /// Find out if the assets in album exist on the device
     /// If they exist, add to selected asset state to show they are already selected.
     void _onAddPhotosPressed(SharedAlbum albumInfo) async {
-      if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) {
+      if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) {
         ref
             .watch(assetSelectionProvider.notifier)
-            .addNewAssets(albumInfo.sharedAssets!.map((e) => e.assetInfo).toList());
+            .addNewAssets(albumInfo.assets!.toList());
       }
 
       ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true);
@@ -101,10 +101,10 @@ class AlbumViewerPage extends HookConsumerWidget {
     }
 
     Widget _buildAlbumDateRange(SharedAlbum albumInfo) {
-      if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) {
+      if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) {
         String startDate = "";
-        DateTime parsedStartDate = DateTime.parse(albumInfo.sharedAssets!.first.assetInfo.createdAt);
-        DateTime parsedEndDate = DateTime.parse(albumInfo.sharedAssets!.last.assetInfo.createdAt);
+        DateTime parsedStartDate = DateTime.parse(albumInfo.assets!.first.createdAt);
+        DateTime parsedEndDate = DateTime.parse(albumInfo.assets!.last.createdAt);
 
         if (parsedStartDate.year == parsedEndDate.year) {
           startDate = DateFormat('LLL d').format(parsedStartDate);
@@ -163,7 +163,7 @@ class AlbumViewerPage extends HookConsumerWidget {
     }
 
     Widget _buildImageGrid(SharedAlbum albumInfo) {
-      if (albumInfo.sharedAssets != null && albumInfo.sharedAssets!.isNotEmpty) {
+      if (albumInfo.assets != null && albumInfo.assets!.isNotEmpty) {
         return SliverPadding(
           padding: const EdgeInsets.only(top: 10.0),
           sliver: SliverGrid(
@@ -174,9 +174,9 @@ class AlbumViewerPage extends HookConsumerWidget {
             ),
             delegate: SliverChildBuilderDelegate(
               (BuildContext context, int index) {
-                return AlbumViewerThumbnail(asset: albumInfo.sharedAssets![index].assetInfo);
+                return AlbumViewerThumbnail(asset: albumInfo.assets![index]);
               },
-              childCount: albumInfo.sharedAssets?.length,
+              childCount: albumInfo.assets?.length,
             ),
           ),
         );

+ 6 - 6
mobile/lib/modules/sharing/views/select_additional_user_for_sharing_page.dart

@@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/sharing/models/shared_album.model.dart';
 import 'package:immich_mobile/modules/sharing/providers/suggested_shared_users.provider.dart';
-import 'package:immich_mobile/shared/models/user_info.model.dart';
+import 'package:immich_mobile/shared/models/user.model.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 
 class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
@@ -14,14 +14,14 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    AsyncValue<List<UserInfo>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider);
-    final sharedUsersList = useState<Set<UserInfo>>({});
+    AsyncValue<List<User>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider);
+    final sharedUsersList = useState<Set<User>>({});
 
     _addNewUsersHandler() {
       AutoRouter.of(context).pop(sharedUsersList.value.map((e) => e.id).toList());
     }
 
-    _buildTileIcon(UserInfo user) {
+    _buildTileIcon(User user) {
       if (sharedUsersList.value.contains(user)) {
         return CircleAvatar(
           backgroundColor: Theme.of(context).primaryColor,
@@ -38,7 +38,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
       }
     }
 
-    _buildUserList(List<UserInfo> users) {
+    _buildUserList(List<User> users) {
       List<Widget> usersChip = [];
 
       for (var user in sharedUsersList.value) {
@@ -120,7 +120,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
       body: suggestedShareUsers.when(
         data: (users) {
           for (var sharedUsers in albumInfo.sharedUsers) {
-            users.removeWhere((u) => u.id == sharedUsers.sharedUserId || u.id == albumInfo.ownerId);
+            users.removeWhere((u) => u.id == sharedUsers.id || u.id == albumInfo.ownerId);
           }
 
           return _buildUserList(users);

+ 5 - 5
mobile/lib/modules/sharing/views/select_user_for_sharing_page.dart

@@ -8,15 +8,15 @@ import 'package:immich_mobile/modules/sharing/providers/shared_album.provider.da
 import 'package:immich_mobile/modules/sharing/providers/suggested_shared_users.provider.dart';
 import 'package:immich_mobile/modules/sharing/services/shared_album.service.dart';
 import 'package:immich_mobile/routing/router.dart';
-import 'package:immich_mobile/shared/models/user_info.model.dart';
+import 'package:immich_mobile/shared/models/user.model.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 
 class SelectUserForSharingPage extends HookConsumerWidget {
   const SelectUserForSharingPage({Key? key}) : super(key: key);
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final sharedUsersList = useState<Set<UserInfo>>({});
-    AsyncValue<List<UserInfo>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider);
+    final sharedUsersList = useState<Set<User>>({});
+    AsyncValue<List<User>> suggestedShareUsers = ref.watch(suggestedSharedUsersProvider);
 
     _createSharedAlbum() async {
       var isSuccess = await SharedAlbumService().createSharedAlbum(
@@ -36,7 +36,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
       const ScaffoldMessenger(child: SnackBar(content: Text('Failed to create album')));
     }
 
-    _buildTileIcon(UserInfo user) {
+    _buildTileIcon(User user) {
       if (sharedUsersList.value.contains(user)) {
         return CircleAvatar(
           backgroundColor: Theme.of(context).primaryColor,
@@ -53,7 +53,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
       }
     }
 
-    _buildUserList(List<UserInfo> users) {
+    _buildUserList(List<User> users) {
       List<Widget> usersChip = [];
 
       for (var user in sharedUsersList.value) {

+ 23 - 45
mobile/lib/shared/models/immich_asset.model.dart

@@ -1,6 +1,8 @@
 import 'dart:convert';
 
-class ImmichAsset {
+import 'package:equatable/equatable.dart';
+
+class ImmichAsset extends Equatable {
   final String id;
   final String deviceAssetId;
   final String userId;
@@ -13,7 +15,7 @@ class ImmichAsset {
   final String originalPath;
   final String resizePath;
 
-  ImmichAsset({
+  const ImmichAsset({
     required this.id,
     required this.deviceAssetId,
     required this.userId,
@@ -56,19 +58,23 @@ class ImmichAsset {
   }
 
   Map<String, dynamic> toMap() {
-    return {
-      'id': id,
-      'deviceAssetId': deviceAssetId,
-      'userId': userId,
-      'deviceId': deviceId,
-      'type': type,
-      'createdAt': createdAt,
-      'modifiedAt': modifiedAt,
-      'isFavorite': isFavorite,
-      'duration': duration,
-      'originalPath': originalPath,
-      'resizePath': resizePath,
-    };
+    final result = <String, dynamic>{};
+
+    result.addAll({'id': id});
+    result.addAll({'deviceAssetId': deviceAssetId});
+    result.addAll({'userId': userId});
+    result.addAll({'deviceId': deviceId});
+    result.addAll({'type': type});
+    result.addAll({'createdAt': createdAt});
+    result.addAll({'modifiedAt': modifiedAt});
+    result.addAll({'isFavorite': isFavorite});
+    if (duration != null) {
+      result.addAll({'duration': duration});
+    }
+    result.addAll({'originalPath': originalPath});
+    result.addAll({'resizePath': resizePath});
+
+    return result;
   }
 
   factory ImmichAsset.fromMap(Map<String, dynamic> map) {
@@ -97,35 +103,7 @@ class ImmichAsset {
   }
 
   @override
-  bool operator ==(Object other) {
-    if (identical(this, other)) return true;
-
-    return other is ImmichAsset &&
-        other.id == id &&
-        other.deviceAssetId == deviceAssetId &&
-        other.userId == userId &&
-        other.deviceId == deviceId &&
-        other.type == type &&
-        other.createdAt == createdAt &&
-        other.modifiedAt == modifiedAt &&
-        other.isFavorite == isFavorite &&
-        other.duration == duration &&
-        other.originalPath == originalPath &&
-        other.resizePath == resizePath;
-  }
-
-  @override
-  int get hashCode {
-    return id.hashCode ^
-        deviceAssetId.hashCode ^
-        userId.hashCode ^
-        deviceId.hashCode ^
-        type.hashCode ^
-        createdAt.hashCode ^
-        modifiedAt.hashCode ^
-        isFavorite.hashCode ^
-        duration.hashCode ^
-        originalPath.hashCode ^
-        resizePath.hashCode;
+  List<Object> get props {
+    return [id];
   }
 }

+ 23 - 8
mobile/lib/shared/models/user_info.model.dart → mobile/lib/shared/models/user.model.dart

@@ -1,25 +1,33 @@
 import 'dart:convert';
 
-class UserInfo {
+class User {
   final String id;
   final String email;
   final String createdAt;
+  final String firstName;
+  final String lastName;
 
-  UserInfo({
+  User({
     required this.id,
     required this.email,
     required this.createdAt,
+    required this.firstName,
+    required this.lastName,
   });
 
-  UserInfo copyWith({
+  User copyWith({
     String? id,
     String? email,
     String? createdAt,
+    String? firstName,
+    String? lastName,
   }) {
-    return UserInfo(
+    return User(
       id: id ?? this.id,
       email: email ?? this.email,
       createdAt: createdAt ?? this.createdAt,
+      firstName: firstName ?? this.firstName,
+      lastName: lastName ?? this.lastName,
     );
   }
 
@@ -33,17 +41,19 @@ class UserInfo {
     return result;
   }
 
-  factory UserInfo.fromMap(Map<String, dynamic> map) {
-    return UserInfo(
+  factory User.fromMap(Map<String, dynamic> map) {
+    return User(
       id: map['id'] ?? '',
       email: map['email'] ?? '',
       createdAt: map['createdAt'] ?? '',
+      firstName: map['firstName'] ?? '',
+      lastName: map['lastName'] ?? '',
     );
   }
 
   String toJson() => json.encode(toMap());
 
-  factory UserInfo.fromJson(String source) => UserInfo.fromMap(json.decode(source));
+  factory User.fromJson(String source) => User.fromMap(json.decode(source));
 
   @override
   String toString() => 'UserInfo(id: $id, email: $email, createdAt: $createdAt)';
@@ -52,7 +62,12 @@ class UserInfo {
   bool operator ==(Object other) {
     if (identical(this, other)) return true;
 
-    return other is UserInfo && other.id == id && other.email == email && other.createdAt == createdAt;
+    return other is User &&
+      other.id == id &&
+      other.email == email &&
+      other.createdAt == createdAt &&
+      other.firstName == firstName &&
+      other.lastName == lastName;
   }
 
   @override

+ 23 - 4
mobile/lib/shared/services/network.service.dart

@@ -22,7 +22,7 @@ class NetworkService {
     } on DioError catch (e) {
       debugPrint("DioError: ${e.response}");
     } catch (e) {
-      debugPrint("ERROR getRequest: ${e.toString()}");
+      debugPrint("ERROR deleteRequest: ${e.toString()}");
     }
   }
 
@@ -78,7 +78,26 @@ class NetworkService {
       debugPrint("DioError: ${e.response}");
       return null;
     } catch (e) {
-      debugPrint("ERROR BackupService: $e");
+      debugPrint("ERROR PostRequest: $e");
+      return null;
+    }
+  }
+
+  Future<dynamic> putRequest({required String url, dynamic data}) async {
+    try {
+      var dio = Dio();
+      dio.interceptors.add(AuthenticatedRequestInterceptor());
+
+      var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
+      String validUrl = Uri.parse('$savedEndpoint/$url').toString();
+      Response res = await dio.put(validUrl, data: data);
+
+      return res;
+    } on DioError catch (e) {
+      debugPrint("DioError: ${e.response}");
+      return null;
+    } catch (e) {
+      debugPrint("ERROR PutRequest: $e");
       return null;
     }
   }
@@ -97,7 +116,7 @@ class NetworkService {
     } on DioError catch (e) {
       debugPrint("DioError: ${e.response}");
     } catch (e) {
-      debugPrint("ERROR BackupService: $e");
+      debugPrint("ERROR PatchRequest: $e");
     }
   }
 
@@ -122,7 +141,7 @@ class NetworkService {
       debugPrint("[PING SERVER] DioError: ${e.response} - $e");
       return false;
     } catch (e) {
-      debugPrint("ERROR BackupService: $e");
+      debugPrint("ERROR PingServer: $e");
       return false;
     }
   }

+ 3 - 3
mobile/lib/shared/services/user.service.dart

@@ -6,7 +6,7 @@ import 'package:hive/hive.dart';
 import 'package:image_picker/image_picker.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/shared/models/upload_profile_image_repsonse.model.dart';
-import 'package:immich_mobile/shared/models/user_info.model.dart';
+import 'package:immich_mobile/shared/models/user.model.dart';
 import 'package:immich_mobile/shared/services/network.service.dart';
 import 'package:immich_mobile/utils/dio_http_interceptor.dart';
 import 'package:immich_mobile/utils/files_helper.dart';
@@ -15,11 +15,11 @@ import 'package:http_parser/http_parser.dart';
 class UserService {
   final NetworkService _networkService = NetworkService();
 
-  Future<List<UserInfo>> getAllUsersInfo() async {
+  Future<List<User>> getAllUsersInfo() async {
     try {
       Response res = await _networkService.getRequest(url: 'user');
       List<dynamic> decodedData = jsonDecode(res.toString());
-      List<UserInfo> result = List.from(decodedData.map((e) => UserInfo.fromMap(e)));
+      List<User> result = List.from(decodedData.map((e) => User.fromMap(e)));
 
       return result;
     } catch (e) {

+ 0 - 56
mobile/pubspec.lock

@@ -1029,62 +1029,6 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.0.4"
-  url_launcher:
-    dependency: "direct main"
-    description:
-      name: url_launcher
-      url: "https://pub.dartlang.org"
-    source: hosted
-    version: "6.1.3"
-  url_launcher_android:
-    dependency: transitive
-    description:
-      name: url_launcher_android
-      url: "https://pub.dartlang.org"
-    source: hosted
-    version: "6.0.17"
-  url_launcher_ios:
-    dependency: transitive
-    description:
-      name: url_launcher_ios
-      url: "https://pub.dartlang.org"
-    source: hosted
-    version: "6.0.17"
-  url_launcher_linux:
-    dependency: transitive
-    description:
-      name: url_launcher_linux
-      url: "https://pub.dartlang.org"
-    source: hosted
-    version: "3.0.1"
-  url_launcher_macos:
-    dependency: transitive
-    description:
-      name: url_launcher_macos
-      url: "https://pub.dartlang.org"
-    source: hosted
-    version: "3.0.1"
-  url_launcher_platform_interface:
-    dependency: transitive
-    description:
-      name: url_launcher_platform_interface
-      url: "https://pub.dartlang.org"
-    source: hosted
-    version: "2.0.5"
-  url_launcher_web:
-    dependency: transitive
-    description:
-      name: url_launcher_web
-      url: "https://pub.dartlang.org"
-    source: hosted
-    version: "2.0.11"
-  url_launcher_windows:
-    dependency: transitive
-    description:
-      name: url_launcher_windows
-      url: "https://pub.dartlang.org"
-    source: hosted
-    version: "3.0.1"
   uuid:
     dependency: transitive
     description:

+ 228 - 0
server/apps/immich/src/api-v1/album/album-repository.ts

@@ -0,0 +1,228 @@
+import { AlbumEntity } from '@app/database/entities/album.entity';
+import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
+import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { getConnection, Repository, SelectQueryBuilder } from 'typeorm';
+import { AddAssetsDto } from './dto/add-assets.dto';
+import { AddUsersDto } from './dto/add-users.dto';
+import { CreateAlbumDto } from './dto/create-album.dto';
+import { GetAlbumsDto } from './dto/get-albums.dto';
+import { RemoveAssetsDto } from './dto/remove-assets.dto';
+import { UpdateAlbumDto } from './dto/update-album.dto';
+
+export interface IAlbumRepository {
+  create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
+  getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]>;
+  get(albumId: string): Promise<AlbumEntity>;
+  delete(album: AlbumEntity): Promise<void>;
+  addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity>;
+  removeUser(album: AlbumEntity, userId: string): Promise<void>;
+  removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<boolean>;
+  addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity>;
+  updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
+}
+
+export const ALBUM_REPOSITORY = 'ALBUM_REPOSITORY';
+
+@Injectable()
+export class AlbumRepository implements IAlbumRepository {
+  constructor(
+    @InjectRepository(AlbumEntity)
+    private albumRepository: Repository<AlbumEntity>,
+
+    @InjectRepository(AssetAlbumEntity)
+    private assetAlbumRepository: Repository<AssetAlbumEntity>,
+
+    @InjectRepository(UserAlbumEntity)
+    private userAlbumRepository: Repository<UserAlbumEntity>,
+  ) {}
+
+  async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity> {
+    return await getConnection().transaction(async (transactionalEntityManager) => {
+      // Create album entity
+      const newAlbum = new AlbumEntity();
+      newAlbum.ownerId = ownerId;
+      newAlbum.albumName = createAlbumDto.albumName;
+
+      const album = await transactionalEntityManager.save(newAlbum);
+
+      // Add shared users
+      if (createAlbumDto.sharedWithUserIds?.length) {
+        for (const sharedUserId of createAlbumDto.sharedWithUserIds) {
+          const newSharedUser = new UserAlbumEntity();
+          newSharedUser.albumId = album.id;
+          newSharedUser.sharedUserId = sharedUserId;
+
+          await transactionalEntityManager.save(newSharedUser);
+        }
+      }
+
+      // Add shared assets
+      const newRecords: AssetAlbumEntity[] = [];
+
+      if (createAlbumDto.assetIds?.length) {
+        for (const assetId of createAlbumDto.assetIds) {
+          const newAssetAlbum = new AssetAlbumEntity();
+          newAssetAlbum.assetId = assetId;
+          newAssetAlbum.albumId = album.id;
+
+          newRecords.push(newAssetAlbum);
+        }
+      }
+
+      if (!album.albumThumbnailAssetId && newRecords.length > 0) {
+        album.albumThumbnailAssetId = newRecords[0].assetId;
+        await transactionalEntityManager.save(album);
+      }
+
+      await transactionalEntityManager.save([...newRecords]);
+
+      return album;
+    });
+    return;
+  }
+
+  getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]> {
+    const filteringByShared = typeof getAlbumsDto.shared == 'boolean';
+    const userId = ownerId;
+    let query = this.albumRepository.createQueryBuilder('album');
+
+    const getSharedAlbumIdsSubQuery = (qb: SelectQueryBuilder<AlbumEntity>) => {
+      return qb
+        .subQuery()
+        .select('albumSub.id')
+        .from(AlbumEntity, 'albumSub')
+        .innerJoin('albumSub.sharedUsers', 'userAlbumSub')
+        .where('albumSub.ownerId = :ownerId', { ownerId: userId })
+        .getQuery();
+    };
+
+    if (filteringByShared) {
+      if (getAlbumsDto.shared) {
+        // shared albums
+        query = query
+          .innerJoinAndSelect('album.sharedUsers', 'sharedUser')
+          .innerJoinAndSelect('sharedUser.userInfo', 'userInfo')
+          .where((qb) => {
+            // owned and shared with other users
+            const subQuery = getSharedAlbumIdsSubQuery(qb);
+            return `album.id IN ${subQuery}`;
+          })
+          .orWhere((qb) => {
+            // shared with userId
+            const subQuery = qb
+              .subQuery()
+              .select('userAlbum.albumId')
+              .from(UserAlbumEntity, 'userAlbum')
+              .where('userAlbum.sharedUserId = :sharedUserId', { sharedUserId: userId })
+              .getQuery();
+            return `album.id IN ${subQuery}`;
+          });
+      } else {
+        // owned, not shared albums
+        query = query.where('album.ownerId = :ownerId', { ownerId: userId }).andWhere((qb) => {
+          const subQuery = getSharedAlbumIdsSubQuery(qb);
+          return `album.id NOT IN ${subQuery}`;
+        });
+      }
+    } else {
+      // owned and shared with userId
+      query = query
+        .leftJoinAndSelect('album.sharedUsers', 'sharedUser')
+        .leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
+        .where('album.ownerId = :ownerId', { ownerId: userId })
+        .orWhere((qb) => {
+          const subQuery = qb
+            .subQuery()
+            .select('userAlbum.albumId')
+            .from(UserAlbumEntity, 'userAlbum')
+            .where('userAlbum.sharedUserId = :sharedUserId', { sharedUserId: userId })
+            .getQuery();
+          return `album.id IN ${subQuery}`;
+        });
+    }
+    return query.orderBy('album.createdAt', 'DESC').getMany();
+  }
+
+  async get(albumId: string): Promise<AlbumEntity | undefined> {
+    const album = await this.albumRepository.findOne({
+      where: { id: albumId },
+      relations: ['sharedUsers', 'sharedUsers.userInfo', 'assets', 'assets.assetInfo'],
+    });
+
+    if (!album) {
+      return;
+    }
+    // TODO: sort in query
+    const sortedSharedAsset = album.assets.sort(
+      (a, b) => new Date(a.assetInfo.createdAt).valueOf() - new Date(b.assetInfo.createdAt).valueOf(),
+    );
+
+    album.assets = sortedSharedAsset;
+
+    return album;
+  }
+
+  async delete(album: AlbumEntity): Promise<void> {
+    await this.albumRepository.delete({ id: album.id, ownerId: album.ownerId });
+  }
+
+  async addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise<AlbumEntity> {
+    const newRecords: UserAlbumEntity[] = [];
+
+    for (const sharedUserId of addUsersDto.sharedUserIds) {
+      const newEntity = new UserAlbumEntity();
+      newEntity.albumId = album.id;
+      newEntity.sharedUserId = sharedUserId;
+
+      newRecords.push(newEntity);
+    }
+
+    await this.userAlbumRepository.save([...newRecords]);
+    return this.get(album.id);
+  }
+
+  async removeUser(album: AlbumEntity, userId: string): Promise<void> {
+    await this.userAlbumRepository.delete({ albumId: album.id, sharedUserId: userId });
+  }
+
+  async removeAssets(album: AlbumEntity, removeAssetsDto: RemoveAssetsDto): Promise<boolean> {
+    let deleteAssetCount = 0;
+    // TODO: should probably do a single delete query?
+    for (const assetId of removeAssetsDto.assetIds) {
+      const res = await this.assetAlbumRepository.delete({ albumId: album.id, assetId: assetId });
+      if (res.affected == 1) deleteAssetCount++;
+    }
+
+    // TODO: No need to return boolean if using a singe delete query
+    return deleteAssetCount == removeAssetsDto.assetIds.length;
+  }
+
+  async addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity> {
+    const newRecords: AssetAlbumEntity[] = [];
+
+    for (const assetId of addAssetsDto.assetIds) {
+      const newAssetAlbum = new AssetAlbumEntity();
+      newAssetAlbum.assetId = assetId;
+      newAssetAlbum.albumId = album.id;
+
+      newRecords.push(newAssetAlbum);
+    }
+
+    // Add album thumbnail if not exist.
+    if (!album.albumThumbnailAssetId && newRecords.length > 0) {
+      album.albumThumbnailAssetId = newRecords[0].assetId;
+      await this.albumRepository.save(album);
+    }
+
+    await this.assetAlbumRepository.save([...newRecords]);
+    return this.get(album.id);
+  }
+
+  updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity> {
+    album.albumName = updateAlbumDto.albumName;
+
+    return this.albumRepository.save(album);
+  }
+}

+ 105 - 0
server/apps/immich/src/api-v1/album/album.controller.ts

@@ -0,0 +1,105 @@
+import {
+  Controller,
+  Get,
+  Post,
+  Body,
+  Patch,
+  Param,
+  Delete,
+  UseGuards,
+  ValidationPipe,
+  ParseUUIDPipe,
+  Put,
+  Query,
+} from '@nestjs/common';
+import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe';
+import { AlbumService } from './album.service';
+import { CreateAlbumDto } from './dto/create-album.dto';
+import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
+import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
+import { AddAssetsDto } from './dto/add-assets.dto';
+import { AddUsersDto } from './dto/add-users.dto';
+import { RemoveAssetsDto } from './dto/remove-assets.dto';
+import { UpdateAlbumDto } from './dto/update-album.dto';
+import { GetAlbumsDto } from './dto/get-albums.dto';
+
+// TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe.
+@UseGuards(JwtAuthGuard)
+@Controller('album')
+export class AlbumController {
+  constructor(private readonly albumService: AlbumService) {}
+
+  @Post()
+  async create(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createAlbumDto: CreateAlbumDto) {
+    return this.albumService.create(authUser, createAlbumDto);
+  }
+
+  @Put('/:albumId/users')
+  async addUsers(
+    @GetAuthUser() authUser: AuthUserDto,
+    @Body(ValidationPipe) addUsersDto: AddUsersDto,
+    @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
+  ) {
+    return this.albumService.addUsersToAlbum(authUser, addUsersDto, albumId);
+  }
+
+  @Put('/:albumId/assets')
+  async addAssets(
+    @GetAuthUser() authUser: AuthUserDto,
+    @Body(ValidationPipe) addAssetsDto: AddAssetsDto,
+    @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
+  ) {
+    return this.albumService.addAssetsToAlbum(authUser, addAssetsDto, albumId);
+  }
+
+  @Get()
+  async getAllAlbums(
+    @GetAuthUser() authUser: AuthUserDto,
+    @Query(new ValidationPipe({ transform: true })) query: GetAlbumsDto,
+  ) {
+    return this.albumService.getAllAlbums(authUser, query);
+  }
+
+  @Get('/:albumId')
+  async getAlbumInfo(
+    @GetAuthUser() authUser: AuthUserDto,
+    @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
+  ) {
+    return this.albumService.getAlbumInfo(authUser, albumId);
+  }
+
+  @Delete('/:albumId/assets')
+  async removeAssetFromAlbum(
+    @GetAuthUser() authUser: AuthUserDto,
+    @Body(ValidationPipe) removeAssetsDto: RemoveAssetsDto,
+    @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
+  ) {
+    return this.albumService.removeAssetsFromAlbum(authUser, removeAssetsDto, albumId);
+  }
+
+  @Delete('/:albumId')
+  async deleteAlbum(
+    @GetAuthUser() authUser: AuthUserDto,
+    @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
+  ) {
+    return this.albumService.deleteAlbum(authUser, albumId);
+  }
+
+  @Delete('/:albumId/user/:userId')
+  async removeUserFromAlbum(
+    @GetAuthUser() authUser: AuthUserDto,
+    @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
+    @Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
+  ) {
+    return this.albumService.removeUserFromAlbum(authUser, albumId, userId);
+  }
+
+  @Patch('/:albumId')
+  async updateAlbumInfo(
+    @GetAuthUser() authUser: AuthUserDto,
+    @Body(ValidationPipe) updateAlbumInfoDto: UpdateAlbumDto,
+    @Param('albumId', new ParseUUIDPipe({ version: '4' })) albumId: string,
+  ) {
+    return this.albumService.updateAlbumTitle(authUser, updateAlbumInfoDto, albumId);
+  }
+}

+ 23 - 0
server/apps/immich/src/api-v1/album/album.module.ts

@@ -0,0 +1,23 @@
+import { Module } from '@nestjs/common';
+import { AlbumService } from './album.service';
+import { AlbumController } from './album.controller';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { AssetEntity } from '@app/database/entities/asset.entity';
+import { UserEntity } from '@app/database/entities/user.entity';
+import { AlbumEntity } from '../../../../../libs/database/src/entities/album.entity';
+import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
+import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
+import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
+
+@Module({
+  imports: [TypeOrmModule.forFeature([AssetEntity, UserEntity, AlbumEntity, AssetAlbumEntity, UserAlbumEntity])],
+  controllers: [AlbumController],
+  providers: [
+    AlbumService,
+    {
+      provide: ALBUM_REPOSITORY,
+      useClass: AlbumRepository,
+    },
+  ],
+})
+export class AlbumModule {}

+ 414 - 0
server/apps/immich/src/api-v1/album/album.service.spec.ts

@@ -0,0 +1,414 @@
+import { AlbumService } from './album.service';
+import { IAlbumRepository } from './album-repository';
+import { AuthUserDto } from '../../decorators/auth-user.decorator';
+import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
+import { AlbumEntity } from '@app/database/entities/album.entity';
+import { AlbumResponseDto } from './response-dto/album-response.dto';
+
+describe('Album service', () => {
+  let sut: AlbumService;
+  let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
+  const authUser: AuthUserDto = Object.freeze({
+    id: '1111',
+    email: 'auth@test.com',
+  });
+  const albumId = '0001';
+  const sharedAlbumOwnerId = '2222';
+  const sharedAlbumSharedAlsoWithId = '3333';
+  const ownedAlbumSharedWithId = '4444';
+
+  const _getOwnedAlbum = () => {
+    const albumEntity = new AlbumEntity();
+    albumEntity.ownerId = authUser.id;
+    albumEntity.id = albumId;
+    albumEntity.albumName = 'name';
+    albumEntity.createdAt = 'date';
+    albumEntity.sharedUsers = [];
+    albumEntity.assets = [];
+
+    return albumEntity;
+  };
+
+  const _getOwnedSharedAlbum = () => {
+    const albumEntity = new AlbumEntity();
+    albumEntity.ownerId = authUser.id;
+    albumEntity.id = albumId;
+    albumEntity.albumName = 'name';
+    albumEntity.createdAt = 'date';
+    albumEntity.assets = [];
+    albumEntity.sharedUsers = [
+      {
+        id: '99',
+        albumId,
+        sharedUserId: ownedAlbumSharedWithId,
+        //@ts-expect-error Partial stub
+        albumInfo: {},
+        //@ts-expect-error Partial stub
+        userInfo: {
+          id: ownedAlbumSharedWithId,
+        },
+      },
+    ];
+
+    return albumEntity;
+  };
+
+  const _getSharedWithAuthUserAlbum = () => {
+    const albumEntity = new AlbumEntity();
+    albumEntity.ownerId = sharedAlbumOwnerId;
+    albumEntity.id = albumId;
+    albumEntity.albumName = 'name';
+    albumEntity.createdAt = 'date';
+    albumEntity.assets = [];
+    albumEntity.sharedUsers = [
+      {
+        id: '99',
+        albumId,
+        sharedUserId: authUser.id,
+        //@ts-expect-error Partial stub
+        albumInfo: {},
+        //@ts-expect-error Partial stub
+        userInfo: {
+          id: authUser.id,
+        },
+      },
+      {
+        id: '98',
+        albumId,
+        sharedUserId: sharedAlbumSharedAlsoWithId,
+        //@ts-expect-error Partial stub
+        albumInfo: {},
+        //@ts-expect-error Partial stub
+        userInfo: {
+          id: sharedAlbumSharedAlsoWithId,
+        },
+      },
+    ];
+
+    return albumEntity;
+  };
+
+  const _getNotOwnedNotSharedAlbum = () => {
+    const albumEntity = new AlbumEntity();
+    albumEntity.ownerId = '5555';
+    albumEntity.id = albumId;
+    albumEntity.albumName = 'name';
+    albumEntity.createdAt = 'date';
+    albumEntity.sharedUsers = [];
+    albumEntity.assets = [];
+
+    return albumEntity;
+  };
+
+  beforeAll(() => {
+    albumRepositoryMock = {
+      addAssets: jest.fn(),
+      addSharedUsers: jest.fn(),
+      create: jest.fn(),
+      delete: jest.fn(),
+      get: jest.fn(),
+      getList: jest.fn(),
+      removeAssets: jest.fn(),
+      removeUser: jest.fn(),
+      updateAlbum: jest.fn(),
+    };
+    sut = new AlbumService(albumRepositoryMock);
+  });
+
+  it('creates album', async () => {
+    const albumEntity = _getOwnedAlbum();
+    albumRepositoryMock.create.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+
+    const result = await sut.create(authUser, {
+      albumName: albumEntity.albumName,
+    });
+
+    expect(result.id).toEqual(albumEntity.id);
+    expect(result.albumName).toEqual(albumEntity.albumName);
+  });
+
+  it('gets list of albums for auth user', async () => {
+    const ownedAlbum = _getOwnedAlbum();
+    const ownedSharedAlbum = _getOwnedSharedAlbum();
+    const sharedWithMeAlbum = _getSharedWithAuthUserAlbum();
+    const albums: AlbumEntity[] = [ownedAlbum, ownedSharedAlbum, sharedWithMeAlbum];
+
+    albumRepositoryMock.getList.mockImplementation(() => Promise.resolve(albums));
+
+    const result = await sut.getAllAlbums(authUser, {});
+    expect(result).toHaveLength(3);
+    expect(result[0].id).toEqual(ownedAlbum.id);
+    expect(result[1].id).toEqual(ownedSharedAlbum.id);
+    expect(result[2].id).toEqual(sharedWithMeAlbum.id);
+  });
+
+  it('gets an owned album', async () => {
+    const ownerId = authUser.id;
+    const albumId = '0001';
+
+    const albumEntity = _getOwnedAlbum();
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+
+    const expectedResult: AlbumResponseDto = {
+      albumName: 'name',
+      albumThumbnailAssetId: undefined,
+      createdAt: 'date',
+      id: '0001',
+      ownerId,
+      shared: false,
+      assets: [],
+      sharedUsers: [],
+    };
+    await expect(sut.getAlbumInfo(authUser, albumId)).resolves.toEqual(expectedResult);
+  });
+
+  it('gets a shared album', async () => {
+    const albumEntity = _getSharedWithAuthUserAlbum();
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+
+    const result = await sut.getAlbumInfo(authUser, albumId);
+    expect(result.id).toEqual(albumId);
+    expect(result.ownerId).toEqual(sharedAlbumOwnerId);
+    expect(result.shared).toEqual(true);
+    expect(result.sharedUsers).toHaveLength(2);
+    expect(result.sharedUsers[0].id).toEqual(authUser.id);
+    expect(result.sharedUsers[1].id).toEqual(sharedAlbumSharedAlsoWithId);
+  });
+
+  it('prevents retrieving an album that is not owned or shared', async () => {
+    const albumEntity = _getNotOwnedNotSharedAlbum();
+    const albumId = albumEntity.id;
+
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+    await expect(sut.getAlbumInfo(authUser, albumId)).rejects.toBeInstanceOf(ForbiddenException);
+  });
+
+  it('throws a not found exception if the album is not found', async () => {
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve(undefined));
+    await expect(sut.getAlbumInfo(authUser, '0002')).rejects.toBeInstanceOf(NotFoundException);
+  });
+
+  it('deletes an owned album', async () => {
+    const albumEntity = _getOwnedAlbum();
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+    albumRepositoryMock.delete.mockImplementation(() => Promise.resolve());
+    await sut.deleteAlbum(authUser, albumId);
+    expect(albumRepositoryMock.delete).toHaveBeenCalledTimes(1);
+    expect(albumRepositoryMock.delete).toHaveBeenCalledWith(albumEntity);
+  });
+
+  it('prevents deleting a shared album (shared with auth user)', async () => {
+    const albumEntity = _getSharedWithAuthUserAlbum();
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+    await expect(sut.deleteAlbum(authUser, albumId)).rejects.toBeInstanceOf(ForbiddenException);
+  });
+
+  it('removes a shared user from an owned album', async () => {
+    const albumEntity = _getOwnedSharedAlbum();
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+    albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve());
+    await expect(sut.removeUserFromAlbum(authUser, albumEntity.id, ownedAlbumSharedWithId)).resolves.toBeUndefined();
+    expect(albumRepositoryMock.removeUser).toHaveBeenCalledTimes(1);
+    expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, ownedAlbumSharedWithId);
+  });
+
+  it('prevents removing a shared user from a not owned album (shared with auth user)', async () => {
+    const albumEntity = _getSharedWithAuthUserAlbum();
+    const albumId = albumEntity.id;
+    const userIdToRemove = sharedAlbumSharedAlsoWithId;
+
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+
+    await expect(sut.removeUserFromAlbum(authUser, albumId, userIdToRemove)).rejects.toBeInstanceOf(ForbiddenException);
+    expect(albumRepositoryMock.removeUser).not.toHaveBeenCalled();
+  });
+
+  it('removes itself from a shared album', async () => {
+    const albumEntity = _getSharedWithAuthUserAlbum();
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+    albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve());
+
+    await sut.removeUserFromAlbum(authUser, albumEntity.id, authUser.id);
+    expect(albumRepositoryMock.removeUser).toHaveReturnedTimes(1);
+    expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, authUser.id);
+  });
+
+  it('removes itself from a shared album using "me" as id', async () => {
+    const albumEntity = _getSharedWithAuthUserAlbum();
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+    albumRepositoryMock.removeUser.mockImplementation(() => Promise.resolve());
+
+    await sut.removeUserFromAlbum(authUser, albumEntity.id, 'me');
+    expect(albumRepositoryMock.removeUser).toHaveReturnedTimes(1);
+    expect(albumRepositoryMock.removeUser).toHaveBeenCalledWith(albumEntity, authUser.id);
+  });
+
+  it('prevents removing itself from a owned album', async () => {
+    const albumEntity = _getOwnedAlbum();
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+
+    await expect(sut.removeUserFromAlbum(authUser, albumEntity.id, authUser.id)).rejects.toBeInstanceOf(
+      BadRequestException,
+    );
+  });
+
+  it('updates a owned album', async () => {
+    const albumEntity = _getOwnedAlbum();
+    const albumId = albumEntity.id;
+    const updatedAlbumName = 'new album name';
+
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+    albumRepositoryMock.updateAlbum.mockImplementation(() =>
+      Promise.resolve<AlbumEntity>({ ...albumEntity, albumName: updatedAlbumName }),
+    );
+
+    const result = await sut.updateAlbumTitle(
+      authUser,
+      {
+        albumName: updatedAlbumName,
+        ownerId: 'this is not used and will be removed',
+      },
+      albumId,
+    );
+
+    expect(result.id).toEqual(albumId);
+    expect(result.albumName).toEqual(updatedAlbumName);
+    expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledTimes(1);
+    expect(albumRepositoryMock.updateAlbum).toHaveBeenCalledWith(albumEntity, {
+      albumName: updatedAlbumName,
+      ownerId: 'this is not used and will be removed',
+    });
+  });
+
+  it('prevents updating a not owned album (shared with auth user)', async () => {
+    const albumEntity = _getSharedWithAuthUserAlbum();
+    const albumId = albumEntity.id;
+
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+
+    await expect(
+      sut.updateAlbumTitle(
+        authUser,
+        {
+          albumName: 'new album name',
+          ownerId: 'this is not used and will be removed',
+        },
+        albumId,
+      ),
+    ).rejects.toBeInstanceOf(ForbiddenException);
+  });
+
+  it('adds assets to owned album', async () => {
+    const albumEntity = _getOwnedAlbum();
+    const albumId = albumEntity.id;
+
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+    albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+
+    const result = await sut.addAssetsToAlbum(
+      authUser,
+      {
+        assetIds: ['1'],
+      },
+      albumId,
+    );
+
+    // TODO: stub and expect album rendered
+    expect(result.id).toEqual(albumId);
+  });
+
+  it('adds assets to shared album (shared with auth user)', async () => {
+    const albumEntity = _getSharedWithAuthUserAlbum();
+    const albumId = albumEntity.id;
+
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+    albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+
+    const result = await sut.addAssetsToAlbum(
+      authUser,
+      {
+        assetIds: ['1'],
+      },
+      albumId,
+    );
+
+    // TODO: stub and expect album rendered
+    expect(result.id).toEqual(albumId);
+  });
+
+  it('prevents adding assets to a not owned / shared album', async () => {
+    const albumEntity = _getNotOwnedNotSharedAlbum();
+    const albumId = albumEntity.id;
+
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+    albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+
+    expect(
+      sut.addAssetsToAlbum(
+        authUser,
+        {
+          assetIds: ['1'],
+        },
+        albumId,
+      ),
+    ).rejects.toBeInstanceOf(ForbiddenException);
+  });
+
+  it('removes assets from owned album', async () => {
+    const albumEntity = _getOwnedAlbum();
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+    albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(true));
+
+    await expect(
+      sut.removeAssetsFromAlbum(
+        authUser,
+        {
+          assetIds: ['1'],
+        },
+        albumEntity.id,
+      ),
+    ).resolves.toBeUndefined();
+    expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
+    expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
+      assetIds: ['1'],
+    });
+  });
+
+  it('removes assets from shared album (shared with auth user)', async () => {
+    const albumEntity = _getOwnedSharedAlbum();
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+    albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(true));
+
+    await expect(
+      sut.removeAssetsFromAlbum(
+        authUser,
+        {
+          assetIds: ['1'],
+        },
+        albumEntity.id,
+      ),
+    ).resolves.toBeUndefined();
+    expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
+    expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
+      assetIds: ['1'],
+    });
+  });
+
+  it('prevents removing assets from a not owned / shared album', async () => {
+    const albumEntity = _getNotOwnedNotSharedAlbum();
+    const albumId = albumEntity.id;
+
+    albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+    albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
+
+    expect(
+      sut.removeAssetsFromAlbum(
+        authUser,
+        {
+          assetIds: ['1'],
+        },
+        albumId,
+      ),
+    ).rejects.toBeInstanceOf(ForbiddenException);
+  });
+});

+ 113 - 0
server/apps/immich/src/api-v1/album/album.service.ts

@@ -0,0 +1,113 @@
+import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
+import { AuthUserDto } from '../../decorators/auth-user.decorator';
+import { AddAssetsDto } from './dto/add-assets.dto';
+import { CreateAlbumDto } from './dto/create-album.dto';
+import { AlbumEntity } from '../../../../../libs/database/src/entities/album.entity';
+import { AddUsersDto } from './dto/add-users.dto';
+import { RemoveAssetsDto } from './dto/remove-assets.dto';
+import { UpdateAlbumDto } from './dto/update-album.dto';
+import { GetAlbumsDto } from './dto/get-albums.dto';
+import { AlbumResponseDto, mapAlbum } from './response-dto/album-response.dto';
+import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
+
+@Injectable()
+export class AlbumService {
+  constructor(@Inject(ALBUM_REPOSITORY) private _albumRepository: IAlbumRepository) {}
+
+  private async _getAlbum({
+    authUser,
+    albumId,
+    validateIsOwner = true,
+  }: {
+    authUser: AuthUserDto;
+    albumId: string;
+    validateIsOwner?: boolean;
+  }): Promise<AlbumEntity> {
+    const album = await this._albumRepository.get(albumId);
+    if (!album) {
+      throw new NotFoundException('Album Not Found');
+    }
+    const isOwner = album.ownerId == authUser.id;
+
+    if (validateIsOwner && !isOwner) {
+      throw new ForbiddenException('Unauthorized Album Access');
+    } else if (!isOwner && !album.sharedUsers.some((user) => user.sharedUserId == authUser.id)) {
+      throw new ForbiddenException('Unauthorized Album Access');
+    }
+    return album;
+  }
+
+  async create(authUser: AuthUserDto, createAlbumDto: CreateAlbumDto): Promise<AlbumResponseDto> {
+    const albumEntity = await this._albumRepository.create(authUser.id, createAlbumDto);
+    return mapAlbum(albumEntity);
+  }
+
+  /**
+   * Get all shared album, including owned and shared one.
+   * @param authUser AuthUserDto
+   * @returns All Shared Album And Its Members
+   */
+  async getAllAlbums(authUser: AuthUserDto, getAlbumsDto: GetAlbumsDto): Promise<AlbumResponseDto[]> {
+    const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto);
+    return albums.map((album) => mapAlbum(album));
+  }
+
+  async getAlbumInfo(authUser: AuthUserDto, albumId: string): Promise<AlbumResponseDto> {
+    const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
+    return mapAlbum(album);
+  }
+
+  async addUsersToAlbum(authUser: AuthUserDto, addUsersDto: AddUsersDto, albumId: string): Promise<AlbumResponseDto> {
+    const album = await this._getAlbum({ authUser, albumId });
+    const updatedAlbum = await this._albumRepository.addSharedUsers(album, addUsersDto);
+    return mapAlbum(updatedAlbum);
+  }
+
+  async deleteAlbum(authUser: AuthUserDto, albumId: string): Promise<void> {
+    const album = await this._getAlbum({ authUser, albumId });
+    await this._albumRepository.delete(album);
+  }
+
+  async removeUserFromAlbum(authUser: AuthUserDto, albumId: string, userId: string | 'me'): Promise<void> {
+    const sharedUserId = userId == 'me' ? authUser.id : userId;
+    const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
+    if (album.ownerId != authUser.id && authUser.id != sharedUserId) {
+      throw new ForbiddenException('Cannot remove a user from a album that is not owned');
+    }
+    if (album.ownerId == sharedUserId) {
+      throw new BadRequestException('The owner of the album cannot be removed');
+    }
+    await this._albumRepository.removeUser(album, sharedUserId);
+  }
+
+  // async removeUsersFromAlbum() {}
+
+  async removeAssetsFromAlbum(authUser: AuthUserDto, removeAssetsDto: RemoveAssetsDto, albumId: string): Promise<void> {
+    const album = await this._getAlbum({ authUser, albumId });
+    await this._albumRepository.removeAssets(album, removeAssetsDto);
+  }
+
+  async addAssetsToAlbum(
+    authUser: AuthUserDto,
+    addAssetsDto: AddAssetsDto,
+    albumId: string,
+  ): Promise<AlbumResponseDto> {
+    const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
+    const updatedAlbum = await this._albumRepository.addAssets(album, addAssetsDto);
+    return mapAlbum(updatedAlbum);
+  }
+
+  async updateAlbumTitle(
+    authUser: AuthUserDto,
+    updateAlbumDto: UpdateAlbumDto,
+    albumId: string,
+  ): Promise<AlbumResponseDto> {
+    // TODO: this should not come from request DTO. To be removed from here and DTO
+    // if (authUser.id != updateAlbumDto.ownerId) {
+    //   throw new BadRequestException('Unauthorized to change album info');
+    // }
+    const album = await this._getAlbum({ authUser, albumId });
+    const updatedAlbum = await this._albumRepository.updateAlbum(album, updateAlbumDto);
+    return mapAlbum(updatedAlbum);
+  }
+}

+ 0 - 4
server/apps/immich/src/api-v1/sharing/dto/add-assets.dto.ts → server/apps/immich/src/api-v1/album/dto/add-assets.dto.ts

@@ -1,10 +1,6 @@
 import { IsNotEmpty } from 'class-validator';
-import { AssetEntity } from '@app/database/entities/asset.entity';
 
 export class AddAssetsDto {
-  @IsNotEmpty()
-  albumId: string;
-
   @IsNotEmpty()
   assetIds: string[];
 }

+ 0 - 3
server/apps/immich/src/api-v1/sharing/dto/add-users.dto.ts → server/apps/immich/src/api-v1/album/dto/add-users.dto.ts

@@ -1,9 +1,6 @@
 import { IsNotEmpty } from 'class-validator';
 
 export class AddUsersDto {
-  @IsNotEmpty()
-  albumId: string;
-
   @IsNotEmpty()
   sharedUserIds: string[];
 }

+ 12 - 0
server/apps/immich/src/api-v1/album/dto/create-album.dto.ts

@@ -0,0 +1,12 @@
+import { IsNotEmpty, IsOptional } from 'class-validator';
+
+export class CreateAlbumDto {
+  @IsNotEmpty()
+  albumName: string;
+
+  @IsOptional()
+  sharedWithUserIds?: string[];
+
+  @IsOptional()
+  assetIds?: string[];
+}

+ 21 - 0
server/apps/immich/src/api-v1/album/dto/get-albums.dto.ts

@@ -0,0 +1,21 @@
+import { Transform } from 'class-transformer';
+import { IsOptional, IsBoolean } from 'class-validator';
+
+export class GetAlbumsDto {
+  @IsOptional()
+  @IsBoolean()
+  @Transform(({ value }) => {
+    if (value == 'true') {
+      return true;
+    } else if (value == 'false') {
+      return false;
+    }
+    return value;
+  })
+  /**
+   * true: only shared albums
+   * false: only non-shared own albums
+   * undefined: shared and owned albums
+   */
+  shared?: boolean;
+}

+ 0 - 3
server/apps/immich/src/api-v1/sharing/dto/remove-assets.dto.ts → server/apps/immich/src/api-v1/album/dto/remove-assets.dto.ts

@@ -1,9 +1,6 @@
 import { IsNotEmpty } from 'class-validator';
 
 export class RemoveAssetsDto {
-  @IsNotEmpty()
-  albumId: string;
-
   @IsNotEmpty()
   assetIds: string[];
 }

+ 1 - 4
server/apps/immich/src/api-v1/sharing/dto/update-shared-album.dto.ts → server/apps/immich/src/api-v1/album/dto/update-album.dto.ts

@@ -1,9 +1,6 @@
 import { IsNotEmpty } from 'class-validator';
 
-export class UpdateShareAlbumDto {
-  @IsNotEmpty()
-  albumId: string;
-
+export class UpdateAlbumDto {
   @IsNotEmpty()
   albumName: string;
 

+ 28 - 0
server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts

@@ -0,0 +1,28 @@
+import { AlbumEntity } from '../../../../../../libs/database/src/entities/album.entity';
+import { User, mapUser } from '../../user/response-dto/user';
+import { AssetResponseDto, mapAsset } from '../../asset/response-dto/asset-response.dto';
+
+export interface AlbumResponseDto {
+  id: string;
+  ownerId: string;
+  albumName: string;
+  createdAt: string;
+  albumThumbnailAssetId: string | null;
+  shared: boolean;
+  sharedUsers: User[];
+  assets: AssetResponseDto[];
+}
+
+export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
+  const sharedUsers = entity.sharedUsers?.map((userAlbum) => mapUser(userAlbum.userInfo)) || [];
+  return {
+    albumName: entity.albumName,
+    albumThumbnailAssetId: entity.albumThumbnailAssetId,
+    createdAt: entity.createdAt,
+    id: entity.id,
+    ownerId: entity.ownerId,
+    sharedUsers,
+    shared: sharedUsers.length > 0,
+    assets: entity.assets?.map((assetAlbum) => mapAsset(assetAlbum.assetInfo)) || [],
+  };
+}

+ 39 - 0
server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts

@@ -0,0 +1,39 @@
+import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
+import { ExifResponseDto, mapExif } from './exif-response.dto';
+import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
+
+export interface AssetResponseDto {
+  id: string;
+  deviceAssetId: string;
+  ownerId: string;
+  deviceId: string;
+  type: AssetType;
+  originalPath: string;
+  resizePath: string | null;
+  createdAt: string;
+  modifiedAt: string;
+  isFavorite: boolean;
+  mimeType: string | null;
+  duration: string | null;
+  exifInfo?: ExifResponseDto;
+  smartInfo?: SmartInfoResponseDto;
+}
+
+export function mapAsset(entity: AssetEntity): AssetResponseDto {
+  return {
+    id: entity.id,
+    deviceAssetId: entity.deviceAssetId,
+    ownerId: entity.userId,
+    deviceId: entity.deviceId,
+    type: entity.type,
+    originalPath: entity.originalPath,
+    resizePath: entity.resizePath,
+    createdAt: entity.createdAt,
+    modifiedAt: entity.modifiedAt,
+    isFavorite: entity.isFavorite,
+    mimeType: entity.mimeType,
+    duration: entity.duration,
+    exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
+    smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
+  };
+}

+ 49 - 0
server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts

@@ -0,0 +1,49 @@
+import { ExifEntity } from '@app/database/entities/exif.entity';
+
+export interface ExifResponseDto {
+  id: string;
+  make: string | null;
+  model: string | null;
+  imageName: string | null;
+  exifImageWidth: number | null;
+  exifImageHeight: number | null;
+  fileSizeInByte: number | null;
+  orientation: string | null;
+  dateTimeOriginal: Date | null;
+  modifyDate: Date | null;
+  lensModel: string | null;
+  fNumber: number | null;
+  focalLength: number | null;
+  iso: number | null;
+  exposureTime: number | null;
+  latitude: number | null;
+  longitude: number | null;
+  city: string | null;
+  state: string | null;
+  country: string | null;
+}
+
+export function mapExif(entity: ExifEntity): ExifResponseDto {
+  return {
+    id: entity.id,
+    make: entity.make,
+    model: entity.model,
+    imageName: entity.imageName,
+    exifImageWidth: entity.exifImageWidth,
+    exifImageHeight: entity.exifImageHeight,
+    fileSizeInByte: entity.fileSizeInByte,
+    orientation: entity.orientation,
+    dateTimeOriginal: entity.dateTimeOriginal,
+    modifyDate: entity.modifyDate,
+    lensModel: entity.lensModel,
+    fNumber: entity.fNumber,
+    focalLength: entity.focalLength,
+    iso: entity.iso,
+    exposureTime: entity.exposureTime,
+    latitude: entity.latitude,
+    longitude: entity.longitude,
+    city: entity.city,
+    state: entity.state,
+    country: entity.country,
+  };
+}

+ 15 - 0
server/apps/immich/src/api-v1/asset/response-dto/smart-info-response.dto.ts

@@ -0,0 +1,15 @@
+import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
+
+export interface SmartInfoResponseDto {
+  id: string;
+  tags: string[] | null;
+  objects: string[] | null;
+}
+
+export function mapSmartInfo(entity: SmartInfoEntity): SmartInfoResponseDto {
+  return {
+    id: entity.id,
+    tags: entity.tags,
+    objects: entity.objects,
+  };
+}

+ 0 - 13
server/apps/immich/src/api-v1/sharing/dto/create-shared-album.dto.ts

@@ -1,13 +0,0 @@
-import { IsNotEmpty, IsOptional } from 'class-validator';
-import { AssetEntity } from '@app/database/entities/asset.entity';
-
-export class CreateSharedAlbumDto {
-  @IsNotEmpty()
-  albumName: string;
-
-  @IsNotEmpty()
-  sharedWithUserIds: string[];
-
-  @IsOptional()
-  assetIds: string[];
-}

+ 0 - 61
server/apps/immich/src/api-v1/sharing/sharing.controller.ts

@@ -1,61 +0,0 @@
-import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, ValidationPipe, Query } from '@nestjs/common';
-import { SharingService } from './sharing.service';
-import { CreateSharedAlbumDto } from './dto/create-shared-album.dto';
-import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
-import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
-import { AddAssetsDto } from './dto/add-assets.dto';
-import { AddUsersDto } from './dto/add-users.dto';
-import { RemoveAssetsDto } from './dto/remove-assets.dto';
-import { UpdateShareAlbumDto } from './dto/update-shared-album.dto';
-
-@UseGuards(JwtAuthGuard)
-@Controller('shared')
-export class SharingController {
-  constructor(private readonly sharingService: SharingService) {}
-
-  @Post('/createAlbum')
-  async create(@GetAuthUser() authUser, @Body(ValidationPipe) createSharedAlbumDto: CreateSharedAlbumDto) {
-    return await this.sharingService.create(authUser, createSharedAlbumDto);
-  }
-
-  @Post('/addUsers')
-  async addUsers(@Body(ValidationPipe) addUsersDto: AddUsersDto) {
-    return await this.sharingService.addUsersToAlbum(addUsersDto);
-  }
-
-  @Post('/addAssets')
-  async addAssets(@Body(ValidationPipe) addAssetsDto: AddAssetsDto) {
-    return await this.sharingService.addAssetsToAlbum(addAssetsDto);
-  }
-
-  @Get('/allSharedAlbums')
-  async getAllSharedAlbums(@GetAuthUser() authUser) {
-    return await this.sharingService.getAllSharedAlbums(authUser);
-  }
-
-  @Get('/:albumId')
-  async getAlbumInfo(@GetAuthUser() authUser, @Param('albumId') albumId: string) {
-    return await this.sharingService.getAlbumInfo(authUser, albumId);
-  }
-
-  @Delete('/removeAssets')
-  async removeAssetFromAlbum(@GetAuthUser() authUser, @Body(ValidationPipe) removeAssetsDto: RemoveAssetsDto) {
-    console.log('removeAssets');
-    return await this.sharingService.removeAssetsFromAlbum(authUser, removeAssetsDto);
-  }
-
-  @Delete('/:albumId')
-  async deleteAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) {
-    return await this.sharingService.deleteAlbum(authUser, albumId);
-  }
-
-  @Delete('/leaveAlbum/:albumId')
-  async leaveAlbum(@GetAuthUser() authUser, @Param('albumId') albumId: string) {
-    return await this.sharingService.leaveAlbum(authUser, albumId);
-  }
-
-  @Patch('/updateInfo')
-  async updateAlbumInfo(@GetAuthUser() authUser, @Body(ValidationPipe) updateAlbumInfoDto: UpdateShareAlbumDto) {
-    return await this.sharingService.updateAlbumTitle(authUser, updateAlbumInfoDto);
-  }
-}

+ 0 - 24
server/apps/immich/src/api-v1/sharing/sharing.module.ts

@@ -1,24 +0,0 @@
-import { Module } from '@nestjs/common';
-import { SharingService } from './sharing.service';
-import { SharingController } from './sharing.controller';
-import { TypeOrmModule } from '@nestjs/typeorm';
-import { AssetEntity } from '@app/database/entities/asset.entity';
-import { UserEntity } from '@app/database/entities/user.entity';
-import { AssetSharedAlbumEntity } from '@app/database/entities/asset-shared-album.entity';
-import { SharedAlbumEntity } from '@app/database/entities/shared-album.entity';
-import { UserSharedAlbumEntity } from '@app/database/entities/user-shared-album.entity';
-
-@Module({
-  imports: [
-    TypeOrmModule.forFeature([
-      AssetEntity,
-      UserEntity,
-      SharedAlbumEntity,
-      AssetSharedAlbumEntity,
-      UserSharedAlbumEntity,
-    ]),
-  ],
-  controllers: [SharingController],
-  providers: [SharingService],
-})
-export class SharingModule {}

+ 0 - 199
server/apps/immich/src/api-v1/sharing/sharing.service.ts

@@ -1,199 +0,0 @@
-import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
-import { InjectRepository } from '@nestjs/typeorm';
-import { getConnection, Repository } from 'typeorm';
-import { AuthUserDto } from '../../decorators/auth-user.decorator';
-import { AssetEntity } from '@app/database/entities/asset.entity';
-import { UserEntity } from '@app/database/entities/user.entity';
-import { AddAssetsDto } from './dto/add-assets.dto';
-import { CreateSharedAlbumDto } from './dto/create-shared-album.dto';
-import { AssetSharedAlbumEntity } from '@app/database/entities/asset-shared-album.entity';
-import { SharedAlbumEntity } from '@app/database/entities/shared-album.entity';
-import { UserSharedAlbumEntity } from '@app/database/entities/user-shared-album.entity';
-import _ from 'lodash';
-import { AddUsersDto } from './dto/add-users.dto';
-import { RemoveAssetsDto } from './dto/remove-assets.dto';
-import { UpdateShareAlbumDto } from './dto/update-shared-album.dto';
-
-@Injectable()
-export class SharingService {
-  constructor(
-    @InjectRepository(AssetEntity)
-    private assetRepository: Repository<AssetEntity>,
-
-    @InjectRepository(UserEntity)
-    private userRepository: Repository<UserEntity>,
-
-    @InjectRepository(SharedAlbumEntity)
-    private sharedAlbumRepository: Repository<SharedAlbumEntity>,
-
-    @InjectRepository(AssetSharedAlbumEntity)
-    private assetSharedAlbumRepository: Repository<AssetSharedAlbumEntity>,
-
-    @InjectRepository(UserSharedAlbumEntity)
-    private userSharedAlbumRepository: Repository<UserSharedAlbumEntity>,
-  ) {}
-
-  async create(authUser: AuthUserDto, createSharedAlbumDto: CreateSharedAlbumDto) {
-    return await getConnection().transaction(async (transactionalEntityManager) => {
-      // Create album entity
-      const newSharedAlbum = new SharedAlbumEntity();
-      newSharedAlbum.ownerId = authUser.id;
-      newSharedAlbum.albumName = createSharedAlbumDto.albumName;
-
-      const sharedAlbum = await transactionalEntityManager.save(newSharedAlbum);
-
-      // Add shared users
-      for (const sharedUserId of createSharedAlbumDto.sharedWithUserIds) {
-        const newSharedUser = new UserSharedAlbumEntity();
-        newSharedUser.albumId = sharedAlbum.id;
-        newSharedUser.sharedUserId = sharedUserId;
-
-        await transactionalEntityManager.save(newSharedUser);
-      }
-
-      // Add shared assets
-      const newRecords: AssetSharedAlbumEntity[] = [];
-
-      for (const assetId of createSharedAlbumDto.assetIds) {
-        const newAssetSharedAlbum = new AssetSharedAlbumEntity();
-        newAssetSharedAlbum.assetId = assetId;
-        newAssetSharedAlbum.albumId = sharedAlbum.id;
-
-        newRecords.push(newAssetSharedAlbum);
-      }
-
-      if (!sharedAlbum.albumThumbnailAssetId && newRecords.length > 0) {
-        sharedAlbum.albumThumbnailAssetId = newRecords[0].assetId;
-        await transactionalEntityManager.save(sharedAlbum);
-      }
-
-      await transactionalEntityManager.save([...newRecords]);
-
-      return sharedAlbum;
-    });
-  }
-
-  /**
-   * Get all shared album, including owned and shared one.
-   * @param authUser AuthUserDto
-   * @returns All Shared Album And Its Members
-   */
-  async getAllSharedAlbums(authUser: AuthUserDto) {
-    const ownedAlbums = await this.sharedAlbumRepository.find({
-      where: { ownerId: authUser.id },
-      relations: ['sharedUsers', 'sharedUsers.userInfo'],
-    });
-
-    const isSharedWithAlbums = await this.userSharedAlbumRepository.find({
-      where: {
-        sharedUserId: authUser.id,
-      },
-      relations: ['albumInfo', 'albumInfo.sharedUsers', 'albumInfo.sharedUsers.userInfo'],
-      select: ['albumInfo'],
-    });
-
-    return [...ownedAlbums, ...isSharedWithAlbums.map((o) => o.albumInfo)].sort(
-      (a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf(),
-    );
-  }
-
-  async getAlbumInfo(authUser: AuthUserDto, albumId: string) {
-    const albumOwner = await this.sharedAlbumRepository.findOne({ where: { ownerId: authUser.id } });
-    const personShared = await this.userSharedAlbumRepository.findOne({
-      where: { albumId: albumId, sharedUserId: authUser.id },
-    });
-
-    if (!(albumOwner || personShared)) {
-      throw new UnauthorizedException('Unauthorized Album Access');
-    }
-
-    const albumInfo = await this.sharedAlbumRepository.findOne({
-      where: { id: albumId },
-      relations: ['sharedUsers', 'sharedUsers.userInfo', 'sharedAssets', 'sharedAssets.assetInfo'],
-    });
-
-    if (!albumInfo) {
-      throw new NotFoundException('Album Not Found');
-    }
-    const sortedSharedAsset = albumInfo.sharedAssets.sort(
-      (a, b) => new Date(a.assetInfo.createdAt).valueOf() - new Date(b.assetInfo.createdAt).valueOf(),
-    );
-
-    albumInfo.sharedAssets = sortedSharedAsset;
-
-    return albumInfo;
-  }
-
-  async addUsersToAlbum(addUsersDto: AddUsersDto) {
-    const newRecords: UserSharedAlbumEntity[] = [];
-
-    for (const sharedUserId of addUsersDto.sharedUserIds) {
-      const newEntity = new UserSharedAlbumEntity();
-      newEntity.albumId = addUsersDto.albumId;
-      newEntity.sharedUserId = sharedUserId;
-
-      newRecords.push(newEntity);
-    }
-
-    return await this.userSharedAlbumRepository.save([...newRecords]);
-  }
-
-  async deleteAlbum(authUser: AuthUserDto, albumId: string) {
-    return await this.sharedAlbumRepository.delete({ id: albumId, ownerId: authUser.id });
-  }
-
-  async leaveAlbum(authUser: AuthUserDto, albumId: string) {
-    return await this.userSharedAlbumRepository.delete({ albumId: albumId, sharedUserId: authUser.id });
-  }
-
-  async removeUsersFromAlbum() {}
-
-  async removeAssetsFromAlbum(authUser: AuthUserDto, removeAssetsDto: RemoveAssetsDto) {
-    let deleteAssetCount = 0;
-    const album = await this.sharedAlbumRepository.findOne({ id: removeAssetsDto.albumId });
-
-    if (album.ownerId != authUser.id) {
-      throw new BadRequestException("You don't have permission to remove assets in this album");
-    }
-
-    for (const assetId of removeAssetsDto.assetIds) {
-      const res = await this.assetSharedAlbumRepository.delete({ albumId: removeAssetsDto.albumId, assetId: assetId });
-      if (res.affected == 1) deleteAssetCount++;
-    }
-
-    return deleteAssetCount == removeAssetsDto.assetIds.length;
-  }
-
-  async addAssetsToAlbum(addAssetsDto: AddAssetsDto) {
-    const newRecords: AssetSharedAlbumEntity[] = [];
-
-    for (const assetId of addAssetsDto.assetIds) {
-      const newAssetSharedAlbum = new AssetSharedAlbumEntity();
-      newAssetSharedAlbum.assetId = assetId;
-      newAssetSharedAlbum.albumId = addAssetsDto.albumId;
-
-      newRecords.push(newAssetSharedAlbum);
-    }
-
-    // Add album thumbnail if not exist.
-    const album = await this.sharedAlbumRepository.findOne({ id: addAssetsDto.albumId });
-
-    if (!album.albumThumbnailAssetId && newRecords.length > 0) {
-      album.albumThumbnailAssetId = newRecords[0].assetId;
-      await this.sharedAlbumRepository.save(album);
-    }
-
-    return await this.assetSharedAlbumRepository.save([...newRecords]);
-  }
-
-  async updateAlbumTitle(authUser: AuthUserDto, updateShareAlbumDto: UpdateShareAlbumDto) {
-    if (authUser.id != updateShareAlbumDto.ownerId) {
-      throw new BadRequestException('Unauthorized to change album info');
-    }
-
-    const sharedAlbum = await this.sharedAlbumRepository.findOne({ where: { id: updateShareAlbumDto.albumId } });
-    sharedAlbum.albumName = updateShareAlbumDto.albumName;
-
-    return await this.sharedAlbumRepository.save(sharedAlbum);
-  }
-}

+ 11 - 0
server/apps/immich/src/api-v1/validation/parse-me-uuid-pipe.ts

@@ -0,0 +1,11 @@
+import { ParseUUIDPipe, Injectable, ArgumentMetadata } from '@nestjs/common';
+
+@Injectable()
+export class ParseMeUUIDPipe extends ParseUUIDPipe {
+  async transform(value: string, metadata: ArgumentMetadata) {
+    if (value == 'me') {
+      return value;
+    }
+    return super.transform(value, metadata);
+  }
+}

+ 2 - 2
server/apps/immich/src/app.module.ts

@@ -11,7 +11,7 @@ import { BullModule } from '@nestjs/bull';
 import { ServerInfoModule } from './api-v1/server-info/server-info.module';
 import { BackgroundTaskModule } from './modules/background-task/background-task.module';
 import { CommunicationModule } from './api-v1/communication/communication.module';
-import { SharingModule } from './api-v1/sharing/sharing.module';
+import { AlbumModule } from './api-v1/album/album.module';
 import { AppController } from './app.controller';
 import { ScheduleModule } from '@nestjs/schedule';
 import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
@@ -47,7 +47,7 @@ import { DatabaseModule } from '@app/database';
 
     CommunicationModule,
 
-    SharingModule,
+    AlbumModule,
 
     ScheduleModule.forRoot(),
 

+ 161 - 0
server/apps/immich/test/album.e2e-spec.ts

@@ -0,0 +1,161 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { INestApplication } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import request from 'supertest';
+import { clearDb, getAuthUser, authCustom } from './test-utils';
+import { databaseConfig } from '@app/database/config/database.config';
+import { AlbumModule } from '../src/api-v1/album/album.module';
+import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto';
+import { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module';
+import { AuthUserDto } from '../src/decorators/auth-user.decorator';
+import { UserService } from '../src/api-v1/user/user.service';
+import { UserModule } from '../src/api-v1/user/user.module';
+
+function _createAlbum(app: INestApplication, data: CreateAlbumDto) {
+  return request(app.getHttpServer()).post('/album').send(data);
+}
+
+describe('Album', () => {
+  let app: INestApplication;
+
+  afterAll(async () => {
+    await clearDb();
+    await app.close();
+  });
+
+  describe('without auth', () => {
+    beforeAll(async () => {
+      const moduleFixture: TestingModule = await Test.createTestingModule({
+        imports: [AlbumModule, ImmichJwtModule, TypeOrmModule.forRoot(databaseConfig)],
+      }).compile();
+
+      app = moduleFixture.createNestApplication();
+      await app.init();
+    });
+
+    afterAll(async () => {
+      await app.close();
+    });
+
+    it('prevents fetching albums if not auth', async () => {
+      const { status } = await request(app.getHttpServer()).get('/album');
+      expect(status).toEqual(401);
+    });
+  });
+
+  describe('with auth', () => {
+    let authUser: AuthUserDto;
+    let userService: UserService;
+
+    beforeAll(async () => {
+      const builder = Test.createTestingModule({
+        imports: [AlbumModule, UserModule, TypeOrmModule.forRoot(databaseConfig)],
+      });
+      authUser = getAuthUser(); // set default auth user
+      const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();
+
+      app = moduleFixture.createNestApplication();
+      userService = app.get(UserService);
+      await app.init();
+    });
+
+    describe('with empty DB', () => {
+      afterEach(async () => {
+        await clearDb();
+      });
+
+      it('creates an album', async () => {
+        const data: CreateAlbumDto = {
+          albumName: 'first albbum',
+        };
+        const { status, body } = await _createAlbum(app, data);
+        expect(status).toEqual(201);
+        expect(body).toEqual(
+          expect.objectContaining({
+            ownerId: authUser.id,
+            albumName: data.albumName,
+          }),
+        );
+      });
+    });
+
+    describe('with albums in DB', () => {
+      const userOneShared = 'userOneShared';
+      const userOneNotShared = 'userOneNotShared';
+      const userTwoShared = 'userTwoShared';
+      const userTwoNotShared = 'userTwoNotShared';
+      let userOne: AuthUserDto;
+      let userTwo: AuthUserDto;
+
+      beforeAll(async () => {
+        // setup users
+        const result = await Promise.all([
+          userService.createUser({
+            email: 'one@test.com',
+            password: '1234',
+            firstName: 'one',
+            lastName: 'test',
+          }),
+          userService.createUser({
+            email: 'two@test.com',
+            password: '1234',
+            firstName: 'two',
+            lastName: 'test',
+          }),
+        ]);
+        userOne = result[0];
+        userTwo = result[1];
+        // add user one albums
+        authUser = userOne;
+        await Promise.all([
+          _createAlbum(app, { albumName: userOneShared, sharedWithUserIds: [userTwo.id] }),
+          _createAlbum(app, { albumName: userOneNotShared }),
+        ]);
+        // add user two albums
+        authUser = userTwo;
+        await Promise.all([
+          _createAlbum(app, { albumName: userTwoShared, sharedWithUserIds: [userOne.id] }),
+          _createAlbum(app, { albumName: userTwoNotShared }),
+        ]);
+        // set user one as authed for next requests
+        authUser = userOne;
+      });
+
+      it('returns the album collection including owned and shared', async () => {
+        const { status, body } = await request(app.getHttpServer()).get('/album');
+        expect(status).toEqual(200);
+        expect(body).toHaveLength(3);
+        expect(body).toEqual(
+          expect.arrayContaining([
+            expect.objectContaining({ ownerId: userOne.id, albumName: userOneShared, shared: true }),
+            expect.objectContaining({ ownerId: userOne.id, albumName: userOneNotShared, shared: false }),
+            expect.objectContaining({ ownerId: userTwo.id, albumName: userTwoShared, shared: true }),
+          ]),
+        );
+      });
+
+      it('returns the album collection filtered by shared', async () => {
+        const { status, body } = await request(app.getHttpServer()).get('/album?shared=true');
+        expect(status).toEqual(200);
+        expect(body).toHaveLength(2);
+        expect(body).toEqual(
+          expect.arrayContaining([
+            expect.objectContaining({ ownerId: userOne.id, albumName: userOneShared, shared: true }),
+            expect.objectContaining({ ownerId: userTwo.id, albumName: userTwoShared, shared: true }),
+          ]),
+        );
+      });
+
+      it('returns the album collection filtered by NOT shared', async () => {
+        const { status, body } = await request(app.getHttpServer()).get('/album?shared=false');
+        expect(status).toEqual(200);
+        expect(body).toHaveLength(1);
+        expect(body).toEqual(
+          expect.arrayContaining([
+            expect.objectContaining({ ownerId: userOne.id, albumName: userOneNotShared, shared: false }),
+          ]),
+        );
+      });
+    });
+  });
+});

+ 27 - 0
server/libs/database/src/entities/album.entity.ts

@@ -0,0 +1,27 @@
+import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
+import { AssetAlbumEntity } from './asset-album.entity';
+import { UserAlbumEntity } from './user-album.entity';
+
+@Entity('albums')
+export class AlbumEntity {
+  @PrimaryGeneratedColumn('uuid')
+  id: string;
+
+  @Column()
+  ownerId: string;
+
+  @Column({ default: 'Untitled Album' })
+  albumName: string;
+
+  @CreateDateColumn({ type: 'timestamptz' })
+  createdAt: string;
+
+  @Column({ comment: 'Asset ID to be used as thumbnail', nullable: true })
+  albumThumbnailAssetId: string;
+
+  @OneToMany(() => UserAlbumEntity, (userAlbums) => userAlbums.albumInfo)
+  sharedUsers: UserAlbumEntity[];
+
+  @OneToMany(() => AssetAlbumEntity, (assetAlbumEntity) => assetAlbumEntity.albumInfo)
+  assets: AssetAlbumEntity[];
+}

+ 6 - 6
server/libs/database/src/entities/asset-shared-album.entity.ts → server/libs/database/src/entities/asset-album.entity.ts

@@ -1,10 +1,10 @@
-import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
+import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
+import { AlbumEntity } from './album.entity';
 import { AssetEntity } from './asset.entity';
-import { SharedAlbumEntity } from './shared-album.entity';
 
-@Entity('asset_shared_album')
+@Entity('asset_album')
 @Unique('PK_unique_asset_in_album', ['albumId', 'assetId'])
-export class AssetSharedAlbumEntity {
+export class AssetAlbumEntity {
   @PrimaryGeneratedColumn()
   id: string;
 
@@ -14,12 +14,12 @@ export class AssetSharedAlbumEntity {
   @Column()
   assetId: string;
 
-  @ManyToOne(() => SharedAlbumEntity, (sharedAlbum) => sharedAlbum.sharedAssets, {
+  @ManyToOne(() => AlbumEntity, (album) => album.assets, {
     onDelete: 'CASCADE',
     nullable: true,
   })
   @JoinColumn({ name: 'albumId' })
-  albumInfo: SharedAlbumEntity;
+  albumInfo: AlbumEntity;
 
   @ManyToOne(() => AssetEntity, {
     onDelete: 'CASCADE',

+ 0 - 27
server/libs/database/src/entities/shared-album.entity.ts

@@ -1,27 +0,0 @@
-import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
-import { AssetSharedAlbumEntity } from './asset-shared-album.entity';
-import { UserSharedAlbumEntity } from './user-shared-album.entity';
-
-@Entity('shared_albums')
-export class SharedAlbumEntity {
-  @PrimaryGeneratedColumn('uuid')
-  id: string;
-
-  @Column()
-  ownerId: string;
-
-  @Column({ default: 'Untitled Album' })
-  albumName: string;
-
-  @CreateDateColumn({ type: 'timestamptz' })
-  createdAt: string;
-
-  @Column({ comment: 'Asset ID to be used as thumbnail', nullable: true })
-  albumThumbnailAssetId: string;
-
-  @OneToMany(() => UserSharedAlbumEntity, (userSharedAlbums) => userSharedAlbums.albumInfo)
-  sharedUsers: UserSharedAlbumEntity[];
-
-  @OneToMany(() => AssetSharedAlbumEntity, (assetSharedAlbumEntity) => assetSharedAlbumEntity.albumInfo)
-  sharedAssets: AssetSharedAlbumEntity[];
-}

+ 5 - 5
server/libs/database/src/entities/user-shared-album.entity.ts → server/libs/database/src/entities/user-album.entity.ts

@@ -1,10 +1,10 @@
-import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
+import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
 import { UserEntity } from './user.entity';
-import { SharedAlbumEntity } from './shared-album.entity';
+import { AlbumEntity } from './album.entity';
 
 @Entity('user_shared_album')
 @Unique('PK_unique_user_in_album', ['albumId', 'sharedUserId'])
-export class UserSharedAlbumEntity {
+export class UserAlbumEntity {
   @PrimaryGeneratedColumn()
   id: string;
 
@@ -14,12 +14,12 @@ export class UserSharedAlbumEntity {
   @Column()
   sharedUserId: string;
 
-  @ManyToOne(() => SharedAlbumEntity, (sharedAlbum) => sharedAlbum.sharedUsers, {
+  @ManyToOne(() => AlbumEntity, (album) => album.sharedUsers, {
     onDelete: 'CASCADE',
     nullable: true,
   })
   @JoinColumn({ name: 'albumId' })
-  albumInfo: SharedAlbumEntity;
+  albumInfo: AlbumEntity;
 
   @ManyToOne(() => UserEntity)
   @JoinColumn({ name: 'sharedUserId' })

+ 19 - 0
server/libs/database/src/migrations/1655401127251-RenameSharedAlbums.ts

@@ -0,0 +1,19 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class RenameSharedAlbums1655401127251 implements MigrationInterface {
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`
+      ALTER TABLE shared_albums RENAME TO albums;
+
+      ALTER TABLE asset_shared_album RENAME TO asset_album;
+    `);
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`
+      ALTER TABLE asset_album RENAME TO asset_shared_album;
+
+      ALTER TABLE albums RENAME TO shared_albums;
+    `);
+  }
+}

+ 1 - 0
server/package.json

@@ -92,6 +92,7 @@
     "typescript": "^4.3.5"
   },
   "jest": {
+    "clearMocks": true,
     "moduleFileExtensions": [
       "js",
       "json",