Forráskód Böngészése

feat: Add description (#2237)

* Added dto, logic to insert description and web implementation

* create text field and update on remote database

* Update description and save changes

* styling

* fix web test

* fix server test

* preserve description on metadata extraction job run

* handle exif info is null situation

* pr feedback

* format openapi spec

* update createAssetDto

* refactor logic to service

* move files

* only owner can update description

* Render description correctly in shared album

* Render description correctly in shared link

* disable description edit for not owner of asset on mobile

* localization and clean up

* fix test

* Uses providers for description text (#2244)

* uses providers for description text

* comments

* fixes initial data setting

* fixes notifier

---------

Co-authored-by: martyfuhry <martyfuhry@gmail.com>
Alex 2 éve
szülő
commit
a9859bc029
27 módosított fájl, 843 hozzáadás és 178 törlés
  1. 7 1
      mobile/assets/i18n/en-US.json
  2. BIN
      mobile/flutter_01.png
  3. 93 0
      mobile/lib/modules/asset_viewer/providers/asset_description.provider.dart
  4. 62 0
      mobile/lib/modules/asset_viewer/services/asset_description.service.dart
  5. 103 0
      mobile/lib/modules/asset_viewer/ui/description_input.dart
  6. 119 101
      mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart
  7. 6 1
      mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
  8. 11 4
      mobile/lib/modules/settings/ui/advanced_settings/advanced_settings.dart
  9. 4 1
      mobile/lib/shared/models/exif_info.dart
  10. 243 42
      mobile/lib/shared/models/exif_info.g.dart
  11. 1 0
      mobile/openapi/doc/ExifResponseDto.md
  12. 1 0
      mobile/openapi/doc/UpdateAssetDto.md
  13. 14 3
      mobile/openapi/lib/model/exif_response_dto.dart
  14. 20 3
      mobile/openapi/lib/model/update_asset_dto.dart
  15. 5 0
      mobile/openapi/test/exif_response_dto_test.dart
  16. 5 0
      mobile/openapi/test/update_asset_dto_test.dart
  17. 13 1
      server/apps/immich/src/api-v1/asset/asset-repository.ts
  18. 2 2
      server/apps/immich/src/api-v1/asset/asset.module.ts
  19. 4 0
      server/apps/immich/src/api-v1/asset/dto/update-asset.dto.ts
  20. 0 1
      server/apps/microservices/src/processors/metadata-extraction.processor.ts
  21. 8 0
      server/immich-openapi-specs.json
  22. 2 0
      server/libs/domain/src/asset/response-dto/exif-response.dto.ts
  23. 1 0
      server/libs/domain/test/fixtures.ts
  24. 12 0
      web/src/api/open-api/api.ts
  25. 19 1
      web/src/lib/components/asset-viewer/asset-viewer.svelte
  26. 52 2
      web/src/lib/components/asset-viewer/detail-panel.svelte
  27. 36 15
      web/src/lib/components/shared-components/side-bar/side-bar.svelte

+ 7 - 1
mobile/assets/i18n/en-US.json

@@ -262,5 +262,11 @@
   "version_announcement_overlay_text_1": "Hi friend, there is a new release of",
   "version_announcement_overlay_text_2": "please take your time to visit the ",
   "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
-  "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89"
+  "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
+  "advanced_settings_tile_title": "Advanced",
+  "advanced_settings_tile_subtitle": "Advanced user's settings",
+  "advanced_settings_troubleshooting_title": "Troubleshooting",
+  "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
+  "description_input_submit_error": "Error updating description, check the log for more details",
+  "description_input_hint_text": "Add description..."
 }

BIN
mobile/flutter_01.png


+ 93 - 0
mobile/lib/modules/asset_viewer/providers/asset_description.provider.dart

@@ -0,0 +1,93 @@
+import 'dart:async';
+
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/asset_viewer/services/asset_description.service.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/models/exif_info.dart';
+import 'package:immich_mobile/shared/providers/db.provider.dart';
+import 'package:isar/isar.dart';
+
+class AssetDescriptionNotifier extends StateNotifier<String> {
+  final Isar _db;
+  final AssetDescriptionService _service;
+  final Asset _asset;
+
+  AssetDescriptionNotifier(
+    this._db,
+    this._service, 
+    this._asset,
+  ) : super('') {
+    _fetchLocalDescription();
+    _fetchRemoteDescription();
+  }
+
+  String get description => state;
+
+  /// Fetches the local database value for description
+  /// and writes it to [state]
+  void _fetchLocalDescription() async {
+    final localExifId = _asset.exifInfo?.id;
+
+    // Guard [localExifId] null
+    if (localExifId == null) {
+      return;
+    }
+
+    // Subscribe to local changes
+    final exifInfo = await _db
+        .exifInfos
+        .get(localExifId);
+
+    // Guard
+    if (exifInfo?.description == null) {
+      return;
+    }
+
+    state = exifInfo!.description!;
+  }
+
+  /// Fetches the remote value and sets the state
+  void _fetchRemoteDescription() async {
+    final remoteAssetId = _asset.remoteId;
+    final localExifId = _asset.exifInfo?.id;
+
+    // Guard [remoteAssetId] and [localExifId] null
+    if (remoteAssetId == null || localExifId == null) {
+      return;
+    }
+
+    // Reads the latest from the remote and writes it to DB in the service
+    final latest = await _service.readLatest(remoteAssetId, localExifId);
+
+    state = latest;
+  }
+
+  /// Sets the description to [description]
+  /// Uses the service to set the asset value
+  Future<void> setDescription(String description) async {
+    state = description;
+
+    final remoteAssetId = _asset.remoteId;
+    final localExifId = _asset.exifInfo?.id;
+
+    // Guard [remoteAssetId] and [localExifId] null
+    if (remoteAssetId == null || localExifId == null) {
+      return;
+    }
+
+    return _service
+        .setDescription(description, remoteAssetId, localExifId);
+  }
+}
+
+final assetDescriptionProvider = StateNotifierProvider
+    .autoDispose
+    .family<AssetDescriptionNotifier, String, Asset>(
+  (ref, asset) => AssetDescriptionNotifier(
+    ref.watch(dbProvider),
+    ref.watch(assetDescriptionServiceProvider),
+    asset,
+  ),
+);
+
+

+ 62 - 0
mobile/lib/modules/asset_viewer/services/asset_description.service.dart

@@ -0,0 +1,62 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/exif_info.dart';
+import 'package:immich_mobile/shared/providers/api.provider.dart';
+import 'package:immich_mobile/shared/providers/db.provider.dart';
+import 'package:immich_mobile/shared/services/api.service.dart';
+import 'package:isar/isar.dart';
+import 'package:openapi/api.dart';
+
+class AssetDescriptionService {
+  AssetDescriptionService(this._db, this._api);
+
+  final Isar _db;
+  final ApiService _api;
+
+  setDescription(
+    String description,
+    String remoteAssetId,
+    int localExifId,
+  ) async {
+    final result = await _api.assetApi.updateAsset(
+      remoteAssetId,
+      UpdateAssetDto(description: description),
+    );
+
+    if (result?.exifInfo?.description != null) {
+      var exifInfo = await _db.exifInfos.get(localExifId);
+
+      if (exifInfo != null) {
+        exifInfo.description = result!.exifInfo!.description;
+        await _db.writeTxn(
+          () => _db.exifInfos.put(exifInfo),
+        );
+      }
+    }
+  }
+
+  Future<String> readLatest(String assetRemoteId, int localExifId) async {
+    final latestAssetFromServer =
+        await _api.assetApi.getAssetById(assetRemoteId);
+    final localExifInfo = await _db.exifInfos.get(localExifId);
+
+    if (latestAssetFromServer != null && localExifInfo != null) {
+      localExifInfo.description =
+          latestAssetFromServer.exifInfo?.description ?? '';
+
+      await _db.writeTxn(
+        () => _db.exifInfos.put(localExifInfo),
+      );
+
+      return localExifInfo.description!;
+    }
+
+    return "";
+  }
+}
+
+final assetDescriptionServiceProvider = Provider(
+  (ref) => AssetDescriptionService(
+    ref.watch(dbProvider),
+    ref.watch(apiServiceProvider),
+  ),
+);

+ 103 - 0
mobile/lib/modules/asset_viewer/ui/description_input.dart

@@ -0,0 +1,103 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/asset_viewer/providers/asset_description.provider.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/ui/immich_toast.dart';
+import 'package:logging/logging.dart';
+import 'package:immich_mobile/shared/models/store.dart' as store;
+
+class DescriptionInput extends HookConsumerWidget {
+  DescriptionInput({
+    super.key,
+    required this.asset,
+  });
+
+  final Asset asset;
+  final Logger _log = Logger('DescriptionInput');
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
+    final textColor = isDarkTheme ? Colors.white : Colors.black;
+    final controller = useTextEditingController();
+    final focusNode = useFocusNode();
+    final isFocus = useState(false);
+    final isTextEmpty = useState(controller.text.isEmpty);
+    final descriptionProvider = ref.watch(assetDescriptionProvider(asset).notifier);
+    final description = ref.watch(assetDescriptionProvider(asset));
+    final owner = store.Store.get(store.StoreKey.currentUser);
+    final hasError = useState(false);
+
+    controller.text = description;
+
+    submitDescription(String description) async {
+      hasError.value = false;
+      try {
+        await descriptionProvider.setDescription(
+          description,
+        );
+      } catch (error, stack) {
+        hasError.value = true;
+        _log.severe("Error updating description $error", error, stack);
+        ImmichToast.show(
+          context: context,
+          msg: "description_input_submit_error".tr(),
+          toastType: ToastType.error,
+        );
+      }
+    }
+
+    Widget? suffixIcon;
+    if (hasError.value) {
+      suffixIcon = const Icon(Icons.warning_outlined);
+    } else if (!isTextEmpty.value && isFocus.value) {
+      suffixIcon = IconButton(
+        onPressed: () {
+          controller.clear();
+          isTextEmpty.value = true;
+        },
+        icon: Icon(
+          Icons.cancel_rounded,
+          color: Colors.grey[500],
+        ),
+        splashRadius: 10,
+      );
+    }
+
+    return TextField(
+      enabled: owner.isarId == asset.ownerId,
+      focusNode: focusNode,
+      onTap: () => isFocus.value = true,
+      onChanged: (value) {
+        isTextEmpty.value = false;
+      },
+      onTapOutside: (a) async {
+        isFocus.value = false;
+        focusNode.unfocus();
+
+        if (description != controller.text) {
+          await submitDescription(controller.text);
+        }
+      },
+      autofocus: false,
+      maxLines: null,
+      keyboardType: TextInputType.multiline,
+      controller: controller,
+      style: const TextStyle(
+        fontSize: 14,
+      ),
+      decoration: InputDecoration(
+        hintText: 'description_input_hint_text'.tr(),
+        border: InputBorder.none,
+        hintStyle: TextStyle(
+          fontWeight: FontWeight.normal,
+          fontSize: 12,
+          color: textColor.withOpacity(0.5),
+        ),
+        suffixIcon: suffixIcon,
+      ),
+    );
+  }
+}

