From 2325a359a6f3f0d092e7f8683a1a790f8895d956 Mon Sep 17 00:00:00 2001 From: Mees Frensel Date: Fri, 17 Apr 2026 14:39:24 +0200 Subject: [PATCH 1/7] refactor!: disallow star rating of -1 --- e2e/src/specs/server/api/asset.e2e-spec.ts | 14 ----- mobile/openapi/lib/api/search_api.dart | 8 +-- .../lib/model/asset_bulk_update_dto.dart | 2 +- .../openapi/lib/model/exif_response_dto.dart | 9 +-- .../lib/model/metadata_search_dto.dart | 8 +-- .../openapi/lib/model/random_search_dto.dart | 8 +-- .../openapi/lib/model/smart_search_dto.dart | 8 +-- .../lib/model/statistics_search_dto.dart | 8 +-- .../openapi/lib/model/update_asset_dto.dart | 2 +- open-api/immich-openapi-specs.json | 63 +++++++++++++++---- .../src/controllers/asset.controller.spec.ts | 11 +--- server/src/dtos/asset.dto.ts | 5 +- server/src/dtos/exif.dto.ts | 2 +- server/src/dtos/search.dto.ts | 5 +- ...76429021231-ConvertNegativeRatingToNull.ts | 9 +++ server/src/services/metadata.service.spec.ts | 14 ----- server/src/services/metadata.service.ts | 2 +- server/src/utils/duplicate.spec.ts | 2 +- 18 files changed, 92 insertions(+), 88 deletions(-) create mode 100644 server/src/schema/migrations/1776429021231-ConvertNegativeRatingToNull.ts diff --git a/e2e/src/specs/server/api/asset.e2e-spec.ts b/e2e/src/specs/server/api/asset.e2e-spec.ts index 3fbacd5bf6a43..f99f697f2605f 100644 --- a/e2e/src/specs/server/api/asset.e2e-spec.ts +++ b/e2e/src/specs/server/api/asset.e2e-spec.ts @@ -566,20 +566,6 @@ 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 return tagged people', async () => { const { status, body } = await request(app) .put(`/assets/${user1Assets[0].id}`) diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 730627d4a14f6..a8afba68b6aff 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -404,7 +404,7 @@ class SearchApi { /// * [List] personIds: /// Filter by person IDs /// - /// * [num] rating: + /// * [int] rating: /// Filter by rating [1-5], or null for unrated /// /// * [num] size: @@ -443,7 +443,7 @@ class SearchApi { /// /// * [bool] withExif: /// Include EXIF data in response - Future searchLargeAssetsWithHttpInfo({ List? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List? personIds, num? rating, num? size, String? state, List? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async { + Future searchLargeAssetsWithHttpInfo({ List? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List? personIds, int? rating, num? size, String? state, List? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async { // ignore: prefer_const_declarations final apiPath = r'/search/large-assets'; @@ -619,7 +619,7 @@ class SearchApi { /// * [List] personIds: /// Filter by person IDs /// - /// * [num] rating: + /// * [int] rating: /// Filter by rating [1-5], or null for unrated /// /// * [num] size: @@ -658,7 +658,7 @@ class SearchApi { /// /// * [bool] withExif: /// Include EXIF data in response - Future?> searchLargeAssets({ List? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List? personIds, num? rating, num? size, String? state, List? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async { + Future?> searchLargeAssets({ List? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List? personIds, int? rating, num? size, String? state, List? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async { final response = await searchLargeAssetsWithHttpInfo( albumIds: albumIds, city: city, country: country, createdAfter: createdAfter, createdBefore: createdBefore, isEncoded: isEncoded, isFavorite: isFavorite, isMotion: isMotion, isNotInAlbum: isNotInAlbum, isOffline: isOffline, lensModel: lensModel, libraryId: libraryId, make: make, minFileSize: minFileSize, model: model, ocr: ocr, personIds: personIds, rating: rating, size: size, state: state, tagIds: tagIds, takenAfter: takenAfter, takenBefore: takenBefore, trashedAfter: trashedAfter, trashedBefore: trashedBefore, type: type, updatedAfter: updatedAfter, updatedBefore: updatedBefore, visibility: visibility, withDeleted: withDeleted, withExif: withExif, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index f97300b19f8bb..ee55e6fc062f1 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -94,7 +94,7 @@ class AssetBulkUpdateDto { /// Rating in range [1-5], or null for unrated /// - /// Minimum value: -1 + /// Minimum value: 1 /// Maximum value: 5 int? rating; diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index 64a5a73bed778..f4e3ab3813644 100644 --- a/mobile/openapi/lib/model/exif_response_dto.dart +++ b/mobile/openapi/lib/model/exif_response_dto.dart @@ -102,7 +102,10 @@ class ExifResponseDto { String? projectionType; /// Rating - num? rating; + /// + /// Minimum value: 1 + /// Maximum value: 5 + int? rating; /// State/province name String? state; @@ -321,9 +324,7 @@ class ExifResponseDto { modifyDate: mapDateTime(json, r'modifyDate', r''), orientation: mapValueOfType(json, r'orientation'), projectionType: mapValueOfType(json, r'projectionType'), - rating: json[r'rating'] == null - ? null - : num.parse('${json[r'rating']}'), + rating: mapValueOfType(json, r'rating'), state: mapValueOfType(json, r'state'), timeZone: mapValueOfType(json, r'timeZone'), ); diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index d49ea7a4e5072..d3943905d868d 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -237,9 +237,9 @@ class MetadataSearchDto { /// Filter by rating [1-5], or null for unrated /// - /// Minimum value: -1 + /// Minimum value: 1 /// Maximum value: 5 - num? rating; + int? rating; /// Number of results to return /// @@ -729,9 +729,7 @@ class MetadataSearchDto { ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], previewPath: mapValueOfType(json, r'previewPath'), - rating: json[r'rating'] == null - ? null - : num.parse('${json[r'rating']}'), + rating: mapValueOfType(json, r'rating'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 3f33d8f850fbb..06f3f1407cf13 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -145,9 +145,9 @@ class RandomSearchDto { /// Filter by rating [1-5], or null for unrated /// - /// Minimum value: -1 + /// Minimum value: 1 /// Maximum value: 5 - num? rating; + int? rating; /// Number of results to return /// @@ -549,9 +549,7 @@ class RandomSearchDto { personIds: json[r'personIds'] is Iterable ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], - rating: json[r'rating'] == null - ? null - : num.parse('${json[r'rating']}'), + rating: mapValueOfType(json, r'rating'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index bf1465223e421..e6398a947b684 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -185,9 +185,9 @@ class SmartSearchDto { /// Filter by rating [1-5], or null for unrated /// - /// Minimum value: -1 + /// Minimum value: 1 /// Maximum value: 5 - num? rating; + int? rating; /// Number of results to return /// @@ -589,9 +589,7 @@ class SmartSearchDto { : const [], query: mapValueOfType(json, r'query'), queryAssetId: mapValueOfType(json, r'queryAssetId'), - rating: json[r'rating'] == null - ? null - : num.parse('${json[r'rating']}'), + rating: mapValueOfType(json, r'rating'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable diff --git a/mobile/openapi/lib/model/statistics_search_dto.dart b/mobile/openapi/lib/model/statistics_search_dto.dart index d0070e8e1276c..6f0170bf138c7 100644 --- a/mobile/openapi/lib/model/statistics_search_dto.dart +++ b/mobile/openapi/lib/model/statistics_search_dto.dart @@ -150,9 +150,9 @@ class StatisticsSearchDto { /// Filter by rating [1-5], or null for unrated /// - /// Minimum value: -1 + /// Minimum value: 1 /// Maximum value: 5 - num? rating; + int? rating; /// Filter by state/province name String? state; @@ -479,9 +479,7 @@ class StatisticsSearchDto { personIds: json[r'personIds'] is Iterable ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], - rating: json[r'rating'] == null - ? null - : num.parse('${json[r'rating']}'), + rating: mapValueOfType(json, r'rating'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 2c4c3352eac8d..927bb2c9f3973 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -79,7 +79,7 @@ class UpdateAssetDto { /// Rating in range [1-5], or null for unrated /// - /// Minimum value: -1 + /// Minimum value: 1 /// Maximum value: 5 int? rating; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 68dc98e807d11..6c167297f2626 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9336,12 +9336,17 @@ "version": "v2.6.0", "state": "Updated", "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + }, + { + "version": "v3", + "state": "Updated", + "description": "Using -1 as a rating is no longer valid." } ], "x-immich-state": "Stable", "schema": { - "type": "number", - "minimum": -1, + "type": "integer", + "minimum": 1, "maximum": 5, "nullable": true } @@ -15687,7 +15692,7 @@ "rating": { "description": "Rating in range [1-5], or null for unrated", "maximum": 5, - "minimum": -1, + "minimum": 1, "nullable": true, "type": "integer", "x-immich-history": [ @@ -15703,6 +15708,11 @@ "version": "v2.6.0", "state": "Updated", "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + }, + { + "version": "v3", + "state": "Updated", + "description": "Using -1 as a rating is no longer valid." } ], "x-immich-state": "Stable" @@ -17735,8 +17745,10 @@ "rating": { "default": null, "description": "Rating", + "maximum": 5, + "minimum": 1, "nullable": true, - "type": "number" + "type": "integer" }, "state": { "default": null, @@ -18755,9 +18767,9 @@ "rating": { "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, - "minimum": -1, + "minimum": 1, "nullable": true, - "type": "number", + "type": "integer", "x-immich-history": [ { "version": "v1", @@ -18771,6 +18783,11 @@ "version": "v2.6.0", "state": "Updated", "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + }, + { + "version": "v3", + "state": "Updated", + "description": "Using -1 as a rating is no longer valid." } ], "x-immich-state": "Stable" @@ -20596,9 +20613,9 @@ "rating": { "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, - "minimum": -1, + "minimum": 1, "nullable": true, - "type": "number", + "type": "integer", "x-immich-history": [ { "version": "v1", @@ -20612,6 +20629,11 @@ "version": "v2.6.0", "state": "Updated", "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + }, + { + "version": "v3", + "state": "Updated", + "description": "Using -1 as a rating is no longer valid." } ], "x-immich-state": "Stable" @@ -21990,9 +22012,9 @@ "rating": { "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, - "minimum": -1, + "minimum": 1, "nullable": true, - "type": "number", + "type": "integer", "x-immich-history": [ { "version": "v1", @@ -22006,6 +22028,11 @@ "version": "v2.6.0", "state": "Updated", "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + }, + { + "version": "v3", + "state": "Updated", + "description": "Using -1 as a rating is no longer valid." } ], "x-immich-state": "Stable" @@ -22250,9 +22277,9 @@ "rating": { "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, - "minimum": -1, + "minimum": 1, "nullable": true, - "type": "number", + "type": "integer", "x-immich-history": [ { "version": "v1", @@ -22266,6 +22293,11 @@ "version": "v2.6.0", "state": "Updated", "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + }, + { + "version": "v3", + "state": "Updated", + "description": "Using -1 as a rating is no longer valid." } ], "x-immich-state": "Stable" @@ -25195,7 +25227,7 @@ "rating": { "description": "Rating in range [1-5], or null for unrated", "maximum": 5, - "minimum": -1, + "minimum": 1, "nullable": true, "type": "integer", "x-immich-history": [ @@ -25211,6 +25243,11 @@ "version": "v2.6.0", "state": "Updated", "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + }, + { + "version": "v3", + "state": "Updated", + "description": "Using -1 as a rating is no longer valid." } ], "x-immich-state": "Stable" diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 3c01e3d0a939f..3e3b792cfa87f 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -214,23 +214,16 @@ describe(AssetController.name, () => { }); it('should reject invalid rating', async () => { - for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) { + for (const test of [{ rating: 0 }, { rating: 7 }, { rating: 3.5 }, { rating: -2 }]) { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test); expect(status).toBe(400); expect(body).toEqual(factory.responses.badRequest()); } }); - it('should convert rating 0 to null', async () => { - const assetId = factory.uuid(); - const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send({ rating: 0 }); - expect(service.update).toHaveBeenCalledWith(undefined, assetId, { rating: null }); - expect(status).toBe(200); - }); - it('should leave correct ratings as-is', async () => { const assetId = factory.uuid(); - for (const test of [{ rating: -1 }, { rating: 1 }, { rating: 5 }]) { + for (const test of [{ rating: 1 }, { rating: 5 }]) { const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send(test); expect(service.update).toHaveBeenCalledWith(undefined, assetId, test); expect(status).toBe(200); diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 1362a86ed70d5..7453ec3ad95ac 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -14,11 +14,9 @@ const UpdateAssetBaseSchema = z latitude: latitudeSchema.optional().describe('Latitude coordinate'), longitude: longitudeSchema.optional().describe('Longitude coordinate'), rating: z - .number() .int() - .min(-1) + .min(1) .max(5) - .transform((value) => (value === 0 ? null : value)) .nullish() .describe('Rating in range [1-5], or null for unrated') .meta({ @@ -26,6 +24,7 @@ const UpdateAssetBaseSchema = z .added('v1') .stable('v2') .updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.') + .updated('v3', 'Using -1 as a rating is no longer valid.') .getExtensions(), }), description: z.string().optional().describe('Asset description'), diff --git a/server/src/dtos/exif.dto.ts b/server/src/dtos/exif.dto.ts index c3e1ab36c8375..aeeba5f5b6f5c 100644 --- a/server/src/dtos/exif.dto.ts +++ b/server/src/dtos/exif.dto.ts @@ -29,7 +29,7 @@ export const ExifResponseSchema = z country: z.string().nullish().default(null).describe('Country name'), description: z.string().nullish().default(null).describe('Image description'), projectionType: z.string().nullish().default(null).describe('Projection type'), - rating: z.number().nullish().default(null).describe('Rating'), + rating: z.int().min(1).max(5).nullish().default(null).describe('Rating'), }) .describe('EXIF response') .meta({ id: 'ExifResponseDto' }); diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index c0362cdb5dce2..4fed130fa795e 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -34,8 +34,8 @@ const BaseSearchSchema = z.object({ tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'), albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'), rating: z - .number() - .min(-1) + .int() + .min(1) .max(5) .nullish() .describe('Filter by rating [1-5], or null for unrated') @@ -44,6 +44,7 @@ const BaseSearchSchema = z.object({ .added('v1') .stable('v2') .updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.') + .updated('v3', 'Using -1 as a rating is no longer valid.') .getExtensions(), }), ocr: z.string().optional().describe('Filter by OCR text content'), diff --git a/server/src/schema/migrations/1776429021231-ConvertNegativeRatingToNull.ts b/server/src/schema/migrations/1776429021231-ConvertNegativeRatingToNull.ts new file mode 100644 index 0000000000000..f54136dc2ce3d --- /dev/null +++ b/server/src/schema/migrations/1776429021231-ConvertNegativeRatingToNull.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`UPDATE "asset_exif" SET "rating" = NULL WHERE "rating" = -1;`.execute(db); +} + +export async function down(): Promise { + // not supported +} diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 245bb441a606e..95f9415e77795 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1436,20 +1436,6 @@ describe(MetadataService.name, () => { ); }); - it('should handle valid negative rating value', async () => { - const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); - mockReadTags({ Rating: -1 }); - - await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - expect.objectContaining({ - rating: -1, - }), - { lockedPropertiesBehavior: 'skip' }, - ); - }); - it('should handle livePhotoCID not set', async () => { const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index c548d94c74ec3..760b06d40b761 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -304,7 +304,7 @@ export class MetadataService extends BaseService { // comments description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), profileDescription: exifTags.ProfileDescription || null, - rating: exifTags.Rating === 0 ? null : validateRange(exifTags.Rating, -1, 5), + rating: exifTags.Rating === 0 ? null : validateRange(exifTags.Rating, 1, 5), // grouping livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, diff --git a/server/src/utils/duplicate.spec.ts b/server/src/utils/duplicate.spec.ts index d63f0d3e3286f..4ba8408d4bb85 100644 --- a/server/src/utils/duplicate.spec.ts +++ b/server/src/utils/duplicate.spec.ts @@ -78,7 +78,7 @@ describe('duplicate utils', () => { model: null, latitude: undefined, city: '', - rating: 0, + rating: null, }); // fileSizeInByte (1000) + make ('Canon') = 2 truthy values // model (null), latitude (undefined), city (''), rating (0) are all falsy From f16a83f65b7d22ee3e3023924d44e3ae28cd295d Mon Sep 17 00:00:00 2001 From: Mees Frensel Date: Thu, 23 Apr 2026 15:06:57 +0200 Subject: [PATCH 2/7] keep allowing 0 and convert to null internally --- mobile/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/controllers/asset.controller.spec.ts | 9 ++++++++- server/src/dtos/asset.dto.ts | 3 ++- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index ee55e6fc062f1..42edc40f5194a 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -94,7 +94,7 @@ class AssetBulkUpdateDto { /// Rating in range [1-5], or null for unrated /// - /// Minimum value: 1 + /// Minimum value: 0 /// Maximum value: 5 int? rating; diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 927bb2c9f3973..19cc90a12f674 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -79,7 +79,7 @@ class UpdateAssetDto { /// Rating in range [1-5], or null for unrated /// - /// Minimum value: 1 + /// Minimum value: 0 /// Maximum value: 5 int? rating; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 6c167297f2626..812ff015351fe 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -15692,7 +15692,7 @@ "rating": { "description": "Rating in range [1-5], or null for unrated", "maximum": 5, - "minimum": 1, + "minimum": 0, "nullable": true, "type": "integer", "x-immich-history": [ @@ -25227,7 +25227,7 @@ "rating": { "description": "Rating in range [1-5], or null for unrated", "maximum": 5, - "minimum": 1, + "minimum": 0, "nullable": true, "type": "integer", "x-immich-history": [ diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 3e3b792cfa87f..941ba1b19b840 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -214,13 +214,20 @@ describe(AssetController.name, () => { }); it('should reject invalid rating', async () => { - for (const test of [{ rating: 0 }, { rating: 7 }, { rating: 3.5 }, { rating: -2 }]) { + for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test); expect(status).toBe(400); expect(body).toEqual(factory.responses.badRequest()); } }); + it('should convert rating 0 to null', async () => { + const assetId = factory.uuid(); + const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send({ rating: 0 }); + expect(service.update).toHaveBeenCalledWith(undefined, assetId, { rating: null }); + expect(status).toBe(200); + }); + it('should leave correct ratings as-is', async () => { const assetId = factory.uuid(); for (const test of [{ rating: 1 }, { rating: 5 }]) { diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 7453ec3ad95ac..8dd29a4391cfd 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -15,8 +15,9 @@ const UpdateAssetBaseSchema = z longitude: longitudeSchema.optional().describe('Longitude coordinate'), rating: z .int() - .min(1) + .min(0) .max(5) + .transform((value) => (value === 0 ? null : value)) .nullish() .describe('Rating in range [1-5], or null for unrated') .meta({ From 7d2316054630d274fba5c72fbbbcfb7e07c8baa5 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler Date: Thu, 4 Jun 2026 14:06:02 +0200 Subject: [PATCH 3/7] chore: constrain rating to [1,5] --- .../openapi/lib/model/asset_bulk_update_dto.dart | 2 +- mobile/openapi/lib/model/metadata_search_dto.dart | 2 +- mobile/openapi/lib/model/random_search_dto.dart | 2 +- mobile/openapi/lib/model/smart_search_dto.dart | 2 +- .../openapi/lib/model/statistics_search_dto.dart | 2 +- mobile/openapi/lib/model/update_asset_dto.dart | 2 +- open-api/immich-openapi-specs.json | 14 +++++++------- server/src/controllers/asset.controller.spec.ts | 9 +-------- server/src/dtos/asset.dto.ts | 3 +-- server/src/dtos/search.dto.ts | 3 +-- 10 files changed, 16 insertions(+), 25 deletions(-) diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index 94f6e18118dc5..f5e679274f8c3 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -97,7 +97,7 @@ class AssetBulkUpdateDto { /// Rating in range [1-5], or null for unrated /// - /// Minimum value: 0 + /// Minimum value: 1 /// Maximum value: 5 Optional rating; diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 11c6927ff165c..2fd717b4f05d7 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -238,7 +238,7 @@ class MetadataSearchDto { /// Filter by rating [1-5], or null for unrated /// - /// Minimum value: 0 + /// Minimum value: 1 /// Maximum value: 5 Optional rating; diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 7c571db2b8885..d8a2a7635ae94 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -145,7 +145,7 @@ class RandomSearchDto { /// Filter by rating [1-5], or null for unrated /// - /// Minimum value: 0 + /// Minimum value: 1 /// Maximum value: 5 Optional rating; diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index b01a50351c78c..dfd4d60ffd762 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -186,7 +186,7 @@ class SmartSearchDto { /// Filter by rating [1-5], or null for unrated /// - /// Minimum value: 0 + /// Minimum value: 1 /// Maximum value: 5 Optional rating; diff --git a/mobile/openapi/lib/model/statistics_search_dto.dart b/mobile/openapi/lib/model/statistics_search_dto.dart index 1705c63c972d1..9410d6ab6302b 100644 --- a/mobile/openapi/lib/model/statistics_search_dto.dart +++ b/mobile/openapi/lib/model/statistics_search_dto.dart @@ -150,7 +150,7 @@ class StatisticsSearchDto { /// Filter by rating [1-5], or null for unrated /// - /// Minimum value: 0 + /// Minimum value: 1 /// Maximum value: 5 Optional rating; diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index c106fcd240ff7..73a84984949a9 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -79,7 +79,7 @@ class UpdateAssetDto { /// Rating in range [1-5], or null for unrated /// - /// Minimum value: 0 + /// Minimum value: 1 /// Maximum value: 5 Optional rating; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 50c695049812f..434079e6a1c05 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9895,7 +9895,7 @@ "x-immich-state": "Stable", "schema": { "type": "integer", - "minimum": 0, + "minimum": 1, "maximum": 5, "nullable": true } @@ -16335,7 +16335,7 @@ "rating": { "description": "Rating in range [1-5], or null for unrated", "maximum": 5, - "minimum": 0, + "minimum": 1, "nullable": true, "type": "integer", "x-immich-history": [ @@ -19378,7 +19378,7 @@ "rating": { "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, - "minimum": 0, + "minimum": 1, "nullable": true, "type": "integer", "x-immich-history": [ @@ -21056,7 +21056,7 @@ "rating": { "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, - "minimum": 0, + "minimum": 1, "nullable": true, "type": "integer", "x-immich-history": [ @@ -22525,7 +22525,7 @@ "rating": { "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, - "minimum": 0, + "minimum": 1, "nullable": true, "type": "integer", "x-immich-history": [ @@ -22790,7 +22790,7 @@ "rating": { "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, - "minimum": 0, + "minimum": 1, "nullable": true, "type": "integer", "x-immich-history": [ @@ -25990,7 +25990,7 @@ "rating": { "description": "Rating in range [1-5], or null for unrated", "maximum": 5, - "minimum": 0, + "minimum": 1, "nullable": true, "type": "integer", "x-immich-history": [ diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 488081a7bc188..b0191f7217249 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -240,7 +240,7 @@ describe(AssetController.name, () => { for (const [test, errors] of [ [{ rating: 7 }, [{ path: ['rating'], message: 'Too big: expected number to be <=5' }]], [{ rating: 3.5 }, [{ path: ['rating'], message: 'Invalid input: expected int, received number' }]], - [{ rating: -2 }, [{ path: ['rating'], message: 'Too small: expected number to be >=-1' }]], + [{ rating: -2 }, [{ path: ['rating'], message: 'Too small: expected number to be >=1' }]], ] as const) { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test); expect(status).toBe(400); @@ -248,13 +248,6 @@ describe(AssetController.name, () => { } }); - it('should convert rating 0 to null', async () => { - const assetId = factory.uuid(); - const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send({ rating: 0 }); - expect(service.update).toHaveBeenCalledWith(undefined, assetId, { rating: null }); - expect(status).toBe(200); - }); - it('should leave correct ratings as-is', async () => { const assetId = factory.uuid(); for (const test of [{ rating: 1 }, { rating: 5 }]) { diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 20dba03b1130d..5072b3aa17359 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -15,9 +15,8 @@ const UpdateAssetBaseSchema = z longitude: longitudeSchema.optional().describe('Longitude coordinate'), rating: z .int() - .min(0) + .min(1) .max(5) - .transform((value) => (value === 0 ? null : value)) .nullish() .describe('Rating in range [1-5], or null for unrated') .meta({ diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 0369316ec175e..ec4d58dae3bd9 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -35,9 +35,8 @@ const BaseSearchSchema = z.object({ albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'), rating: z .int() - .min(0) + .min(1) .max(5) - .transform((value) => (value === 0 ? null : value)) .nullish() .describe('Filter by rating [1-5], or null for unrated') .meta({ From 6b9ec46c4b90a03f242ff4e6d5be0457c66733ea Mon Sep 17 00:00:00 2001 From: timonrieger Date: Thu, 4 Jun 2026 15:14:57 +0200 Subject: [PATCH 4/7] fix(mobile): clear rating sends null instead of 0 --- .../repositories/remote_asset.repository.dart | 2 +- .../asset_viewer/asset_details/rating_details.widget.dart | 2 +- .../widgets/asset_viewer/rating_bar.widget.dart | 6 +++++- mobile/lib/providers/infrastructure/action.provider.dart | 2 +- mobile/lib/repositories/asset_api.repository.dart | 2 +- mobile/lib/services/action.service.dart | 2 +- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 7d4e23c22bd85..2e4a239a0b6d3 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -267,7 +267,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository { ); } - Future updateRating(String assetId, int rating) async { + Future updateRating(String assetId, int? rating) async { await (_db.remoteExifEntity.update()..where((row) => row.assetId.equals(assetId))).write( RemoteExifEntityCompanion(rating: Value(rating)), ); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart index 10566261192ab..e501c2ee3e153 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart @@ -44,7 +44,7 @@ class RatingDetails extends ConsumerWidget { await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round()); }, onClearRating: () async { - await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0); + await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, null); }, ), ], diff --git a/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart index bd4935e41f2ce..b956ef103c54b 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart @@ -77,7 +77,11 @@ class _RatingBarState extends State { setState(() { _currentRating = newRating; }); - widget.onRatingUpdate?.call(newRating.round()); + if (newRating == 0) { + widget.onClearRating?.call(); + } else { + widget.onRatingUpdate?.call(newRating.round()); + } } } diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 6fdd9fc5c95a5..426c028822bac 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -466,7 +466,7 @@ class ActionNotifier extends Notifier { } } - Future updateRating(ActionSource source, int rating) async { + Future updateRating(ActionSource source, int? rating) async { final ids = _getRemoteIdsForSource(source); if (ids.length != 1) { _logger.warning('updateRating called with multiple assets, expected single asset'); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 4a23ced504cd8..0fdfb7e21b494 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -97,7 +97,7 @@ class AssetApiRepository extends ApiRepository { return _api.updateAsset(assetId, UpdateAssetDto(description: Optional.present(description))); } - Future updateRating(String assetId, int rating) { + Future updateRating(String assetId, int? rating) { return _api.updateAsset(assetId, UpdateAssetDto(rating: Optional.present(rating))); } diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index b22c6680a4dd0..88f33395f2c80 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -231,7 +231,7 @@ class ActionService { return true; } - Future updateRating(String assetId, int rating) async { + Future updateRating(String assetId, int? rating) async { // update remote first, then local to ensure consistency await _assetApiRepository.updateRating(assetId, rating); await _remoteAssetRepository.updateRating(assetId, rating); From f83855be7fb09566d5522a1ea1c48e2c3e324ced Mon Sep 17 00:00:00 2001 From: timonrieger Date: Thu, 4 Jun 2026 15:35:22 +0200 Subject: [PATCH 5/7] refactor(mobile): use Optional in SearchRatingFilter --- .../repositories/search_api.repository.dart | 5 +++-- .../models/search/search_filter.model.dart | 20 +++++++++++++------ .../pages/search/drift_search.page.dart | 9 ++++++--- mobile/lib/utils/option.dart | 9 +++++++++ .../search_filter/star_rating_picker.dart | 5 +++-- 5 files changed, 35 insertions(+), 13 deletions(-) diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart index e2f2af17ebff2..395d4045cf5d1 100644 --- a/mobile/lib/infrastructure/repositories/search_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart @@ -1,6 +1,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility; import 'package:immich_mobile/infrastructure/repositories/api.repository.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:immich_mobile/utils/option.dart'; import 'package:openapi/api.dart'; class SearchApiRepository extends ApiRepository { @@ -37,7 +38,7 @@ class SearchApiRepository extends ApiRepository { ? const Optional.absent() : Optional.present(filter.date.takenBefore!), visibility: Optional.present(filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline), - rating: filter.rating.rating == null ? const Optional.absent() : Optional.present(filter.rating.rating!), + rating: filter.rating.rating.toOptional(), isFavorite: filter.display.isFavorite ? const Optional.present(true) : const Optional.absent(), isNotInAlbum: filter.display.isNotInAlbum ? const Optional.present(true) : const Optional.absent(), personIds: Optional.present(filter.people.map((e) => e.id).toList()), @@ -70,7 +71,7 @@ class SearchApiRepository extends ApiRepository { ? const Optional.absent() : Optional.present(filter.date.takenBefore!), visibility: Optional.present(filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline), - rating: filter.rating.rating == null ? const Optional.absent() : Optional.present(filter.rating.rating!), + rating: filter.rating.rating.toOptional(), isFavorite: filter.display.isFavorite ? const Optional.present(true) : const Optional.absent(), isNotInAlbum: filter.display.isNotInAlbum ? const Optional.present(true) : const Optional.absent(), personIds: Optional.present(filter.people.map((e) => e.id).toList()), diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index cf1a1dcdafb8b..825acb32c6b89 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; +import 'package:immich_mobile/utils/option.dart'; class SearchLocationFilter { String? country; @@ -133,19 +134,26 @@ class SearchDateFilter { } class SearchRatingFilter { - int? rating; - SearchRatingFilter({this.rating}); + /// none = no filter; some(null) = filter for unrated; some(1-5) = filter for that rating + Option rating; + SearchRatingFilter({this.rating = const Option.none()}); - SearchRatingFilter copyWith({int? rating}) { + SearchRatingFilter copyWith({Option? rating}) { return SearchRatingFilter(rating: rating ?? this.rating); } Map toMap() { - return {'rating': rating}; + if (rating.isNone) { + return {'active': false}; + } + return {'active': true, 'value': rating.unwrapOrNull}; } factory SearchRatingFilter.fromMap(Map map) { - return SearchRatingFilter(rating: map['rating'] != null ? map['rating'] as int : null); + if (!(map['active'] as bool? ?? false)) { + return SearchRatingFilter(); + } + return SearchRatingFilter(rating: Option.some(map['value'] as int?)); } String toJson() => json.encode(toMap()); @@ -270,7 +278,7 @@ class SearchFilter { display.isNotInAlbum == false && display.isArchive == false && display.isFavorite == false && - rating.rating == null && + rating.rating.isNone && mediaType == AssetType.other; } diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 2a405566412a8..2ae6371f6ae92 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -404,12 +404,15 @@ class DriftSearchPage extends HookConsumerWidget { handleClear() { ratingCurrentFilterWidget.value = null; - search(filter.value.copyWith(rating: SearchRatingFilter(rating: null))); + search(filter.value.copyWith(rating: SearchRatingFilter())); } handleApply() { - ratingCurrentFilterWidget.value = rating.rating != null - ? Text('rating_count'.t(args: {'count': rating.rating!}), style: context.textTheme.labelLarge) + ratingCurrentFilterWidget.value = rating.rating.isSome + ? Text( + 'rating_count'.t(args: {'count': rating.rating.unwrapOrNull ?? 0}), + style: context.textTheme.labelLarge, + ) : null; search(filter.value.copyWith(rating: rating)); } diff --git a/mobile/lib/utils/option.dart b/mobile/lib/utils/option.dart index 3470e8489e06f..1d73ddfbcca6b 100644 --- a/mobile/lib/utils/option.dart +++ b/mobile/lib/utils/option.dart @@ -1,3 +1,5 @@ +import 'package:openapi/api.dart' show Optional; + sealed class Option { const Option(); @@ -56,3 +58,10 @@ final class None extends Option { extension ObjectOptionExtension on T? { Option toOption() => Option.fromNullable(this); } + +extension OptionToOptional on Option { + Optional toOptional() => switch (this) { + None() => const Optional.absent(), + Some(:final value) => Optional.present(value), + }; +} diff --git a/mobile/lib/widgets/search/search_filter/star_rating_picker.dart b/mobile/lib/widgets/search/search_filter/star_rating_picker.dart index 917d56e8023bc..32d1ab5bd403f 100644 --- a/mobile/lib/widgets/search/search_filter/star_rating_picker.dart +++ b/mobile/lib/widgets/search/search_filter/star_rating_picker.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:immich_mobile/utils/option.dart'; class StarRatingPicker extends HookWidget { const StarRatingPicker({super.key, required this.onSelect, this.filter}); @@ -13,12 +14,12 @@ class StarRatingPicker extends HookWidget { final selectedRating = useState(filter); return RadioGroup( - groupValue: selectedRating.value?.rating, + groupValue: selectedRating.value?.rating.fold((v) => v ?? 0, () => null), onChanged: (int? newValue) { if (newValue == null) { return; } - final newFilter = SearchRatingFilter(rating: newValue); + final newFilter = SearchRatingFilter(rating: Option.some(newValue == 0 ? null : newValue)); selectedRating.value = newFilter; onSelect(newFilter); }, From e117973eb74c94d560e352b94adc9f0a5ab7bf07 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Thu, 4 Jun 2026 15:49:20 +0200 Subject: [PATCH 6/7] add test --- mobile/test/services/action.service_test.dart | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/mobile/test/services/action.service_test.dart b/mobile/test/services/action.service_test.dart index f08a247a3ef89..a25ee9920db67 100644 --- a/mobile/test/services/action.service_test.dart +++ b/mobile/test/services/action.service_test.dart @@ -73,6 +73,32 @@ void main() { await Store.clear(); }); + group('ActionService.updateRating', () { + const assetId = 'asset_id_1'; + + test('calls both repositories with the given rating', () async { + when(() => assetApiRepository.updateRating(assetId, 3)).thenAnswer((_) async {}); + when(() => remoteAssetRepository.updateRating(assetId, 3)).thenAnswer((_) async {}); + + final result = await sut.updateRating(assetId, 3); + + expect(result, isTrue); + verify(() => assetApiRepository.updateRating(assetId, 3)).called(1); + verify(() => remoteAssetRepository.updateRating(assetId, 3)).called(1); + }); + + test('calls both repositories with null to clear rating', () async { + when(() => assetApiRepository.updateRating(assetId, null)).thenAnswer((_) async {}); + when(() => remoteAssetRepository.updateRating(assetId, null)).thenAnswer((_) async {}); + + final result = await sut.updateRating(assetId, null); + + expect(result, isTrue); + verify(() => assetApiRepository.updateRating(assetId, null)).called(1); + verify(() => remoteAssetRepository.updateRating(assetId, null)).called(1); + }); + }); + group('ActionService.deleteLocal', () { test('routes deleted ids to trashed repository when Android trash handling is enabled', () async { await Store.put(StoreKey.manageLocalMediaAndroid, true); From a0f27f21ffb9f7c46c9492d41b89b3169525e202 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler Date: Thu, 4 Jun 2026 18:54:52 +0200 Subject: [PATCH 7/7] chore: rename migration --- ...tingToNull.ts => 1780592070031-ConvertNegativeRatingToNull.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/src/schema/migrations/{1776429021231-ConvertNegativeRatingToNull.ts => 1780592070031-ConvertNegativeRatingToNull.ts} (100%) diff --git a/server/src/schema/migrations/1776429021231-ConvertNegativeRatingToNull.ts b/server/src/schema/migrations/1780592070031-ConvertNegativeRatingToNull.ts similarity index 100% rename from server/src/schema/migrations/1776429021231-ConvertNegativeRatingToNull.ts rename to server/src/schema/migrations/1780592070031-ConvertNegativeRatingToNull.ts