From 5c38373808ce6f45d83e2315eac0f398087cf3fb Mon Sep 17 00:00:00 2001 From: Timon Date: Wed, 10 Jun 2026 10:55:51 +0200 Subject: [PATCH] refactor(server): allow -1 rating again (#28886) --- e2e/src/specs/server/api/asset.e2e-spec.ts | 14 ++++++++++++ .../lib/model/asset_bulk_update_dto.dart | 4 ++-- .../openapi/lib/model/update_asset_dto.dart | 4 ++-- open-api/immich-openapi-specs.json | 22 +++++-------------- packages/sdk/src/fetch-client.ts | 4 ++-- .../src/controllers/asset.controller.spec.ts | 11 +++++++++- server/src/dtos/asset.dto.ts | 10 +++++---- ...80592070031-ConvertNegativeRatingToNull.ts | 6 ++--- .../asset-viewer/DetailPanelStarRating.svelte | 4 ++-- web/src/lib/elements/StarRating.svelte | 14 ++++-------- .../timeline-manager/timeline-month.svelte.ts | 4 ++-- 11 files changed, 52 insertions(+), 45 deletions(-) diff --git a/e2e/src/specs/server/api/asset.e2e-spec.ts b/e2e/src/specs/server/api/asset.e2e-spec.ts index 7f89cb515d..010b096c4d 100644 --- a/e2e/src/specs/server/api/asset.e2e-spec.ts +++ b/e2e/src/specs/server/api/asset.e2e-spec.ts @@ -492,6 +492,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 return tagged people', async () => { const { status, body } = await request(app) .put(`/assets/${user1Assets[0].id}`) diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index f5e679274f..71f21b812f 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -95,9 +95,9 @@ class AssetBulkUpdateDto { /// Optional longitude; - /// Rating in range [1-5], or null for unrated + /// Rating in range [1-5] (starred), -1 (rejected), or null (unrated) /// - /// Minimum value: 1 + /// 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 73a8498494..1958e36963 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -77,9 +77,9 @@ class UpdateAssetDto { /// Optional longitude; - /// Rating in range [1-5], or null for unrated + /// Rating in range [1-5] (starred), -1 (rejected), or null (unrated) /// - /// Minimum value: 1 + /// 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 bd856f38fd..e51914d5b4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16602,9 +16602,9 @@ "type": "number" }, "rating": { - "description": "Rating in range [1-5], or null for unrated", + "description": "Rating in range [1-5] (starred), -1 (rejected), or null (unrated)", "maximum": 5, - "minimum": 1, + "minimum": -1, "nullable": true, "type": "integer", "x-immich-history": [ @@ -16616,15 +16616,10 @@ "version": "v2", "state": "Stable" }, - { - "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." + "description": "Using 0 as a rating is no longer valid." } ], "x-immich-state": "Stable" @@ -26430,9 +26425,9 @@ "type": "number" }, "rating": { - "description": "Rating in range [1-5], or null for unrated", + "description": "Rating in range [1-5] (starred), -1 (rejected), or null (unrated)", "maximum": 5, - "minimum": 1, + "minimum": -1, "nullable": true, "type": "integer", "x-immich-history": [ @@ -26444,15 +26439,10 @@ "version": "v2", "state": "Stable" }, - { - "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." + "description": "Using 0 as a rating is no longer valid." } ], "x-immich-state": "Stable" diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 7503208d27..720d741ebd 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -672,7 +672,7 @@ export type AssetBulkUpdateDto = { latitude?: number; /** Longitude coordinate */ longitude?: number; - /** Rating in range [1-5], or null for unrated */ + /** Rating in range [1-5] (starred), -1 (rejected), or null (unrated) */ rating?: number | null; /** Time zone (IANA timezone) */ timeZone?: string; @@ -919,7 +919,7 @@ export type UpdateAssetDto = { livePhotoVideoId?: string | null; /** Longitude coordinate */ longitude?: number; - /** Rating in range [1-5], or null for unrated */ + /** Rating in range [1-5] (starred), -1 (rejected), or null (unrated) */ rating?: number | null; visibility?: AssetVisibility; }; diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index b0191f7217..8ee6995b1f 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -240,7 +240,16 @@ 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' }]], + [ + { rating: 0 }, + [ + { + path: ['rating'], + message: 'Rating must be -1 (rejected), 1–5 (starred), or null (unrated); 0 is not valid', + }, + ], + ], ] as const) { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test); expect(status).toBe(400); diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 5072b3aa17..d7d680fb43 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -15,16 +15,18 @@ const UpdateAssetBaseSchema = z longitude: longitudeSchema.optional().describe('Longitude coordinate'), rating: z .int() - .min(1) + .min(-1) .max(5) .nullish() - .describe('Rating in range [1-5], or null for unrated') + .refine((v) => v !== 0, { + error: 'Rating must be -1 (rejected), 1–5 (starred), or null (unrated); 0 is not valid', + }) + .describe('Rating in range [1-5] (starred), -1 (rejected), or null (unrated)') .meta({ ...new HistoryBuilder() .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.') + .updated('v3', 'Using 0 as a rating is no longer valid.') .getExtensions(), }), description: z.string().optional().describe('Asset description'), diff --git a/server/src/schema/migrations/1780592070031-ConvertNegativeRatingToNull.ts b/server/src/schema/migrations/1780592070031-ConvertNegativeRatingToNull.ts index f54136dc2c..ed2e6ad7e2 100644 --- a/server/src/schema/migrations/1780592070031-ConvertNegativeRatingToNull.ts +++ b/server/src/schema/migrations/1780592070031-ConvertNegativeRatingToNull.ts @@ -1,7 +1,5 @@ -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 up(): Promise { + // await sql`UPDATE "asset_exif" SET "rating" = NULL WHERE "rating" = -1;`.execute(db); } export async function down(): Promise { diff --git a/web/src/lib/components/asset-viewer/DetailPanelStarRating.svelte b/web/src/lib/components/asset-viewer/DetailPanelStarRating.svelte index 3b790b3d85..fd4d63d3b3 100644 --- a/web/src/lib/components/asset-viewer/DetailPanelStarRating.svelte +++ b/web/src/lib/components/asset-viewer/DetailPanelStarRating.svelte @@ -13,9 +13,9 @@ let { asset, isOwner }: Props = $props(); - let rating = $derived(asset.exifInfo?.rating || null) as Rating; + let rating = $derived(asset.exifInfo?.rating ?? null) as Rating; - const handleChangeRating = async (rating: number | null) => { + const handleChangeRating = async (rating: Rating) => { try { await updateAsset({ id: asset.id, updateAssetDto: { rating } }); } catch (error) { diff --git a/web/src/lib/elements/StarRating.svelte b/web/src/lib/elements/StarRating.svelte index 3acde1dc79..1b3ae5fea5 100644 --- a/web/src/lib/elements/StarRating.svelte +++ b/web/src/lib/elements/StarRating.svelte @@ -6,7 +6,7 @@ import { mdiStar, mdiStarOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; - export type Rating = 1 | 2 | 3 | 4 | 5 | null; + export type Rating = -1 | 1 | 2 | 3 | 4 | 5 | null; interface Props { count?: number; @@ -33,6 +33,7 @@ return; } + ratingSelection = newRating; onRating(newRating); }; @@ -70,7 +71,7 @@
{#each { length: count } as _, index (index)} {@const value = index + 1} - {@const filled = hoverRating === null ? (ratingSelection || 0) >= value : hoverRating >= value} + {@const filled = hoverRating === null ? (ratingSelection ?? 0) >= value : hoverRating >= value} {@const starId = `${id}-${value}`} @@ -102,14 +103,7 @@
{#if ratingSelection !== null && !readOnly} - {/if} diff --git a/web/src/lib/managers/timeline-manager/timeline-month.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-month.svelte.ts index 54a0edc784..49350ed257 100644 --- a/web/src/lib/managers/timeline-manager/timeline-month.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-month.svelte.ts @@ -15,7 +15,7 @@ import { fromTimelinePlainDate, fromTimelinePlainDateTime, fromTimelinePlainYearMonth, - fromISODateTimeUTC, + fromISODateTimeUTCToObject, getTimes, setDifference, type TimelineDateTime, @@ -190,7 +190,7 @@ export class TimelineMonth { isVideo: !bucketAssets.isImage[i], livePhotoVideoId: bucketAssets.livePhotoVideoId[i], localDateTime, - createdAt: fromISODateTimeUTC(bucketAssets.createdAt[i]).setZone('local'), + createdAt: fromISODateTimeUTCToObject(bucketAssets.createdAt[i]), fileCreatedAt, ownerId: bucketAssets.ownerId[i], projectionType: bucketAssets.projectionType[i],