+ 119 - 101
mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart

@@ -2,25 +2,25 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_map/flutter_map.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
-import 'package:immich_mobile/shared/models/exif_info.dart';
 import 'package:immich_mobile/shared/ui/drag_sheet.dart';
 import 'package:latlong2/latlong.dart';
 import 'package:immich_mobile/utils/bytes_units.dart';
 
 class ExifBottomSheet extends HookConsumerWidget {
-  final Asset assetDetail;
+  final Asset asset;
 
-  const ExifBottomSheet({Key? key, required this.assetDetail})
-      : super(key: key);
+  const ExifBottomSheet({Key? key, required this.asset}) : super(key: key);
 
   bool get showMap =>
-      assetDetail.exifInfo?.latitude != null &&
-      assetDetail.exifInfo?.longitude != null;
+      asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null;
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final ExifInfo? exifInfo = assetDetail.exifInfo;
+    final exifInfo = asset.exifInfo;
+    var isDarkTheme = Theme.of(context).brightness == Brightness.dark;
+    var textColor = isDarkTheme ? Colors.white : Colors.black;
 
     buildMap() {
       return Padding(
@@ -76,19 +76,6 @@ class ExifBottomSheet extends HookConsumerWidget {
       );
     }
 
-    final textColor = Theme.of(context).primaryColor;
-
-    buildLocationText() {
-      return Text(
-        "${exifInfo?.city}, ${exifInfo?.state}",
-        style: TextStyle(
-          fontSize: 12,
-          fontWeight: FontWeight.bold,
-          color: textColor,
-        ),
-      );
-    }
-
     buildSizeText(Asset a) {
       String resolution = a.width != null && a.height != null
           ? "${a.height} x ${a.width}  "
@@ -128,13 +115,39 @@ class ExifBottomSheet extends HookConsumerWidget {
             children: [
               Text(
                 "exif_bottom_sheet_location",
-                style: TextStyle(fontSize: 11, color: textColor),
+                style: TextStyle(
+                  fontSize: 11,
+                  color: textColor,
+                  fontWeight: FontWeight.bold,
+                ),
               ).tr(),
               buildMap(),
-              if (exifInfo != null &&
-                  exifInfo.city != null &&
-                  exifInfo.state != null)
-                buildLocationText(),
+              RichText(
+                text: TextSpan(
+                  style: TextStyle(
+                    fontSize: 12,
+                    fontWeight: FontWeight.bold,
+                    color: textColor,
+                    fontFamily: 'WorkSans',
+                  ),
+                  children: [
+                    if (exifInfo != null && exifInfo.city != null)
+                      TextSpan(
+                        text: exifInfo.city,
+                      ),
+                    if (exifInfo != null &&
+                        exifInfo.city != null &&
+                        exifInfo.state != null)
+                      const TextSpan(
+                        text: ", ",
+                      ),
+                    if (exifInfo != null && exifInfo.state != null)
+                      TextSpan(
+                        text: "${exifInfo.state}",
+                      ),
+                  ],
+                ),
+              ),
               Text(
                 "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}",
                 style: const TextStyle(fontSize: 12),
@@ -146,7 +159,7 @@ class ExifBottomSheet extends HookConsumerWidget {
     }
 
     buildDate() {
-      final fileCreatedAt = assetDetail.fileCreatedAt.toLocal();
+      final fileCreatedAt = asset.fileCreatedAt.toLocal();
       final date = DateFormat.yMMMEd().format(fileCreatedAt);
       final time = DateFormat.jm().format(fileCreatedAt);
 
@@ -167,27 +180,37 @@ class ExifBottomSheet extends HookConsumerWidget {
             padding: const EdgeInsets.only(bottom: 8.0),
             child: Text(
               "exif_bottom_sheet_details",
-              style: TextStyle(fontSize: 11, color: textColor),
+              style: TextStyle(
+                fontSize: 11,
+                color: textColor,
+                fontWeight: FontWeight.bold,
+              ),
             ).tr(),
           ),
           ListTile(
             contentPadding: const EdgeInsets.all(0),
             dense: true,
-            leading: const Icon(Icons.image),
+            leading: Icon(
+              Icons.image,
+              color: textColor.withAlpha(200),
+            ),
             title: Text(
-              assetDetail.fileName,
+              asset.fileName,
               style: TextStyle(
                 fontWeight: FontWeight.bold,
                 color: textColor,
               ),
             ),
-            subtitle: buildSizeText(assetDetail),
+            subtitle: buildSizeText(asset),
           ),
           if (exifInfo?.make != null)
             ListTile(
               contentPadding: const EdgeInsets.all(0),
               dense: true,
-              leading: const Icon(Icons.camera),
+              leading: Icon(
+                Icons.camera,
+                color: textColor.withAlpha(200),
+              ),
               title: Text(
                 "${exifInfo!.make} ${exifInfo.model}",
                 style: TextStyle(
@@ -203,80 +226,75 @@ class ExifBottomSheet extends HookConsumerWidget {
       );
     }
 
-    return SingleChildScrollView(
-      child: Card(
-        shape: const RoundedRectangleBorder(
-          borderRadius: BorderRadius.only(
-            topLeft: Radius.circular(15),
-            topRight: Radius.circular(15),
+    return GestureDetector(
+      onTap: () {
+        // FocusScope.of(context).unfocus();
+      },
+      child: SingleChildScrollView(
+        child: Card(
+          shape: const RoundedRectangleBorder(
+            borderRadius: BorderRadius.only(
+              topLeft: Radius.circular(15),
+              topRight: Radius.circular(15),
+            ),
           ),
-        ),
-        margin: const EdgeInsets.all(0),
-        child: Container(
-          margin: const EdgeInsets.symmetric(horizontal: 8.0),
-          child: LayoutBuilder(
-            builder: (context, constraints) {
-              if (constraints.maxWidth > 600) {
-                // Two column
-                return Padding(
-                  padding: const EdgeInsets.symmetric(horizontal: 12.0),
-                  child: Column(
-                    crossAxisAlignment: CrossAxisAlignment.stretch,
-                    children: [
-                      buildDragHeader(),
-                      buildDate(),
-                      const SizedBox(height: 32.0),
-                      Row(
-                        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
-                        crossAxisAlignment: CrossAxisAlignment.start,
-                        children: [
-                          Flexible(
-                            flex: showMap ? 5 : 0,
-                            child: Padding(
-                              padding: const EdgeInsets.only(right: 8.0),
-                              child: buildLocation(),
+          margin: const EdgeInsets.all(0),
+          child: Container(
+            margin: const EdgeInsets.symmetric(horizontal: 16.0),
+            child: LayoutBuilder(
+              builder: (context, constraints) {
+                if (constraints.maxWidth > 600) {
+                  // Two column
+                  return Padding(
+                    padding: const EdgeInsets.symmetric(horizontal: 12.0),
+                    child: Column(
+                      crossAxisAlignment: CrossAxisAlignment.stretch,
+                      children: [
+                        buildDragHeader(),
+                        buildDate(),
+                        if (asset.isRemote) DescriptionInput(asset: asset),
+                        Row(
+                          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+                          crossAxisAlignment: CrossAxisAlignment.start,
+                          children: [
+                            Flexible(
+                              flex: showMap ? 5 : 0,
+                              child: Padding(
+                                padding: const EdgeInsets.only(right: 8.0),
+                                child: buildLocation(),
+                              ),
                             ),
-                          ),
-                          Flexible(
-                            flex: 5,
-                            child: Padding(
-                              padding: const EdgeInsets.only(left: 8.0),
-                              child: buildDetail(),
+                            Flexible(
+                              flex: 5,
+                              child: Padding(
+                                padding: const EdgeInsets.only(left: 8.0),
+                                child: buildDetail(),
+                              ),
                             ),
-                          ),
-                        ],
-                      ),
-                      const SizedBox(height: 50),
-                    ],
-                  ),
-                );
-              }
-
-              // One column
-              return Column(
-                crossAxisAlignment: CrossAxisAlignment.stretch,
-                children: [
-                  buildDragHeader(),
-                  buildDate(),
-                  const SizedBox(height: 16.0),
-                  if (showMap)
-                    Divider(
-                      thickness: 1,
-                      color: Colors.grey[600],
+                          ],
+                        ),
+                        const SizedBox(height: 50),
+                      ],
                     ),
-                  const SizedBox(height: 16.0),
-                  buildLocation(),
-                  const SizedBox(height: 16.0),
-                  Divider(
-                    thickness: 1,
-                    color: Colors.grey[600],
-                  ),
-                  const SizedBox(height: 16.0),
-                  buildDetail(),
-                  const SizedBox(height: 50),
-                ],
-              );
-            },
+                  );
+                }
+
+                // One column
+                return Column(
+                  crossAxisAlignment: CrossAxisAlignment.stretch,
+                  children: [
+                    buildDragHeader(),
+                    buildDate(),
+                    if (asset.isRemote) DescriptionInput(asset: asset),
+                    const SizedBox(height: 8.0),
+                    buildLocation(),
+                    SizedBox(height: showMap ? 16.0 : 0.0),
+                    buildDetail(),
+                    const SizedBox(height: 50),
+                  ],
+                );
+              },
+            ),
           ),
         ),
       ),

+ 6 - 1
mobile/lib/modules/asset_viewer/views/gallery_viewer.dart

@@ -195,7 +195,12 @@ class GalleryViewerPage extends HookConsumerWidget {
               .getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)) {
             return AdvancedBottomSheet(assetDetail: assetDetail!);
           }
-          return ExifBottomSheet(assetDetail: assetDetail!);
+          return Padding(
+            padding: EdgeInsets.only(
+              bottom: MediaQuery.of(context).viewInsets.bottom,
+            ),
+            child: ExifBottomSheet(asset: assetDetail!),
+          );
         },
       );
     }

+ 11 - 4
mobile/lib/modules/settings/ui/advanced_settings/advanced_settings.dart

@@ -1,3 +1,4 @@
+import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -25,19 +26,25 @@ class AdvancedSettings extends HookConsumerWidget {
     return ExpansionTile(
       textColor: Theme.of(context).primaryColor,
       title: const Text(
-        "Advanced",
+        "advanced_settings_tile_title",
         style: TextStyle(
           fontWeight: FontWeight.bold,
         ),
-      ),
+      ).tr(),
+      subtitle: const Text(
+        "advanced_settings_tile_subtitle",
+        style: TextStyle(
+          fontSize: 13,
+        ),
+      ).tr(),
       children: [
         SettingsSwitchListTile(
           enabled: true,
           appSettingService: appSettingService,
           valueNotifier: isEnabled,
           settingsEnum: AppSettingsEnum.advancedTroubleshooting,
-          title: "Troubleshooting",
-          subtitle: "Enable additional features for troubleshooting",
+          title: "advanced_settings_troubleshooting_title".tr(),
+          subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
         ),
       ],
     );

+ 4 - 1
mobile/lib/shared/models/exif_info.dart

@@ -21,6 +21,7 @@ class ExifInfo {
   String? city;
   String? state;
   String? country;
+  String? description;
 
   @ignore
   String get exposureTime {
@@ -58,7 +59,8 @@ class ExifInfo {
         long = dto.longitude?.toDouble(),
         city = dto.city,
         state = dto.state,
-        country = dto.country;
+        country = dto.country,
+        description = dto.description;
 
   ExifInfo({
     this.fileSize,
@@ -74,6 +76,7 @@ class ExifInfo {
     this.city,
     this.state,
     this.country,
+    this.description,
   });
 }
 

+ 243 - 42
mobile/lib/shared/models/exif_info.g.dart

@@ -27,58 +27,63 @@ const ExifInfoSchema = CollectionSchema(
       name: r'country',
       type: IsarType.string,
     ),
-    r'exposureSeconds': PropertySchema(
+    r'description': PropertySchema(
       id: 2,
+      name: r'description',
+      type: IsarType.string,
+    ),
+    r'exposureSeconds': PropertySchema(
+      id: 3,
       name: r'exposureSeconds',
       type: IsarType.float,
     ),
     r'f': PropertySchema(
-      id: 3,
+      id: 4,
       name: r'f',
       type: IsarType.float,
     ),
     r'fileSize': PropertySchema(
-      id: 4,
+      id: 5,
       name: r'fileSize',
       type: IsarType.long,
     ),
     r'iso': PropertySchema(
-      id: 5,
+      id: 6,
       name: r'iso',
       type: IsarType.int,
     ),
     r'lat': PropertySchema(
-      id: 6,
+      id: 7,
       name: r'lat',
       type: IsarType.float,
     ),
     r'lens': PropertySchema(
-      id: 7,
+      id: 8,
       name: r'lens',
       type: IsarType.string,
     ),
     r'long': PropertySchema(
-      id: 8,
+      id: 9,
       name: r'long',
       type: IsarType.float,
     ),
     r'make': PropertySchema(
-      id: 9,
+      id: 10,
       name: r'make',
       type: IsarType.string,
     ),
     r'mm': PropertySchema(
-      id: 10,
+      id: 11,
       name: r'mm',
       type: IsarType.float,
     ),
     r'model': PropertySchema(
-      id: 11,
+      id: 12,
       name: r'model',
       type: IsarType.string,
     ),
     r'state': PropertySchema(
-      id: 12,
+      id: 13,
       name: r'state',
       type: IsarType.string,
     )
@@ -115,6 +120,12 @@ int _exifInfoEstimateSize(
       bytesCount += 3 + value.length * 3;
     }
   }
+  {
+    final value = object.description;
+    if (value != null) {
+      bytesCount += 3 + value.length * 3;
+    }
+  }
   {
     final value = object.lens;
     if (value != null) {
@@ -150,17 +161,18 @@ void _exifInfoSerialize(
 ) {
   writer.writeString(offsets[0], object.city);
   writer.writeString(offsets[1], object.country);
-  writer.writeFloat(offsets[2], object.exposureSeconds);
-  writer.writeFloat(offsets[3], object.f);
-  writer.writeLong(offsets[4], object.fileSize);
-  writer.writeInt(offsets[5], object.iso);
-  writer.writeFloat(offsets[6], object.lat);
-  writer.writeString(offsets[7], object.lens);
-  writer.writeFloat(offsets[8], object.long);
-  writer.writeString(offsets[9], object.make);
-  writer.writeFloat(offsets[10], object.mm);
-  writer.writeString(offsets[11], object.model);
-  writer.writeString(offsets[12], object.state);
+  writer.writeString(offsets[2], object.description);
+  writer.writeFloat(offsets[3], object.exposureSeconds);
+  writer.writeFloat(offsets[4], object.f);
+  writer.writeLong(offsets[5], object.fileSize);
+  writer.writeInt(offsets[6], object.iso);
+  writer.writeFloat(offsets[7], object.lat);
+  writer.writeString(offsets[8], object.lens);
+  writer.writeFloat(offsets[9], object.long);
+  writer.writeString(offsets[10], object.make);
+  writer.writeFloat(offsets[11], object.mm);
+  writer.writeString(offsets[12], object.model);
+  writer.writeString(offsets[13], object.state);
 }
 
 ExifInfo _exifInfoDeserialize(
@@ -172,17 +184,18 @@ ExifInfo _exifInfoDeserialize(
   final object = ExifInfo(
     city: reader.readStringOrNull(offsets[0]),
     country: reader.readStringOrNull(offsets[1]),
-    exposureSeconds: reader.readFloatOrNull(offsets[2]),
-    f: reader.readFloatOrNull(offsets[3]),
-    fileSize: reader.readLongOrNull(offsets[4]),
-    iso: reader.readIntOrNull(offsets[5]),
-    lat: reader.readFloatOrNull(offsets[6]),
-    lens: reader.readStringOrNull(offsets[7]),
-    long: reader.readFloatOrNull(offsets[8]),
-    make: reader.readStringOrNull(offsets[9]),
-    mm: reader.readFloatOrNull(offsets[10]),
-    model: reader.readStringOrNull(offsets[11]),
-    state: reader.readStringOrNull(offsets[12]),
+    description: reader.readStringOrNull(offsets[2]),
+    exposureSeconds: reader.readFloatOrNull(offsets[3]),
+    f: reader.readFloatOrNull(offsets[4]),
+    fileSize: reader.readLongOrNull(offsets[5]),
+    iso: reader.readIntOrNull(offsets[6]),
+    lat: reader.readFloatOrNull(offsets[7]),
+    lens: reader.readStringOrNull(offsets[8]),
+    long: reader.readFloatOrNull(offsets[9]),
+    make: reader.readStringOrNull(offsets[10]),
+    mm: reader.readFloatOrNull(offsets[11]),
+    model: reader.readStringOrNull(offsets[12]),
+    state: reader.readStringOrNull(offsets[13]),
   );
   object.id = id;
   return object;
@@ -200,27 +213,29 @@ P _exifInfoDeserializeProp<P>(
     case 1:
       return (reader.readStringOrNull(offset)) as P;
     case 2:
-      return (reader.readFloatOrNull(offset)) as P;
+      return (reader.readStringOrNull(offset)) as P;
     case 3:
       return (reader.readFloatOrNull(offset)) as P;
     case 4:
-      return (reader.readLongOrNull(offset)) as P;
+      return (reader.readFloatOrNull(offset)) as P;
     case 5:
-      return (reader.readIntOrNull(offset)) as P;
+      return (reader.readLongOrNull(offset)) as P;
     case 6:
-      return (reader.readFloatOrNull(offset)) as P;
+      return (reader.readIntOrNull(offset)) as P;
     case 7:
-      return (reader.readStringOrNull(offset)) as P;
-    case 8:
       return (reader.readFloatOrNull(offset)) as P;
-    case 9:
+    case 8:
       return (reader.readStringOrNull(offset)) as P;
-    case 10:
+    case 9:
       return (reader.readFloatOrNull(offset)) as P;
-    case 11:
+    case 10:
       return (reader.readStringOrNull(offset)) as P;
+    case 11:
+      return (reader.readFloatOrNull(offset)) as P;
     case 12:
       return (reader.readStringOrNull(offset)) as P;
+    case 13:
+      return (reader.readStringOrNull(offset)) as P;
     default:
       throw IsarError('Unknown property with id $propertyId');
   }
@@ -607,6 +622,155 @@ extension ExifInfoQueryFilter
     });
   }
 
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionIsNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNull(
+        property: r'description',
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
+      descriptionIsNotNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNotNull(
+        property: r'description',
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionEqualTo(
+    String? value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'description',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
+      descriptionGreaterThan(
+    String? value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'description',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionLessThan(
+    String? value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'description',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionBetween(
+    String? lower,
+    String? upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'description',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionStartsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.startsWith(
+        property: r'description',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionEndsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.endsWith(
+        property: r'description',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionContains(
+      String value,
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.contains(
+        property: r'description',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionMatches(
+      String pattern,
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.matches(
+        property: r'description',
+        wildcard: pattern,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> descriptionIsEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'description',
+        value: '',
+      ));
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
+      descriptionIsNotEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        property: r'description',
+        value: '',
+      ));
+    });
+  }
+
   QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
       exposureSecondsIsNull() {
     return QueryBuilder.apply(this, (query) {
@@ -1825,6 +1989,18 @@ extension ExifInfoQuerySortBy on QueryBuilder<ExifInfo, ExifInfo, QSortBy> {
     });
   }
 
+  QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByDescription() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'description', Sort.asc);
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByDescriptionDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'description', Sort.desc);
+    });
+  }
+
   QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByExposureSeconds() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'exposureSeconds', Sort.asc);
@@ -1984,6 +2160,18 @@ extension ExifInfoQuerySortThenBy
     });
   }
 
+  QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByDescription() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'description', Sort.asc);
+    });
+  }
+
+  QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByDescriptionDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'description', Sort.desc);
+    });
+  }
+
   QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByExposureSeconds() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'exposureSeconds', Sort.asc);
@@ -2145,6 +2333,13 @@ extension ExifInfoQueryWhereDistinct
     });
   }
 
+  QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByDescription(
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'description', caseSensitive: caseSensitive);
+    });
+  }
+
   QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByExposureSeconds() {
     return QueryBuilder.apply(this, (query) {
       return query.addDistinctBy(r'exposureSeconds');
@@ -2236,6 +2431,12 @@ extension ExifInfoQueryProperty
     });
   }
 
