From fe1e09e51f565b0e521862b5f3e18a8b5b1f588a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20K=C3=BCndig?= <christian@kuendig.info>
Date: Tue, 28 Jan 2025 04:54:29 +0100
Subject: [PATCH] fix(server): Allow negative rating (for rejected images)
 (#15699)

Allow negative rating (for rejected images)
---
 e2e/src/api/specs/asset.e2e-spec.ts                | 14 ++++++++++++++
 .../openapi/lib/model/asset_bulk_update_dto.dart   |  2 +-
 mobile/openapi/lib/model/update_asset_dto.dart     |  2 +-
 open-api/immich-openapi-specs.json                 |  4 ++--
 server/src/dtos/asset.dto.ts                       |  2 +-
 server/src/services/metadata.service.spec.ts       | 11 +++++++++++
 server/src/services/metadata.service.ts            |  2 +-
 7 files changed, 31 insertions(+), 6 deletions(-)

diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts
index 32cbdd6df8..1b644454aa 100644
--- a/e2e/src/api/specs/asset.e2e-spec.ts
+++ b/e2e/src/api/specs/asset.e2e-spec.ts
@@ -701,6 +701,20 @@ describe('/asset', () => {
       expect(status).toEqual(200);
     });
 
+    it('should set the negative rating', async () => {
+      const { status, body } = await request(app)
+        .put(`/assets/${user1Assets[0].id}`)
+        .set('Authorization', `Bearer ${user1.accessToken}`)
+        .send({ rating: -1 });
+      expect(body).toMatchObject({
+        id: user1Assets[0].id,
+        exifInfo: expect.objectContaining({
+          rating: -1,
+        }),
+      });
+      expect(status).toEqual(200);
+    });
+
     it('should reject invalid rating', async () => {
       for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) {
         const { status, body } = await request(app)
diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart
index da23d2f09d..0b5a2c30d9 100644
--- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart
+++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart
@@ -67,7 +67,7 @@ class AssetBulkUpdateDto {
   ///
   num? longitude;
 
-  /// Minimum value: 0
+  /// Minimum value: -1
   /// Maximum value: 5
   ///
   /// Please note: This property should have been non-nullable! Since the specification file
diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart
index 9ebce5fd92..c6ae6d8e07 100644
--- a/mobile/openapi/lib/model/update_asset_dto.dart
+++ b/mobile/openapi/lib/model/update_asset_dto.dart
@@ -73,7 +73,7 @@ class UpdateAssetDto {
   ///
   num? longitude;
 
-  /// Minimum value: 0
+  /// Minimum value: -1
   /// Maximum value: 5
   ///
   /// Please note: This property should have been non-nullable! Since the specification file
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index fc62b58290..3067b25449 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -7951,7 +7951,7 @@
           },
           "rating": {
             "maximum": 5,
-            "minimum": 0,
+            "minimum": -1,
             "type": "number"
           }
         },
@@ -12780,7 +12780,7 @@
           },
           "rating": {
             "maximum": 5,
-            "minimum": 0,
+            "minimum": -1,
             "type": "number"
           }
         },
diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts
index 42d6d7d745..8aa63f2f69 100644
--- a/server/src/dtos/asset.dto.ts
+++ b/server/src/dtos/asset.dto.ts
@@ -52,7 +52,7 @@ export class UpdateAssetBase {
   @Optional()
   @IsInt()
   @Max(5)
-  @Min(0)
+  @Min(-1)
   rating?: number;
 }
 
diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts
index 8cc6e014d2..99ca1e7ed3 100644
--- a/server/src/services/metadata.service.spec.ts
+++ b/server/src/services/metadata.service.spec.ts
@@ -1162,6 +1162,17 @@ describe(MetadataService.name, () => {
         }),
       );
     });
+    it('should handle valid negative rating value', async () => {
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
+      mockReadTags({ Rating: -1 });
+
+      await sut.handleMetadataExtraction({ id: assetStub.image.id });
+      expect(assetMock.upsertExif).toHaveBeenCalledWith(
+        expect.objectContaining({
+          rating: -1,
+        }),
+      );
+    });
   });
 
   describe('handleQueueSidecar', () => {
diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts
index d5b7e6e4e4..db3af9fca0 100644
--- a/server/src/services/metadata.service.ts
+++ b/server/src/services/metadata.service.ts
@@ -204,7 +204,7 @@ export class MetadataService extends BaseService {
       // comments
       description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
       profileDescription: exifTags.ProfileDescription || null,
-      rating: validateRange(exifTags.Rating, 0, 5),
+      rating: validateRange(exifTags.Rating, -1, 5),
 
       // grouping
       livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,