Quellcode durchsuchen

feat(web): add asset and album count info (#623)

* Get asset and album count

* Generate APIs

* Added asset count for each type

* Added api on the web

* Added info button for asset and album count to trigger getting info on hover

* Remove websocket event from photo page
Alex vor 2 Jahren
Ursprung
Commit
566039b93f
28 geänderte Dateien mit 931 neuen und 38 gelöschten Zeilen
  1. 5 0
      mobile/openapi/.openapi-generator/FILES
  2. 4 0
      mobile/openapi/README.md
  3. 44 0
      mobile/openapi/doc/AlbumApi.md
  4. 17 0
      mobile/openapi/doc/AlbumCountResponseDto.md
  5. 44 0
      mobile/openapi/doc/AssetApi.md
  6. 16 0
      mobile/openapi/doc/AssetCountByUserIdResponseDto.md
  7. 2 0
      mobile/openapi/lib/api.dart
  8. 41 0
      mobile/openapi/lib/api/album_api.dart
  9. 41 0
      mobile/openapi/lib/api/asset_api.dart
  10. 4 0
      mobile/openapi/lib/api_client.dart
  11. 127 0
      mobile/openapi/lib/model/album_count_response_dto.dart
  12. 119 0
      mobile/openapi/lib/model/asset_count_by_user_id_response_dto.dart
  13. 37 0
      mobile/openapi/test/album_count_response_dto_test.dart
  14. 32 0
      mobile/openapi/test/asset_count_by_user_id_response_dto_test.dart
  15. 34 19
      server/apps/immich/src/api-v1/album/album-repository.ts
  16. 7 0
      server/apps/immich/src/api-v1/album/album.controller.ts
  17. 2 1
      server/apps/immich/src/api-v1/album/album.service.spec.ts
  18. 5 0
      server/apps/immich/src/api-v1/album/album.service.ts
  19. 18 0
      server/apps/immich/src/api-v1/album/response-dto/album-count-response.dto.ts
  20. 24 0
      server/apps/immich/src/api-v1/asset/asset-repository.ts
  21. 13 2
      server/apps/immich/src/api-v1/asset/asset.controller.ts
  22. 1 0
      server/apps/immich/src/api-v1/asset/asset.service.spec.ts
  23. 5 0
      server/apps/immich/src/api-v1/asset/asset.service.ts
  24. 14 0
      server/apps/immich/src/api-v1/asset/response-dto/asset-count-by-user-id-response.dto.ts
  25. 0 0
      server/immich-openapi-specs.json
  26. 164 0
      web/src/api/open-api/api.ts
  27. 111 8
      web/src/lib/components/shared-components/side-bar/side-bar.svelte
  28. 0 8
      web/src/routes/photos/+page.svelte

+ 5 - 0
mobile/openapi/.openapi-generator/FILES

@@ -6,10 +6,12 @@ doc/AddAssetsDto.md
 doc/AddUsersDto.md
 doc/AdminSignupResponseDto.md
 doc/AlbumApi.md
+doc/AlbumCountResponseDto.md
 doc/AlbumResponseDto.md
 doc/AssetApi.md
 doc/AssetCountByTimeBucket.md
 doc/AssetCountByTimeBucketResponseDto.md
+doc/AssetCountByUserIdResponseDto.md
 doc/AssetFileUploadResponseDto.md
 doc/AssetResponseDto.md
 doc/AssetTypeEnum.md
@@ -70,9 +72,11 @@ lib/auth/oauth.dart
 lib/model/add_assets_dto.dart
 lib/model/add_users_dto.dart
 lib/model/admin_signup_response_dto.dart
+lib/model/album_count_response_dto.dart
 lib/model/album_response_dto.dart
 lib/model/asset_count_by_time_bucket.dart
 lib/model/asset_count_by_time_bucket_response_dto.dart
+lib/model/asset_count_by_user_id_response_dto.dart
 lib/model/asset_file_upload_response_dto.dart
 lib/model/asset_response_dto.dart
 lib/model/asset_type_enum.dart
@@ -111,3 +115,4 @@ lib/model/user_count_response_dto.dart
 lib/model/user_response_dto.dart
 lib/model/validate_access_token_response_dto.dart
 pubspec.yaml
+test/asset_count_by_user_id_response_dto_test.dart

+ 4 - 0
mobile/openapi/README.md

@@ -69,6 +69,7 @@ Class | Method | HTTP request | Description
 *AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{albumId}/users | 
 *AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /album | 
 *AlbumApi* | [**deleteAlbum**](doc//AlbumApi.md#deletealbum) | **DELETE** /album/{albumId} | 
+*AlbumApi* | [**getAlbumCountByUserId**](doc//AlbumApi.md#getalbumcountbyuserid) | **GET** /album/count-by-user-id | 
 *AlbumApi* | [**getAlbumInfo**](doc//AlbumApi.md#getalbuminfo) | **GET** /album/{albumId} | 
 *AlbumApi* | [**getAllAlbums**](doc//AlbumApi.md#getallalbums) | **GET** /album | 
 *AlbumApi* | [**removeAssetFromAlbum**](doc//AlbumApi.md#removeassetfromalbum) | **DELETE** /album/{albumId}/assets | 
@@ -81,6 +82,7 @@ Class | Method | HTTP request | Description
 *AssetApi* | [**getAssetById**](doc//AssetApi.md#getassetbyid) | **GET** /asset/assetById/{assetId} | 
 *AssetApi* | [**getAssetByTimeBucket**](doc//AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket | 
 *AssetApi* | [**getAssetCountByTimeBucket**](doc//AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket | 
+*AssetApi* | [**getAssetCountByUserId**](doc//AssetApi.md#getassetcountbyuserid) | **GET** /asset/count-by-user-id | 
 *AssetApi* | [**getAssetSearchTerms**](doc//AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms | 
 *AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{assetId} | 
 *AssetApi* | [**getCuratedLocations**](doc//AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | 
@@ -113,9 +115,11 @@ Class | Method | HTTP request | Description
  - [AddAssetsDto](doc//AddAssetsDto.md)
  - [AddUsersDto](doc//AddUsersDto.md)
  - [AdminSignupResponseDto](doc//AdminSignupResponseDto.md)
+ - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
  - [AlbumResponseDto](doc//AlbumResponseDto.md)
  - [AssetCountByTimeBucket](doc//AssetCountByTimeBucket.md)
  - [AssetCountByTimeBucketResponseDto](doc//AssetCountByTimeBucketResponseDto.md)
+ - [AssetCountByUserIdResponseDto](doc//AssetCountByUserIdResponseDto.md)
  - [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
  - [AssetResponseDto](doc//AssetResponseDto.md)
  - [AssetTypeEnum](doc//AssetTypeEnum.md)

+ 44 - 0
mobile/openapi/doc/AlbumApi.md

@@ -13,6 +13,7 @@ Method | HTTP request | Description
 [**addUsersToAlbum**](AlbumApi.md#adduserstoalbum) | **PUT** /album/{albumId}/users | 
 [**createAlbum**](AlbumApi.md#createalbum) | **POST** /album | 
 [**deleteAlbum**](AlbumApi.md#deletealbum) | **DELETE** /album/{albumId} | 
+[**getAlbumCountByUserId**](AlbumApi.md#getalbumcountbyuserid) | **GET** /album/count-by-user-id | 
 [**getAlbumInfo**](AlbumApi.md#getalbuminfo) | **GET** /album/{albumId} | 
 [**getAllAlbums**](AlbumApi.md#getallalbums) | **GET** /album | 
 [**removeAssetFromAlbum**](AlbumApi.md#removeassetfromalbum) | **DELETE** /album/{albumId}/assets | 
@@ -211,6 +212,49 @@ void (empty response body)
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
+# **getAlbumCountByUserId**
+> AlbumCountResponseDto getAlbumCountByUserId()
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = AlbumApi();
+
+try {
+    final result = api_instance.getAlbumCountByUserId();
+    print(result);
+} catch (e) {
+    print('Exception when calling AlbumApi->getAlbumCountByUserId: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+[**AlbumCountResponseDto**](AlbumCountResponseDto.md)
+
+### Authorization
+
+[bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **getAlbumInfo**
 > AlbumResponseDto getAlbumInfo(albumId)
 

+ 17 - 0
mobile/openapi/doc/AlbumCountResponseDto.md

@@ -0,0 +1,17 @@
+# openapi.model.AlbumCountResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**owned** | **int** |  | 
+**shared** | **int** |  | 
+**sharing** | **int** |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 44 - 0
mobile/openapi/doc/AssetApi.md

@@ -16,6 +16,7 @@ Method | HTTP request | Description
 [**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{assetId} | 
 [**getAssetByTimeBucket**](AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket | 
 [**getAssetCountByTimeBucket**](AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket | 
+[**getAssetCountByUserId**](AssetApi.md#getassetcountbyuserid) | **GET** /asset/count-by-user-id | 
 [**getAssetSearchTerms**](AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms | 
 [**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{assetId} | 
 [**getCuratedLocations**](AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | 
@@ -363,6 +364,49 @@ Name | Type | Description  | Notes
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
+# **getAssetCountByUserId**
+> AssetCountByUserIdResponseDto getAssetCountByUserId()
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = AssetApi();
+
+try {
+    final result = api_instance.getAssetCountByUserId();
+    print(result);
+} catch (e) {
+    print('Exception when calling AssetApi->getAssetCountByUserId: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+[**AssetCountByUserIdResponseDto**](AssetCountByUserIdResponseDto.md)
+
+### Authorization
+
+[bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **getAssetSearchTerms**
 > List<String> getAssetSearchTerms()
 

+ 16 - 0
mobile/openapi/doc/AssetCountByUserIdResponseDto.md

@@ -0,0 +1,16 @@
+# openapi.model.AssetCountByUserIdResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**photos** | **int** |  | 
+**videos** | **int** |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 2 - 0
mobile/openapi/lib/api.dart

@@ -37,9 +37,11 @@ part 'api/user_api.dart';
 part 'model/add_assets_dto.dart';
 part 'model/add_users_dto.dart';
 part 'model/admin_signup_response_dto.dart';
+part 'model/album_count_response_dto.dart';
 part 'model/album_response_dto.dart';
 part 'model/asset_count_by_time_bucket.dart';
 part 'model/asset_count_by_time_bucket_response_dto.dart';
+part 'model/asset_count_by_user_id_response_dto.dart';
 part 'model/asset_file_upload_response_dto.dart';
 part 'model/asset_response_dto.dart';
 part 'model/asset_type_enum.dart';

+ 41 - 0
mobile/openapi/lib/api/album_api.dart

@@ -207,6 +207,47 @@ class AlbumApi {
     }
   }
 
+  /// Performs an HTTP 'GET /album/count-by-user-id' operation and returns the [Response].
+  Future<Response> getAlbumCountByUserIdWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/album/count-by-user-id';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<AlbumCountResponseDto?> getAlbumCountByUserId() async {
+    final response = await getAlbumCountByUserIdWithHttpInfo();
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumCountResponseDto',) as AlbumCountResponseDto;
+    
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'GET /album/{albumId}' operation and returns the [Response].
   /// Parameters:
   ///

+ 41 - 0
mobile/openapi/lib/api/asset_api.dart

@@ -395,6 +395,47 @@ class AssetApi {
     return null;
   }
 
+  /// Performs an HTTP 'GET /asset/count-by-user-id' operation and returns the [Response].
+  Future<Response> getAssetCountByUserIdWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/asset/count-by-user-id';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<AssetCountByUserIdResponseDto?> getAssetCountByUserId() async {
+    final response = await getAssetCountByUserIdWithHttpInfo();
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetCountByUserIdResponseDto',) as AssetCountByUserIdResponseDto;
+    
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'GET /asset/search-terms' operation and returns the [Response].
   Future<Response> getAssetSearchTermsWithHttpInfo() async {
     // ignore: prefer_const_declarations

+ 4 - 0
mobile/openapi/lib/api_client.dart

@@ -198,12 +198,16 @@ class ApiClient {
           return AddUsersDto.fromJson(value);
         case 'AdminSignupResponseDto':
           return AdminSignupResponseDto.fromJson(value);
+        case 'AlbumCountResponseDto':
+          return AlbumCountResponseDto.fromJson(value);
         case 'AlbumResponseDto':
           return AlbumResponseDto.fromJson(value);
         case 'AssetCountByTimeBucket':
           return AssetCountByTimeBucket.fromJson(value);
         case 'AssetCountByTimeBucketResponseDto':
           return AssetCountByTimeBucketResponseDto.fromJson(value);
+        case 'AssetCountByUserIdResponseDto':
+          return AssetCountByUserIdResponseDto.fromJson(value);
         case 'AssetFileUploadResponseDto':
           return AssetFileUploadResponseDto.fromJson(value);
         case 'AssetResponseDto':

+ 127 - 0
mobile/openapi/lib/model/album_count_response_dto.dart

@@ -0,0 +1,127 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class AlbumCountResponseDto {
+  /// Returns a new [AlbumCountResponseDto] instance.
+  AlbumCountResponseDto({
+    required this.owned,
+    required this.shared,
+    required this.sharing,
+  });
+
+  int owned;
+
+  int shared;
+
+  int sharing;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is AlbumCountResponseDto &&
+     other.owned == owned &&
+     other.shared == shared &&
+     other.sharing == sharing;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (owned.hashCode) +
+    (shared.hashCode) +
+    (sharing.hashCode);
+
+  @override
+  String toString() => 'AlbumCountResponseDto[owned=$owned, shared=$shared, sharing=$sharing]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'owned'] = owned;
+      _json[r'shared'] = shared;
+      _json[r'sharing'] = sharing;
+    return _json;
+  }
+
+  /// Returns a new [AlbumCountResponseDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static AlbumCountResponseDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "AlbumCountResponseDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "AlbumCountResponseDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return AlbumCountResponseDto(
+        owned: mapValueOfType<int>(json, r'owned')!,
+        shared: mapValueOfType<int>(json, r'shared')!,
+        sharing: mapValueOfType<int>(json, r'sharing')!,
+      );
+    }
+    return null;
+  }
+
+  static List<AlbumCountResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <AlbumCountResponseDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = AlbumCountResponseDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, AlbumCountResponseDto> mapFromJson(dynamic json) {
+    final map = <String, AlbumCountResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = AlbumCountResponseDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of AlbumCountResponseDto-objects as value to a dart map
+  static Map<String, List<AlbumCountResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<AlbumCountResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = AlbumCountResponseDto.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'owned',
+    'shared',
+    'sharing',
+  };
+}
+

+ 119 - 0
mobile/openapi/lib/model/asset_count_by_user_id_response_dto.dart

@@ -0,0 +1,119 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class AssetCountByUserIdResponseDto {
+  /// Returns a new [AssetCountByUserIdResponseDto] instance.
+  AssetCountByUserIdResponseDto({
+    required this.photos,
+    required this.videos,
+  });
+
+  int photos;
+
+  int videos;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is AssetCountByUserIdResponseDto &&
+     other.photos == photos &&
+     other.videos == videos;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (photos.hashCode) +
+    (videos.hashCode);
+
+  @override
+  String toString() => 'AssetCountByUserIdResponseDto[photos=$photos, videos=$videos]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'photos'] = photos;
+      _json[r'videos'] = videos;
+    return _json;
+  }
+
+  /// Returns a new [AssetCountByUserIdResponseDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static AssetCountByUserIdResponseDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "AssetCountByUserIdResponseDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "AssetCountByUserIdResponseDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return AssetCountByUserIdResponseDto(
+        photos: mapValueOfType<int>(json, r'photos')!,
+        videos: mapValueOfType<int>(json, r'videos')!,
+      );
+    }
+    return null;
+  }
+
+  static List<AssetCountByUserIdResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <AssetCountByUserIdResponseDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = AssetCountByUserIdResponseDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, AssetCountByUserIdResponseDto> mapFromJson(dynamic json) {
+    final map = <String, AssetCountByUserIdResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = AssetCountByUserIdResponseDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of AssetCountByUserIdResponseDto-objects as value to a dart map
+  static Map<String, List<AssetCountByUserIdResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<AssetCountByUserIdResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = AssetCountByUserIdResponseDto.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'photos',
+    'videos',
+  };
+}
+

+ 37 - 0
mobile/openapi/test/album_count_response_dto_test.dart

@@ -0,0 +1,37 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for AlbumCountResponseDto
+void main() {
+  // final instance = AlbumCountResponseDto();
+
+  group('test AlbumCountResponseDto', () {
+    // int owned
+    test('to test the property `owned`', () async {
+      // TODO
+    });
+
+    // int shared
+    test('to test the property `shared`', () async {
+      // TODO
+    });
+
+    // int sharing
+    test('to test the property `sharing`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 32 - 0
mobile/openapi/test/asset_count_by_user_id_response_dto_test.dart

@@ -0,0 +1,32 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for AssetCountByUserIdResponseDto
+void main() {
+  // final instance = AssetCountByUserIdResponseDto();
+
+  group('test AssetCountByUserIdResponseDto', () {
+    // int photos
+    test('to test the property `photos`', () async {
+      // TODO
+    });
+
+    // int videos
+    test('to test the property `videos`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 34 - 19
server/apps/immich/src/api-v1/album/album-repository.ts

@@ -10,6 +10,7 @@ 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';
+import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
 import { AlbumResponseDto } from './response-dto/album-response.dto';
 
 export interface IAlbumRepository {
@@ -23,6 +24,7 @@ export interface IAlbumRepository {
   addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity>;
   updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
   getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]>;
+  getCountByUserId(userId: string): Promise<AlbumCountResponseDto>;
 }
 
 export const ALBUM_REPOSITORY = 'ALBUM_REPOSITORY';
@@ -42,6 +44,18 @@ export class AlbumRepository implements IAlbumRepository {
     private dataSource: DataSource,
   ) {}
 
+  async getCountByUserId(userId: string): Promise<AlbumCountResponseDto> {
+    const ownedAlbums = await this.albumRepository.find({ where: { ownerId: userId }, relations: ['sharedUsers'] });
+
+    const sharedAlbums = await this.userAlbumRepository.count({
+      where: { sharedUserId: userId },
+    });
+
+    const sharingAlbums = ownedAlbums.map((album) => album.sharedUsers?.length || 0).reduce((a, b) => a + b, 0);
+
+    return new AlbumCountResponseDto(ownedAlbums.length, sharedAlbums, sharingAlbums);
+  }
+
   async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity> {
     return this.dataSource.transaction(async (transactionalEntityManager) => {
       // Create album entity
@@ -154,23 +168,23 @@ export class AlbumRepository implements IAlbumRepository {
     let query = this.albumRepository.createQueryBuilder('album');
 
     const albums = await query
-        .where('album.ownerId = :ownerId', { ownerId: userId })
-        .andWhere((qb) => {
-          // shared with userId
-          const subQuery = qb
-              .subQuery()
-              .select('assetAlbum.albumId')
-              .from(AssetAlbumEntity, 'assetAlbum')
-              .where('assetAlbum.assetId = :assetId', {assetId: assetId})
-              .getQuery();
-          return `album.id IN ${subQuery}`;
-        })
-        .leftJoinAndSelect('album.assets', 'assets')
-        .leftJoinAndSelect('assets.assetInfo', 'assetInfo')
-        .leftJoinAndSelect('album.sharedUsers', 'sharedUser')
-        .leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
-        .orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC')
-        .getMany();
+      .where('album.ownerId = :ownerId', { ownerId: userId })
+      .andWhere((qb) => {
+        // shared with userId
+        const subQuery = qb
+          .subQuery()
+          .select('assetAlbum.albumId')
+          .from(AssetAlbumEntity, 'assetAlbum')
+          .where('assetAlbum.assetId = :assetId', { assetId: assetId })
+          .getQuery();
+        return `album.id IN ${subQuery}`;
+      })
+      .leftJoinAndSelect('album.assets', 'assets')
+      .leftJoinAndSelect('assets.assetInfo', 'assetInfo')
+      .leftJoinAndSelect('album.sharedUsers', 'sharedUser')
+      .leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
+      .orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC')
+      .getMany();
 
     return albums;
   }
@@ -228,9 +242,10 @@ export class AlbumRepository implements IAlbumRepository {
 
     // TODO: No need to return boolean if using a singe delete query
     if (deleteAssetCount == removeAssetsDto.assetIds.length) {
-      const retAlbum = await this.get(album.id) as AlbumEntity;
+      const retAlbum = (await this.get(album.id)) as AlbumEntity;
 
-      if (retAlbum?.assets?.length === 0) { // is empty album
+      if (retAlbum?.assets?.length === 0) {
+        // is empty album
         await this.albumRepository.update(album.id, { albumThumbnailAssetId: null });
         retAlbum.albumThumbnailAssetId = null;
       }

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

@@ -11,6 +11,7 @@ import {
   ParseUUIDPipe,
   Put,
   Query,
+  Header,
 } from '@nestjs/common';
 import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe';
 import { AlbumService } from './album.service';
@@ -24,6 +25,7 @@ import { UpdateAlbumDto } from './dto/update-album.dto';
 import { GetAlbumsDto } from './dto/get-albums.dto';
 import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
 import { AlbumResponseDto } from './response-dto/album-response.dto';
+import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
 
 // TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe.
 @UseGuards(JwtAuthGuard)
@@ -33,6 +35,11 @@ import { AlbumResponseDto } from './response-dto/album-response.dto';
 export class AlbumController {
   constructor(private readonly albumService: AlbumService) {}
 
+  @Get('count-by-user-id')
+  async getAlbumCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
+    return this.albumService.getAlbumCountByUserId(authUser);
+  }
+
   @Post()
   async createAlbum(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createAlbumDto: CreateAlbumDto) {
     return this.albumService.create(authUser, createAlbumDto);

+ 2 - 1
server/apps/immich/src/api-v1/album/album.service.spec.ts

@@ -116,7 +116,8 @@ describe('Album service', () => {
       removeAssets: jest.fn(),
       removeUser: jest.fn(),
       updateAlbum: jest.fn(),
-      getListByAssetId: jest.fn()
+      getListByAssetId: jest.fn(),
+      getCountByUserId: jest.fn(),
     };
     sut = new AlbumService(albumRepositoryMock);
   });

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

@@ -9,6 +9,7 @@ import { UpdateAlbumDto } from './dto/update-album.dto';
 import { GetAlbumsDto } from './dto/get-albums.dto';
 import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto';
 import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
+import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
 
 @Injectable()
 export class AlbumService {
@@ -118,4 +119,8 @@ export class AlbumService {
     const updatedAlbum = await this._albumRepository.updateAlbum(album, updateAlbumDto);
     return mapAlbum(updatedAlbum);
   }
+
+  async getAlbumCountByUserId(authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
+    return this._albumRepository.getCountByUserId(authUser.id);
+  }
 }

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

@@ -0,0 +1,18 @@
+import { ApiProperty } from '@nestjs/swagger';
+
+export class AlbumCountResponseDto {
+  @ApiProperty({ type: 'integer' })
+  owned!: number;
+
+  @ApiProperty({ type: 'integer' })
+  shared!: number;
+
+  @ApiProperty({ type: 'integer' })
+  sharing!: number;
+
+  constructor(owned: number, shared: number, sharing: number) {
+    this.owned = owned;
+    this.shared = shared;
+    this.sharing = sharing;
+  }
+}

+ 24 - 0
server/apps/immich/src/api-v1/asset/asset-repository.ts

@@ -9,6 +9,7 @@ import { CuratedObjectsResponseDto } from './response-dto/curated-objects-respon
 import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
 import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
 import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
+import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
 
 export interface IAssetRepository {
   create(
@@ -25,6 +26,7 @@ export interface IAssetRepository {
   getDetectedObjectsByUserId(userId: string): Promise<CuratedObjectsResponseDto[]>;
   getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
   getAssetCountByTimeBucket(userId: string, timeBucket: TimeGroupEnum): Promise<AssetCountByTimeBucket[]>;
+  getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
   getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
   getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
 }
@@ -38,6 +40,28 @@ export class AssetRepository implements IAssetRepository {
     private assetRepository: Repository<AssetEntity>,
   ) {}
 
+  async getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto> {
+    // Get asset count by AssetType
+    const res = await this.assetRepository
+      .createQueryBuilder('asset')
+      .select(`COUNT(asset.id)`, 'count')
+      .addSelect(`asset.type`, 'type')
+      .where('"userId" = :userId', { userId: userId })
+      .groupBy('asset.type')
+      .getRawMany();
+
+    const assetCountByUserId = new AssetCountByUserIdResponseDto(0, 0);
+    res.map((item) => {
+      if (item.type === 'IMAGE') {
+        assetCountByUserId.photos = item.count;
+      } else if (item.type === 'VIDEO') {
+        assetCountByUserId.videos = item.count;
+      }
+    });
+
+    return assetCountByUserId;
+  }
+
   async getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]> {
     // Get asset entity from a list of time buckets
     return await this.assetRepository

+ 13 - 2
server/apps/immich/src/api-v1/asset/asset.controller.ts

@@ -48,6 +48,7 @@ import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by
 import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
 import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
 import { QueryFailedError } from 'typeorm';
+import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
 
 @UseGuards(JwtAuthGuard)
 @ApiBearerAuth()
@@ -78,7 +79,13 @@ export class AssetController {
     const checksum = await this.assetService.calculateChecksum(file.path);
 
     try {
-      const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype, checksum);
+      const savedAsset = await this.assetService.createUserAsset(
+        authUser,
+        assetInfo,
+        file.path,
+        file.mimetype,
+        checksum,
+      );
 
       if (!savedAsset) {
         await this.backgroundTaskService.deleteFileOnDisk([
@@ -104,7 +111,7 @@ export class AssetController {
       ]); // simulate asset to make use of delete queue (or use fs.unlink instead)
 
       if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
-        const existedAsset = await this.assetService.getAssetByChecksum(authUser.id, checksum)
+        const existedAsset = await this.assetService.getAssetByChecksum(authUser.id, checksum);
         return new AssetFileUploadResponseDto(existedAsset.id);
       }
 
@@ -172,6 +179,10 @@ export class AssetController {
     return this.assetService.getAssetCountByTimeBucket(authUser, getAssetCountByTimeGroupDto);
   }
 
+  @Get('/count-by-user-id')
+  async getAssetCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
+    return this.assetService.getAssetCountByUserId(authUser);
+  }
   /**
    * Get all AssetEntity belong to the user
    */

+ 1 - 0
server/apps/immich/src/api-v1/asset/asset.service.spec.ts

@@ -61,6 +61,7 @@ describe('AssetService', () => {
       getSearchPropertiesByUserId: jest.fn(),
       getAssetByTimeBucket: jest.fn(),
       getAssetByChecksum: jest.fn(),
+      getAssetCountByUserId: jest.fn(),
     };
 
     sui = new AssetService(assetRepositoryMock, a);

+ 5 - 0
server/apps/immich/src/api-v1/asset/asset.service.ts

@@ -35,6 +35,7 @@ import {
 } from './response-dto/asset-count-by-time-group-response.dto';
 import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
 import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
+import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
 
 const fileInfo = promisify(stat);
 
@@ -479,4 +480,8 @@ export class AssetService {
     fileReadStream.pipe(sha1Hash);
     return deferred;
   }
+
+  getAssetCountByUserId(authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
+    return this._assetRepository.getAssetCountByUserId(authUser.id);
+  }
 }

+ 14 - 0
server/apps/immich/src/api-v1/asset/response-dto/asset-count-by-user-id-response.dto.ts

@@ -0,0 +1,14 @@
+import { ApiProperty } from '@nestjs/swagger';
+
+export class AssetCountByUserIdResponseDto {
+  @ApiProperty({ type: 'integer' })
+  photos!: number;
+
+  @ApiProperty({ type: 'integer' })
+  videos!: number;
+
+  constructor(photos: number, videos: number) {
+    this.photos = photos;
+    this.videos = videos;
+  }
+}

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
server/immich-openapi-specs.json


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

@@ -84,6 +84,31 @@ export interface AdminSignupResponseDto {
      */
     'createdAt': string;
 }
+/**
+ * 
+ * @export
+ * @interface AlbumCountResponseDto
+ */
+export interface AlbumCountResponseDto {
+    /**
+     * 
+     * @type {number}
+     * @memberof AlbumCountResponseDto
+     */
+    'owned': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AlbumCountResponseDto
+     */
+    'shared': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AlbumCountResponseDto
+     */
+    'sharing': number;
+}
 /**
  * 
  * @export
@@ -183,6 +208,25 @@ export interface AssetCountByTimeBucketResponseDto {
      */
     'buckets': Array<AssetCountByTimeBucket>;
 }
+/**
+ * 
+ * @export
+ * @interface AssetCountByUserIdResponseDto
+ */
+export interface AssetCountByUserIdResponseDto {
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetCountByUserIdResponseDto
+     */
+    'photos': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetCountByUserIdResponseDto
+     */
+    'videos': number;
+}
 /**
  * 
  * @export
@@ -1408,6 +1452,39 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
 
 
     
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getAlbumCountByUserId: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/album/count-by-user-id`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -1676,6 +1753,15 @@ export const AlbumApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAlbum(albumId, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async getAlbumCountByUserId(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AlbumCountResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getAlbumCountByUserId(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {string} albumId 
@@ -1778,6 +1864,14 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
         deleteAlbum(albumId: string, options?: any): AxiosPromise<void> {
             return localVarFp.deleteAlbum(albumId, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getAlbumCountByUserId(options?: any): AxiosPromise<AlbumCountResponseDto> {
+            return localVarFp.getAlbumCountByUserId(options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {string} albumId 
@@ -1883,6 +1977,16 @@ export class AlbumApi extends BaseAPI {
         return AlbumApiFp(this.configuration).deleteAlbum(albumId, options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AlbumApi
+     */
+    public getAlbumCountByUserId(options?: AxiosRequestConfig) {
+        return AlbumApiFp(this.configuration).getAlbumCountByUserId(options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {string} albumId 
@@ -2236,6 +2340,39 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 options: localVarRequestOptions,
             };
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getAssetCountByUserId: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/asset/count-by-user-id`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
         /**
          * 
          * @param {*} [options] Override http request option.
@@ -2640,6 +2777,15 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetCountByTimeBucket(getAssetCountByTimeBucketDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async getAssetCountByUserId(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetCountByUserIdResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetCountByUserId(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {*} [options] Override http request option.
@@ -2800,6 +2946,14 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         getAssetCountByTimeBucket(getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto, options?: any): AxiosPromise<AssetCountByTimeBucketResponseDto> {
             return localVarFp.getAssetCountByTimeBucket(getAssetCountByTimeBucketDto, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getAssetCountByUserId(options?: any): AxiosPromise<AssetCountByUserIdResponseDto> {
+            return localVarFp.getAssetCountByUserId(options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {*} [options] Override http request option.
@@ -2966,6 +3120,16 @@ export class AssetApi extends BaseAPI {
         return AssetApiFp(this.configuration).getAssetCountByTimeBucket(getAssetCountByTimeBucketDto, options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public getAssetCountByUserId(options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).getAssetCountByUserId(options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {*} [options] Override http request option.

+ 111 - 8
web/src/lib/components/shared-components/side-bar/side-bar.svelte

@@ -5,11 +5,19 @@
 	import ImageAlbum from 'svelte-material-icons/ImageAlbum.svelte';
 	import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
 	import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
+	import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
 	import SideBarButton from './side-bar-button.svelte';
 	import StatusBox from '../status-box.svelte';
+	import { AlbumCountResponseDto, api, AssetCountByUserIdResponseDto } from '@api';
+	import { fade } from 'svelte/transition';
+	import LoadingSpinner from '../loading-spinner.svelte';
 
 	let selectedAction: AppSideBarSelection;
 
+	let showAssetCount: boolean = false;
+	let showSharingCount = false;
+	let showAlbumsCount = false;
+
 	onMount(async () => {
 		if ($page.routeId == 'albums') {
 			selectedAction = AppSideBarSelection.ALBUMS;
@@ -19,35 +27,130 @@
 			selectedAction = AppSideBarSelection.SHARING;
 		}
 	});
+
+	const getAssetCount = async () => {
+		const { data: assetCount } = await api.assetApi.getAssetCountByUserId();
+
+		return {
+			videos: assetCount.videos,
+			photos: assetCount.photos
+		};
+	};
+
+	const getAlbumCount = async () => {
+		const { data: albumCount } = await api.albumApi.getAlbumCountByUserId();
+		return {
+			shared: albumCount.shared,
+			sharing: albumCount.sharing,
+			owned: albumCount.owned
+		};
+	};
 </script>
 
 <section id="sidebar" class="flex flex-col gap-1 pt-8 pr-6">
-	<a sveltekit:prefetch sveltekit:noscroll href={$page.routeId !== 'photos' ? `/photos` : null}>
+	<a
+		sveltekit:prefetch
+		sveltekit:noscroll
+		href={$page.routeId !== 'photos' ? `/photos` : null}
+		class="relative"
+	>
 		<SideBarButton
-			title="Photos"
+			title={`Photos`}
 			logo={ImageOutline}
 			actionType={AppSideBarSelection.PHOTOS}
 			isSelected={selectedAction === AppSideBarSelection.PHOTOS}
-		/></a
-	>
-	<a sveltekit:prefetch href={$page.routeId !== 'sharing' ? `/sharing` : null}>
+		/>
+		<div
+			id="asset-count-info"
+			class="absolute right-4 top-[15px] z-40 text-xs hover:cursor-help"
+			on:mouseenter={() => (showAssetCount = true)}
+			on:mouseleave={() => (showAssetCount = false)}
+		>
+			<InformationOutline size={18} color="#4250af" />
+			{#if showAssetCount}
+				<div
+					transition:fade={{ duration: 200 }}
+					id="asset-count-info-detail"
+					class="w-32 rounded-lg px-4 py-2 shadow-lg bg-white absolute -right-[135px] top-0 z-[9999] flex place-items-center place-content-center"
+				>
+					{#await getAssetCount()}
+						<LoadingSpinner />
+					{:then data}
+						<div>
+							<p>{data.videos} Videos</p>
+							<p>{data.photos} Photos</p>
+						</div>
+					{/await}
+				</div>
+			{/if}
+		</div>
+	</a>
+
+	<a sveltekit:prefetch href={$page.routeId !== 'sharing' ? `/sharing` : null} class="relative">
 		<SideBarButton
 			title="Sharing"
 			logo={AccountMultipleOutline}
 			actionType={AppSideBarSelection.SHARING}
 			isSelected={selectedAction === AppSideBarSelection.SHARING}
-		/></a
-	>
+		/>
+		<div
+			id="sharing-count-info"
+			class="absolute right-4 top-[15px] z-40 text-xs hover:cursor-help"
+			on:mouseenter={() => (showSharingCount = true)}
+			on:mouseleave={() => (showSharingCount = false)}
+		>
+			<InformationOutline size={18} color="#4250af" />
+			{#if showSharingCount}
+				<div
+					transition:fade={{ duration: 200 }}
+					id="asset-count-info-detail"
+					class="w-32 rounded-lg px-4 py-2 shadow-lg bg-white absolute -right-[135px] top-0 z-[9999] flex place-items-center place-content-center"
+				>
+					{#await getAlbumCount()}
+						<LoadingSpinner />
+					{:then data}
+						<div>
+							<p>{data.shared + data.sharing} albums</p>
+						</div>
+					{/await}
+				</div>
+			{/if}
+		</div>
+	</a>
 	<div class="text-xs ml-5 my-4">
 		<p>LIBRARY</p>
 	</div>
-	<a sveltekit:prefetch href={$page.routeId !== 'albums' ? `/albums` : null}>
+	<a sveltekit:prefetch href={$page.routeId !== 'albums' ? `/albums` : null} class="relative">
 		<SideBarButton
 			title="Albums"
 			logo={ImageAlbum}
 			actionType={AppSideBarSelection.ALBUMS}
 			isSelected={selectedAction === AppSideBarSelection.ALBUMS}
 		/>
+
+		<div
+			id="album-count-info"
+			class="absolute right-4 top-[15px] z-40 text-xs hover:cursor-help"
+			on:mouseenter={() => (showAlbumsCount = true)}
+			on:mouseleave={() => (showAlbumsCount = false)}
+		>
+			<InformationOutline size={18} color="#4250af" />
+			{#if showAlbumsCount}
+				<div
+					transition:fade={{ duration: 200 }}
+					id="asset-count-info-detail"
+					class="w-32 rounded-lg px-4 py-2 shadow-lg bg-white absolute -right-[135px] top-0 z-[9999] flex place-items-center place-content-center"
+				>
+					{#await getAlbumCount()}
+						<LoadingSpinner />
+					{:then data}
+						<div>
+							<p>{data.owned} albums</p>
+						</div>
+					{/await}
+				</div>
+			{/if}
+		</div>
 	</a>
 
 	<!-- Status Box -->

+ 0 - 8
web/src/routes/photos/+page.svelte

@@ -26,14 +26,6 @@
 
 	export let data: PageData;
 
-	onMount(async () => {
-		openWebsocketConnection();
-
-		return () => {
-			closeWebsocketConnection();
-		};
-	});
-
 	const deleteSelectedAssetHandler = async () => {
 		try {
 			if (

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.