+  QueryBuilder<ExifInfo, String?, QQueryOperations> descriptionProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'description');
+    });
+  }
+
   QueryBuilder<ExifInfo, double?, QQueryOperations> exposureSecondsProperty() {
     return QueryBuilder.apply(this, (query) {
       return query.addPropertyName(r'exposureSeconds');

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

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

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

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

+ 14 - 3
mobile/openapi/lib/model/exif_response_dto.dart

@@ -32,6 +32,7 @@ class ExifResponseDto {
     this.city,
     this.state,
     this.country,
+    this.description,
   });
 
   int? fileSizeInByte;
@@ -72,6 +73,8 @@ class ExifResponseDto {
 
   String? country;
 
+  String? description;
+
   @override
   bool operator ==(Object other) => identical(this, other) || other is ExifResponseDto &&
      other.fileSizeInByte == fileSizeInByte &&
@@ -92,7 +95,8 @@ class ExifResponseDto {
      other.longitude == longitude &&
      other.city == city &&
      other.state == state &&
-     other.country == country;
+     other.country == country &&
+     other.description == description;
 
   @override
   int get hashCode =>
@@ -115,10 +119,11 @@ class ExifResponseDto {
     (longitude == null ? 0 : longitude!.hashCode) +
     (city == null ? 0 : city!.hashCode) +
     (state == null ? 0 : state!.hashCode) +
-    (country == null ? 0 : country!.hashCode);
+    (country == null ? 0 : country!.hashCode) +
+    (description == null ? 0 : description!.hashCode);
 
   @override
-  String toString() => 'ExifResponseDto[fileSizeInByte=$fileSizeInByte, make=$make, model=$model, exifImageWidth=$exifImageWidth, exifImageHeight=$exifImageHeight, orientation=$orientation, dateTimeOriginal=$dateTimeOriginal, modifyDate=$modifyDate, timeZone=$timeZone, lensModel=$lensModel, fNumber=$fNumber, focalLength=$focalLength, iso=$iso, exposureTime=$exposureTime, latitude=$latitude, longitude=$longitude, city=$city, state=$state, country=$country]';
+  String toString() => 'ExifResponseDto[fileSizeInByte=$fileSizeInByte, make=$make, model=$model, exifImageWidth=$exifImageWidth, exifImageHeight=$exifImageHeight, orientation=$orientation, dateTimeOriginal=$dateTimeOriginal, modifyDate=$modifyDate, timeZone=$timeZone, lensModel=$lensModel, fNumber=$fNumber, focalLength=$focalLength, iso=$iso, exposureTime=$exposureTime, latitude=$latitude, longitude=$longitude, city=$city, state=$state, country=$country, description=$description]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -217,6 +222,11 @@ class ExifResponseDto {
     } else {
       // json[r'country'] = null;
     }
+    if (this.description != null) {
+      json[r'description'] = this.description;
+    } else {
+      // json[r'description'] = null;
+    }
     return json;
   }
 
@@ -272,6 +282,7 @@ class ExifResponseDto {
         city: mapValueOfType<String>(json, r'city'),
         state: mapValueOfType<String>(json, r'state'),
         country: mapValueOfType<String>(json, r'country'),
+        description: mapValueOfType<String>(json, r'description'),
       );
     }
     return null;

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

@@ -16,6 +16,7 @@ class UpdateAssetDto {
     this.tagIds = const [],
     this.isFavorite,
     this.isArchived,
+    this.description,
   });
 
   List<String> tagIds;
@@ -36,21 +37,31 @@ class UpdateAssetDto {
   ///
   bool? isArchived;
 
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  String? description;
+
   @override
   bool operator ==(Object other) => identical(this, other) || other is UpdateAssetDto &&
      other.tagIds == tagIds &&
      other.isFavorite == isFavorite &&
-     other.isArchived == isArchived;
+     other.isArchived == isArchived &&
+     other.description == description;
 
   @override
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     (tagIds.hashCode) +
     (isFavorite == null ? 0 : isFavorite!.hashCode) +
-    (isArchived == null ? 0 : isArchived!.hashCode);
+    (isArchived == null ? 0 : isArchived!.hashCode) +
+    (description == null ? 0 : description!.hashCode);
 
   @override
-  String toString() => 'UpdateAssetDto[tagIds=$tagIds, isFavorite=$isFavorite, isArchived=$isArchived]';
+  String toString() => 'UpdateAssetDto[tagIds=$tagIds, isFavorite=$isFavorite, isArchived=$isArchived, description=$description]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -65,6 +76,11 @@ class UpdateAssetDto {
     } else {
       // json[r'isArchived'] = null;
     }
+    if (this.description != null) {
+      json[r'description'] = this.description;
+    } else {
+      // json[r'description'] = null;
+    }
     return json;
   }
 
@@ -92,6 +108,7 @@ class UpdateAssetDto {
             : const [],
         isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
         isArchived: mapValueOfType<bool>(json, r'isArchived'),
+        description: mapValueOfType<String>(json, r'description'),
       );
     }
     return null;

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

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

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

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

+ 13 - 1
server/apps/immich/src/api-v1/asset/asset-repository.ts

@@ -1,6 +1,6 @@
 import { SearchPropertiesDto } from './dto/search-properties.dto';
 import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
-import { AssetEntity, AssetType } from '@app/infra/entities';
+import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
 import { Inject, Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm/repository/Repository';
@@ -55,6 +55,7 @@ export class AssetRepository implements IAssetRepository {
     private assetRepository: Repository<AssetEntity>,
 
     @Inject(ITagRepository) private _tagRepository: ITagRepository,
+    @InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
   ) {}
 
   async getAllVideos(): Promise<AssetEntity[]> {
@@ -268,6 +269,17 @@ export class AssetRepository implements IAssetRepository {
       asset.tags = tags;
     }
 
+    if (asset.exifInfo != null) {
+      asset.exifInfo.description = dto.description || '';
+      await this.exifRepository.save(asset.exifInfo);
+    } else {
+      const exifInfo = new ExifEntity();
+      exifInfo.description = dto.description || '';
+      exifInfo.asset = asset;
+      await this.exifRepository.save(exifInfo);
+      asset.exifInfo = exifInfo;
+    }
+
     return await this.assetRepository.save(asset);
   }
 

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

@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
 import { AssetService } from './asset.service';
 import { AssetController } from './asset.controller';
 import { TypeOrmModule } from '@nestjs/typeorm';
-import { AssetEntity } from '@app/infra/entities';
+import { AssetEntity, ExifEntity } from '@app/infra/entities';
 import { AssetRepository, IAssetRepository } from './asset-repository';
 import { DownloadModule } from '../../modules/download/download.module';
 import { TagModule } from '../tag/tag.module';
@@ -16,7 +16,7 @@ const ASSET_REPOSITORY_PROVIDER = {
 @Module({
   imports: [
     //
-    TypeOrmModule.forFeature([AssetEntity]),
+    TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
     DownloadModule,
     TagModule,
     AlbumModule,

+ 4 - 0
server/apps/immich/src/api-v1/asset/dto/update-asset.dto.ts

@@ -25,4 +25,8 @@ export class UpdateAssetDto {
     ],
   })
   tagIds?: string[];
+
+  @IsOptional()
+  @IsString()
+  description?: string;
 }

+ 0 - 1
server/apps/microservices/src/processors/metadata-extraction.processor.ts

@@ -223,7 +223,6 @@ export class MetadataExtractionProcessor {
 
       const newExif = new ExifEntity();
       newExif.assetId = asset.id;
-      newExif.description = '';
       newExif.fileSizeInByte = data.format.size || null;
       newExif.dateTimeOriginal = fileCreatedAt ? new Date(fileCreatedAt) : null;
       newExif.modifyDate = null;

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

@@ -3675,6 +3675,11 @@
             "type": "string",
             "nullable": true,
             "default": null
+          },
+          "description": {
+            "type": "string",
+            "nullable": true,
+            "default": null
           }
         }
       },
@@ -5283,6 +5288,9 @@
           },
           "isArchived": {
             "type": "boolean"
+          },
+          "description": {
+            "type": "string"
           }
         }
       },

+ 2 - 0
server/libs/domain/src/asset/response-dto/exif-response.dto.ts

@@ -23,6 +23,7 @@ export class ExifResponseDto {
   city?: string | null = null;
   state?: string | null = null;
   country?: string | null = null;
+  description?: string | null = null;
 }
 
 export function mapExif(entity: ExifEntity): ExifResponseDto {
@@ -46,5 +47,6 @@ export function mapExif(entity: ExifEntity): ExifResponseDto {
     city: entity.city,
     state: entity.state,
     country: entity.country,
+    description: entity.description,
   };
 }

+ 1 - 0
server/libs/domain/test/fixtures.ts

@@ -343,6 +343,7 @@ const assetInfo: ExifResponseDto = {
   city: 'city',
   state: 'state',
   country: 'country',
+  description: 'description',
 };
 
 const assetResponse: AssetResponseDto = {

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

@@ -1208,6 +1208,12 @@ export interface ExifResponseDto {
      * @memberof ExifResponseDto
      */
     'country'?: string | null;
+    /**
+     * 
+     * @type {string}
+     * @memberof ExifResponseDto
+     */
+    'description'?: string | null;
 }
 /**
  * 
@@ -2341,6 +2347,12 @@ export interface UpdateAssetDto {
      * @memberof UpdateAssetDto
      */
     'isArchived'?: boolean;
+    /**
+     * 
+     * @type {string}
+     * @memberof UpdateAssetDto
+     */
+    'description'?: string;
 }
 /**
  * 

+ 19 - 1
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -251,6 +251,18 @@
 			}
 		});
 	};
+
+	const disableKeyDownEvent = () => {
+		if (browser) {
+			document.removeEventListener('keydown', onKeyboardPress);
+		}
+	};
+
+	const enableKeyDownEvent = () => {
+		if (browser) {
+			document.addEventListener('keydown', onKeyboardPress);
+		}
+	};
 </script>
 
 <section
@@ -352,7 +364,13 @@
 			class="bg-immich-bg w-[360px] row-span-full transition-all overflow-y-auto dark:bg-immich-dark-bg dark:border-l dark:border-l-immich-dark-gray"
 			translate="yes"
 		>
-			<DetailPanel {asset} albums={appearsInAlbums} on:close={() => (isShowDetail = false)} />
+			<DetailPanel
+				{asset}
+				albums={appearsInAlbums}
+				on:close={() => (isShowDetail = false)}
+				on:description-focus-in={disableKeyDownEvent}
+				on:description-focus-out={enableKeyDownEvent}
+			/>
 		</div>
 	{/if}
 

+ 52 - 2
web/src/lib/components/asset-viewer/detail-panel.svelte

@@ -5,14 +5,26 @@
 	import CameraIris from 'svelte-material-icons/CameraIris.svelte';
 	import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte';
 	import { createEventDispatcher } from 'svelte';
-	import { AssetResponseDto, AlbumResponseDto } from '@api';
+	import { AssetResponseDto, AlbumResponseDto, api } from '@api';
 	import { asByteUnitString } from '../../utils/byte-units';
 	import { locale } from '$lib/stores/preferences.store';
 	import { DateTime } from 'luxon';
 	import type { LatLngTuple } from 'leaflet';
+	import { page } from '$app/stores';
 
 	export let asset: AssetResponseDto;
 	export let albums: AlbumResponseDto[] = [];
+	let textarea: HTMLTextAreaElement;
+	let description: string;
+
+	$: {
+		// Get latest description from server
+		if (asset.id) {
+			api.assetApi
+				.getAssetById(asset.id)
+				.then((res) => (textarea.value = res.data?.exifInfo?.description || ''));
+		}
+	}
 
 	$: latlng = (() => {
 		const lat = asset.exifInfo?.latitude;
@@ -34,6 +46,27 @@
 
 		return undefined;
 	};
+
+	const autoGrowHeight = (e: Event) => {
+		const target = e.target as HTMLTextAreaElement;
+		target.style.height = 'auto';
+		target.style.height = `${target.scrollHeight}px`;
+	};
+
+	const handleFocusIn = () => {
+		dispatch('description-focus-in');
+	};
+
+	const handleFocusOut = async () => {
+		dispatch('description-focus-out');
+		try {
+			await api.assetApi.updateAsset(asset.id, {
+				description: description
+			});
+		} catch (error) {
+			console.error(error);
+		}
+	};
 </script>
 
 <section class="p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
@@ -48,6 +81,23 @@
 		<p class="text-immich-fg dark:text-immich-dark-fg text-lg">Info</p>
 	</div>
 
+	<div class="mx-4 mt-10">
+		<textarea
+			bind:this={textarea}
+			class="max-h-[500px]
+      text-base text-black bg-transparent dark:text-white border-b focus:border-b-2 border-gray-500 w-full focus:border-immich-primary dark:focus:border-immich-dark-primary transition-all resize-none overflow-hidden outline-none disabled:border-none"
+			placeholder={$page?.data?.user?.id !== asset.ownerId ? '' : 'Add a description'}
+			style:display={$page?.data?.user?.id !== asset.ownerId && textarea?.value == ''
+				? 'none'
+				: 'block'}
+			on:focusin={handleFocusIn}
+			on:focusout={handleFocusOut}
+			on:input={autoGrowHeight}
+			bind:value={description}
+			disabled={$page?.data?.user?.id !== asset.ownerId}
+		/>
+	</div>
+
 	<div class="px-4 py-4">
 		{#if !asset.exifInfo}
 			<p class="text-sm pb-4">NO EXIF INFO AVAILABLE</p>
@@ -178,7 +228,7 @@
 <section class="p-2 dark:text-immich-dark-fg">
 	<div class="px-4 py-4">
 		{#if albums.length > 0}
-			<p class="text-sm pb-4 ">APPEARS IN</p>
+			<p class="text-sm pb-4">APPEARS IN</p>
 		{/if}
 		{#each albums as album}
 			<a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}>

+ 36 - 15
web/src/lib/components/shared-components/side-bar/side-bar.svelte

@@ -24,29 +24,50 @@
 	};
 
 	const getFavoriteCount = async () => {
-		const { data: assets } = await api.assetApi.getAllAssets(true, undefined);
+		try {
+			const { data: assets } = await api.assetApi.getAllAssets(true, undefined);
 
-		return {
-			favorites: assets.length
-		};
+			return {
+				favorites: assets.length
+			};
+		} catch {
+			return {
+				favorites: 0
+			};
+		}
 	};
 
 	const getAlbumCount = async () => {
-		const { data: albumCount } = await api.albumApi.getAlbumCountByUserId();
-		return {
-			shared: albumCount.shared,
-			sharing: albumCount.sharing,
-			owned: albumCount.owned
-		};
+		try {
+			const { data: albumCount } = await api.albumApi.getAlbumCountByUserId();
+			return {
+				shared: albumCount.shared,
+				sharing: albumCount.sharing,
+				owned: albumCount.owned
+			};
+		} catch {
+			return {
+				shared: 0,
+				sharing: 0,
+				owned: 0
+			};
+		}
 	};
 
 	const getArchivedAssetsCount = async () => {
-		const { data: assetCount } = await api.assetApi.getArchivedAssetCountByUserId();
+		try {
+			const { data: assetCount } = await api.assetApi.getArchivedAssetCountByUserId();
 
-		return {
-			videos: assetCount.videos,
-			photos: assetCount.photos
-		};
+			return {
+				videos: assetCount.videos,
+				photos: assetCount.photos
+			};
+		} catch {
+			return {
+				videos: 0,
+				photos: 0
+			};
+		}
 	};
 </script>