From f33dbdfe9af2407b593f3337dd49f41abe9d32dc Mon Sep 17 00:00:00 2001 From: Christoph Suter Date: Fri, 9 Aug 2024 19:45:52 +0200 Subject: [PATCH] feat(web): add Exif-Rating (#11580) * Add Exif-Rating * Integrate star rating as own component * Add e2e tests for rating and validation * Rename component and async handleChangeRating * Display rating can be enabled in app settings * Correct i18n reference Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> * Star rating: change from slider to buttons * Star rating for clarity * Design updates. * Renaming and code optimization * chore: clean up * chore: e2e formatting * light mode border and default value --------- Co-authored-by: Christoph Suter Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com> Co-authored-by: Jason Rasmussen Co-authored-by: Alex Tran --- e2e/src/api/specs/asset.e2e-spec.ts | 53 ++++++++++++++++++ e2e/test-assets | 2 +- mobile/openapi/README.md | Bin 31355 -> 31439 bytes mobile/openapi/lib/api.dart | Bin 10635 -> 10703 bytes mobile/openapi/lib/api_client.dart | Bin 27812 -> 27968 bytes .../lib/model/asset_bulk_update_dto.dart | Bin 8322 -> 9010 bytes .../openapi/lib/model/exif_response_dto.dart | Bin 10313 -> 10663 bytes mobile/openapi/lib/model/rating_response.dart | Bin 0 -> 2761 bytes mobile/openapi/lib/model/rating_update.dart | Bin 0 -> 3157 bytes .../openapi/lib/model/update_asset_dto.dart | Bin 6810 -> 7498 bytes .../model/user_preferences_response_dto.dart | Bin 4154 -> 4386 bytes .../model/user_preferences_update_dto.dart | Bin 6284 -> 6942 bytes open-api/immich-openapi-specs.json | 43 +++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 11 ++++ server/src/dtos/asset.dto.ts | 8 +++ server/src/dtos/exif.dto.ts | 3 + server/src/dtos/user-preferences.dto.ts | 15 +++++ server/src/entities/exif.entity.ts | 3 + server/src/entities/user-metadata.entity.ts | 6 ++ server/src/interfaces/job.interface.ts | 1 + .../migrations/1722753178937-AddExifRating.ts | 14 +++++ server/src/queries/asset.repository.sql | 7 +++ server/src/queries/person.repository.sql | 1 + server/src/queries/search.repository.sql | 1 + server/src/queries/shared.link.repository.sql | 2 + server/src/services/asset.service.spec.ts | 7 +++ server/src/services/asset.service.ts | 8 +-- server/src/services/metadata.service.spec.ts | 2 + server/src/services/metadata.service.ts | 4 +- server/test/fixtures/shared-link.stub.ts | 1 + .../detail-panel-star-rating.svelte | 27 +++++++++ .../asset-viewer/detail-panel.svelte | 2 + web/src/lib/components/elements/icon.svelte | 4 ++ .../shared-components/star-rating.svelte | 50 +++++++++++++++++ .../user-settings-page/app-settings.svelte | 27 +++++++++ web/src/lib/i18n/de.json | 2 + web/src/lib/i18n/en.json | 2 + 37 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 mobile/openapi/lib/model/rating_response.dart create mode 100644 mobile/openapi/lib/model/rating_update.dart create mode 100644 server/src/migrations/1722753178937-AddExifRating.ts create mode 100644 web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte create mode 100644 web/src/lib/components/shared-components/star-rating.svelte diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 067ebbebc..b9282ff81 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -43,6 +43,7 @@ const makeUploadDto = (options?: { omit: string }): Record => { const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`; +const ratingAssetFilepath = `${testAssetDir}/metadata/rating/mongolels.jpg`; const readTags = async (bytes: Buffer, filename: string) => { const filepath = join(tempDir, filename); @@ -72,6 +73,7 @@ describe('/asset', () => { let user2Assets: AssetMediaResponseDto[]; let stackAssets: AssetMediaResponseDto[]; let locationAsset: AssetMediaResponseDto; + let ratingAsset: AssetMediaResponseDto; const setupTests = async () => { await utils.resetDatabase(); @@ -99,6 +101,16 @@ describe('/asset', () => { await utils.waitForWebsocketEvent({ event: 'assetUpload', id: locationAsset.id }); + // asset rating + ratingAsset = await utils.createAsset(admin.accessToken, { + assetData: { + filename: 'mongolels.jpg', + bytes: await readFile(ratingAssetFilepath), + }, + }); + + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: ratingAsset.id }); + user1Assets = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), @@ -214,6 +226,22 @@ describe('/asset', () => { expect(body).toMatchObject({ id: user1Assets[0].id }); }); + it('should get the asset rating', async () => { + await utils.waitForWebsocketEvent({ + event: 'assetUpload', + id: ratingAsset.id, + }); + + const { status, body } = await request(app) + .get(`/assets/${ratingAsset.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toMatchObject({ + id: ratingAsset.id, + exifInfo: expect.objectContaining({ rating: 3 }), + }); + }); + it('should work with a shared link', async () => { const sharedLink = await utils.createSharedLink(user1.accessToken, { type: SharedLinkType.Individual, @@ -575,6 +603,31 @@ describe('/asset', () => { expect(status).toEqual(200); }); + it('should set the rating', async () => { + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ rating: 2 }); + expect(body).toMatchObject({ + id: user1Assets[0].id, + exifInfo: expect.objectContaining({ + rating: 2, + }), + }); + 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) + .put(`/assets/${user1Assets[0].id}`) + .send(test) + .set('Authorization', `Bearer ${user1.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + } + }); + it('should return tagged people', async () => { const { status, body } = await request(app) .put(`/assets/${user1Assets[0].id}`) diff --git a/e2e/test-assets b/e2e/test-assets index 898069e47..39f25a96f 160000 --- a/e2e/test-assets +++ b/e2e/test-assets @@ -1 +1 @@ -Subproject commit 898069e47f8e3283bf3bbd40b58b56d8fd57dc65 +Subproject commit 39f25a96f13f743c96cdb7c6d93b031fcb61b83c diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 89a4fb8e3b06265de87567cd56cea359fb0d0ce1..97f6a9d6c8e2b70a1e757f8c4b2168e4674228f0 100644 GIT binary patch delta 89 zcmezUh4K7X#tk3Syb?R}*xfFC2q9MYe1u2Oo Os45{so1dpm5e5K=dLw`U delta 14 WcmX^AmGSo%#tk3SHoK;G2?GE@p$EAD diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e7aaf38de70cabea01e022b0fecf14c83d361ac6..19ff7fc6d56e44b9ba32110b18750c3fe12d8d84 100644 GIT binary patch delta 33 kcmeAUJ|DaxQIa*WBr`94@*fFVwju~qQBrzyf~2+p0N;cQ;Q#;t delta 12 TcmX>f+#S3jQF8NANi6{YB`E~f diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4fe810b886e477b83e18c112af858d54f645ac27..346eee3f5043dd976a083a9d65397d55519733b3 100644 GIT binary patch delta 61 zcmZ2-lkvbU#tl!LSrbb#^U^0fYRa$$L6{Q_G~i54DHwC3rrKl&7mm%Zop)*g0P=$u Ax&QzG delta 14 WcmX?bi*d;I`6Y3F>Y4imSE-w^A&7u6`(Sje4I>DaKXINoE!yv1p@^un7o`o93wAS z-c|*wrBd)6GsH!c6NDwjAfoEA3bqO*8JWd;5dLIEQ8}pXllKeDZB7%N$f5`~$4Viu dG*_=6v8XszLtUjht2jR|x=0;r_H)VEYydf3QR)By delta 51 zcmV-30L=fgMuI`GD+04T0-Fc3_y$@5li&shv(gL_1G54U`T?^l5=sHHgcIunv*s6& J1hc0ciw5fp650R& diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index d29d485a057f54f48802e45db2e10e671f594e80..0185f300fac5b89ae81c52b2631bb8c0276d39ab 100644 GIT binary patch delta 206 zcmX>ZusnDJKQmiVVo7FR`eqj9lZ@PXrMdPBP=OOH3Csczwt}rKOjL@al|>pMnpc{W zqhPOKpkM`4(8CkM#0ODes{%FZ8Q**^s6CSf1trBG!s@XKwhAQ~nZ&dh^m47bR=1Qq@j?GcZzH9(Vrw%v( diff --git a/mobile/openapi/lib/model/rating_response.dart b/mobile/openapi/lib/model/rating_response.dart new file mode 100644 index 0000000000000000000000000000000000000000..80ef5980fb2e2a53b4306eeac8a17eddcc5657d5 GIT binary patch literal 2761 zcmbVOZExE)5dQ98aRG){0aSV2ry{A`7K>A~Yhxhw3KWJRFcM|4lSz%FY8a{i`|e0d zksP;4XTY{Z9q;9Np5tjU8BHee=I_P)+1uIm?B>m4b_rMSKFmV6oWu2e0Uzg=@2exp8~m-9#(>LFd{W-<~RIER!}783q)&;M#3BM}w6@a+8!wuF=dEOeQaW zPtt;EGa6wy3n~XyamgwX;rC)RN=s%8+%PNT`i7ed!*RG90Zg#`8*XbY4KR>=3(KL^ z3WQ57p9SOrpe-0|Axwl}Z%dwq_-KEIp#g4{Dxq0+3$N8Tt~Hk0KEz}-vr0o8 z2lLynJjD`h!Sxx$Q}B?atPIG1*gpCG-Cu!tP}u3I%gecF^Fh}?%t1&CYi^-nrub83 z9HTl1ois=hlBe7lrgy|Bl2mXbj57+4jfn2Li<7w%ZP+B<}j7fP*ta25Ctjp5Vx)sJL zuE#;8d5(g2y`t+B^S)vlc^4CUAL?qA;Cy z9bka2Gl^1W>;s$wPrUC5>ZyYa0!+<+8UoH?!>Z2&&pco3D*lpMWf~BVA>7k+c0%1m z9}GZ$d-vP$M3{9vHI;u5nBGucQz&uq3P~71;A7pn9$jm-rRGIdW*zZB(SZ(o`ai;grTiiV-@d1_* z$;nlMNjI<%H1{l?#7<6qhjdm^2@%UO&vkF72ek!v0CBU+Dk>^yVLFHQ%6V~p(%|3k z*#oSI@}n%YiF!!Ts^7{7PTG#{1CB9&ink*s_I&(EzS!r@G(3lHa3wf9a75B5^atL6 zJ6zSfmOVUe@ucIXt%Vrz^*qL+a}znM9w(2mi6G&riSeU<_>K-Ux8G}#LjQzP+}bq_ z7LB)b3^|OHpFmq-3tXbEp@BMN;F&kVJtTGPn5$`q}7~Yy{>;$Iat+@-XRAOQPaWJ6HwSS=6#EXovOSj2K|kJ zdSaNsqTn6x-P;XH%;2ZN1LQx;Cw@)< literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/rating_update.dart b/mobile/openapi/lib/model/rating_update.dart new file mode 100644 index 0000000000000000000000000000000000000000..bb8f7eadc2f55c7831f67eed793d96bdf3534467 GIT binary patch literal 3157 zcmbVO?{6A85dF?yF{w@skv7n~PZcesRg<3DYr-i>PdcG!Wp)j0yVzUX8-!B+_j_Y| z$)b=nx=2~T9?y^W-psn+@AUih{_FMl_|LP8v&;9_XQ%Y>)0eY^PRDdHzNYi>>8FqX z9>9z(-{r!%;UB%HI#_gRhpC)pP!uAknTFN}Z%H{~MyxZ`kze_2sB&8ity@oYa2ALVRCcDse znbShtN}5WkV5E*!U6$ed$ZdM7X9&fvv1gQJ8NdL?hANxaEiDrtaB9^G4A8>EnFcwW}l(M*&jO-1&d zoaVgQH&BZylUr9FCkFKc_ym=PM4kt;JqaN1M+)=-a30%$jL~BP;26)T*5wIB&^_Gm zG-@y~kGsr03ekp!!|r(cO=eI8kTgd$98pkMCdw{5Pnw;dKf^adz&6wL@+I6-*lf6l z<3}?nl%u(H0LU&rFm}U{cd+dX{|78ZVxDaA#Gv3SvOY3AsAXZp;t6$q8;=a zw?S#`(yyHHZ0qo)>#IF%*D{H|hrh)yH-{d;b5XqwhZ4%yXxkzifr8IgD|iV~pA|}! z^bJciqzBe_n>RlDVn!a;El>xcipC3h4-<136F9tqw{{l=Hye^A+9pH>aQjX%IMLdN zHolL&B`+kn;1*)EPu+QWyRwkRK{7}4+ zmM0;^y5o3q6LMng*d6yfhew}#?eq$Jq21ZCa_hz);KVHHMz?y!bBmAO?>B%4GKM=O z_EVNmYLd(b1i9n$Y%eg=wLCj*=ftUKVwQ%-Y9-3Fq;7A)_jjeNWHcW`2+ z_u}7F9pHdfdmik#Xb*)3YQ%dIX>T_SOgyGowBvn$iFmt6@q))ahVLt3knXUCRkLn* z!*If=l(wma;SSY2&7XJD^ENFue!(LE!q`Rh-gmj?Y3I)e4RYWo$i&gsBx6E&>Ue~7 zc&n`nx4;tj3g3C!=pP3YoQT7W->JAD@bw6u(2=qC&RfHuRkPbJBR1ycy;?}|C8S%6;-rYb-{bn`pG+e|77Q1h&S_T}moBo-B?YN)GJ SXBFq?MHi_0Hv&;>Z0h5mp%(H6{ivzQ56x#x`c^A9KzL4ICwssco2 z^DQPVMmYsFH7*4p$S=uAErRM%u(gG%nasj+nNL9*tU?27;8~V^0y=>~Ef*I6dumHP delta 46 zcmV+}0MY-VBDx^3Gy;=O0;{tV10?~IRs_?NK?St41qLnwvsee<0<&`pn+67bI|_XY E3Tto?CK{YDa+QQV#<_czN+AgB?8)y1 L#bFA3gdEra5*$#8 delta 41 zcmV+^0M`GWHjFW_0RpoU0(S?q{|X}kvp@_}0h7TEva^E@>;kjq5z7Lz=@c~vG*1s? diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c30c43fab..78aaf78e9 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7550,6 +7550,11 @@ "longitude": { "type": "number" }, + "rating": { + "maximum": 5, + "minimum": 0, + "type": "number" + }, "removeParent": { "type": "boolean" }, @@ -8702,6 +8707,11 @@ "nullable": true, "type": "string" }, + "rating": { + "default": null, + "nullable": true, + "type": "number" + }, "state": { "default": null, "nullable": true, @@ -9905,6 +9915,25 @@ ], "type": "object" }, + "RatingResponse": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "RatingUpdate": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "type": "object" + }, "ReactionLevel": { "enum": [ "album", @@ -11565,6 +11594,11 @@ }, "longitude": { "type": "number" + }, + "rating": { + "maximum": 5, + "minimum": 0, + "type": "number" } }, "type": "object" @@ -11865,6 +11899,9 @@ }, "purchase": { "$ref": "#/components/schemas/PurchaseResponse" + }, + "rating": { + "$ref": "#/components/schemas/RatingResponse" } }, "required": [ @@ -11872,7 +11909,8 @@ "download", "emailNotifications", "memories", - "purchase" + "purchase", + "rating" ], "type": "object" }, @@ -11892,6 +11930,9 @@ }, "purchase": { "$ref": "#/components/schemas/PurchaseUpdate" + }, + "rating": { + "$ref": "#/components/schemas/RatingUpdate" } }, "type": "object" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 184052a4f..9d97f4bcc 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -99,12 +99,16 @@ export type PurchaseResponse = { hideBuyButtonUntil: string; showSupportBadge: boolean; }; +export type RatingResponse = { + enabled: boolean; +}; export type UserPreferencesResponseDto = { avatar: AvatarResponse; download: DownloadResponse; emailNotifications: EmailNotificationsResponse; memories: MemoryResponse; purchase: PurchaseResponse; + rating: RatingResponse; }; export type AvatarUpdate = { color?: UserAvatarColor; @@ -124,12 +128,16 @@ export type PurchaseUpdate = { hideBuyButtonUntil?: string; showSupportBadge?: boolean; }; +export type RatingUpdate = { + enabled?: boolean; +}; export type UserPreferencesUpdateDto = { avatar?: AvatarUpdate; download?: DownloadUpdate; emailNotifications?: EmailNotificationsUpdate; memories?: MemoryUpdate; purchase?: PurchaseUpdate; + rating?: RatingUpdate; }; export type AlbumUserResponseDto = { role: AlbumUserRole; @@ -155,6 +163,7 @@ export type ExifResponseDto = { modifyDate?: string | null; orientation?: string | null; projectionType?: string | null; + rating?: number | null; state?: string | null; timeZone?: string | null; }; @@ -330,6 +339,7 @@ export type AssetBulkUpdateDto = { isFavorite?: boolean; latitude?: number; longitude?: number; + rating?: number; removeParent?: boolean; stackParentId?: string; }; @@ -381,6 +391,7 @@ export type UpdateAssetDto = { isFavorite?: boolean; latitude?: number; longitude?: number; + rating?: number; }; export type AssetMediaReplaceDto = { assetData: Blob; diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 4d2ddb0a3..8b438992d 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -9,6 +9,8 @@ import { IsNotEmpty, IsPositive, IsString, + Max, + Min, ValidateIf, } from 'class-validator'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; @@ -46,6 +48,12 @@ export class UpdateAssetBase { @IsLongitude() @IsNotEmpty() longitude?: number; + + @Optional() + @IsInt() + @Max(5) + @Min(0) + rating?: number; } export class AssetBulkUpdateDto extends UpdateAssetBase { diff --git a/server/src/dtos/exif.dto.ts b/server/src/dtos/exif.dto.ts index 6724de98f..079891ae5 100644 --- a/server/src/dtos/exif.dto.ts +++ b/server/src/dtos/exif.dto.ts @@ -25,6 +25,7 @@ export class ExifResponseDto { country?: string | null = null; description?: string | null = null; projectionType?: string | null = null; + rating?: number | null = null; } export function mapExif(entity: ExifEntity): ExifResponseDto { @@ -50,6 +51,7 @@ export function mapExif(entity: ExifEntity): ExifResponseDto { country: entity.country, description: entity.description, projectionType: entity.projectionType, + rating: entity.rating, }; } @@ -62,5 +64,6 @@ export function mapSanitizedExif(entity: ExifEntity): ExifResponseDto { projectionType: entity.projectionType, exifImageWidth: entity.exifImageWidth, exifImageHeight: entity.exifImageHeight, + rating: entity.rating, }; } diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index 29cefcc10..8c50d0058 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -16,6 +16,11 @@ class MemoryUpdate { enabled?: boolean; } +class RatingUpdate { + @ValidateBoolean({ optional: true }) + enabled?: boolean; +} + class EmailNotificationsUpdate { @ValidateBoolean({ optional: true }) enabled?: boolean; @@ -45,6 +50,11 @@ class PurchaseUpdate { } export class UserPreferencesUpdateDto { + @Optional() + @ValidateNested() + @Type(() => RatingUpdate) + rating?: RatingUpdate; + @Optional() @ValidateNested() @Type(() => AvatarUpdate) @@ -76,6 +86,10 @@ class AvatarResponse { color!: UserAvatarColor; } +class RatingResponse { + enabled!: boolean; +} + class MemoryResponse { enabled!: boolean; } @@ -97,6 +111,7 @@ class PurchaseResponse { } export class UserPreferencesResponseDto implements UserPreferences { + rating!: RatingResponse; memories!: MemoryResponse; avatar!: AvatarResponse; emailNotifications!: EmailNotificationsResponse; diff --git a/server/src/entities/exif.entity.ts b/server/src/entities/exif.entity.ts index 3461faa68..c9c29d732 100644 --- a/server/src/entities/exif.entity.ts +++ b/server/src/entities/exif.entity.ts @@ -95,6 +95,9 @@ export class ExifEntity { @Column({ type: 'integer', nullable: true }) bitsPerSample!: number | null; + @Column({ type: 'integer', nullable: true }) + rating!: number | null; + /* Video info */ @Column({ type: 'float8', nullable: true }) fps?: number | null; diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index cbc889a5b..73eb9e04a 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -31,6 +31,9 @@ export enum UserAvatarColor { } export interface UserPreferences { + rating: { + enabled: boolean; + }; memories: { enabled: boolean; }; @@ -58,6 +61,9 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences ); return { + rating: { + enabled: false, + }, memories: { enabled: true, }, diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 0fd35167a..7776d2bd3 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -147,6 +147,7 @@ export interface ISidecarWriteJob extends IEntityJob { dateTimeOriginal?: string; latitude?: number; longitude?: number; + rating?: number; } export interface IDeferrableJob extends IEntityJob { diff --git a/server/src/migrations/1722753178937-AddExifRating.ts b/server/src/migrations/1722753178937-AddExifRating.ts new file mode 100644 index 000000000..52e8fb71e --- /dev/null +++ b/server/src/migrations/1722753178937-AddExifRating.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddRating1722753178937 implements MigrationInterface { + name = 'AddRating1722753178937' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" ADD "rating" integer`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "rating"`); + } + +} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index ba0707cfe..98fb1d699 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -58,6 +58,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps" FROM "assets" "entity" @@ -177,6 +178,7 @@ SELECT "AssetEntity__AssetEntity_exifInfo"."profileDescription" AS "AssetEntity__AssetEntity_exifInfo_profileDescription", "AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace", "AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample", + "AssetEntity__AssetEntity_exifInfo"."rating" AS "AssetEntity__AssetEntity_exifInfo_rating", "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps", "AssetEntity__AssetEntity_smartInfo"."assetId" AS "AssetEntity__AssetEntity_smartInfo_assetId", "AssetEntity__AssetEntity_smartInfo"."tags" AS "AssetEntity__AssetEntity_smartInfo_tags", @@ -628,6 +630,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", @@ -769,6 +772,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", @@ -886,6 +890,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", @@ -1053,6 +1058,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", @@ -1129,6 +1135,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 4e4d36da8..9b20b964d 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -322,6 +322,7 @@ FROM "AssetEntity__AssetEntity_exifInfo"."profileDescription" AS "AssetEntity__AssetEntity_exifInfo_profileDescription", "AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace", "AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample", + "AssetEntity__AssetEntity_exifInfo"."rating" AS "AssetEntity__AssetEntity_exifInfo_rating", "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps" FROM "assets" "AssetEntity" diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 58a288a0c..390aedaf3 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -402,6 +402,7 @@ SELECT "exif"."profileDescription" AS "exif_profileDescription", "exif"."colorspace" AS "exif_colorspace", "exif"."bitsPerSample" AS "exif_bitsPerSample", + "exif"."rating" AS "exif_rating", "exif"."fps" AS "exif_fps" FROM "assets" "asset" diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 09f0cf7cb..2880e6896 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -77,6 +77,7 @@ FROM "9b1d35b344d838023994a3233afd6ffe098be6d8"."profileDescription" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_profileDescription", "9b1d35b344d838023994a3233afd6ffe098be6d8"."colorspace" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_colorspace", "9b1d35b344d838023994a3233afd6ffe098be6d8"."bitsPerSample" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_bitsPerSample", + "9b1d35b344d838023994a3233afd6ffe098be6d8"."rating" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_rating", "9b1d35b344d838023994a3233afd6ffe098be6d8"."fps" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_fps", "SharedLinkEntity__SharedLinkEntity_album"."id" AS "SharedLinkEntity__SharedLinkEntity_album_id", "SharedLinkEntity__SharedLinkEntity_album"."ownerId" AS "SharedLinkEntity__SharedLinkEntity_album_ownerId", @@ -144,6 +145,7 @@ FROM "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."profileDescription" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_profileDescription", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."colorspace" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_colorspace", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."bitsPerSample" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_bitsPerSample", + "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."rating" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_rating", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."fps" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_fps", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."id" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_id", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."name" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_name", diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 2e920b400..3385427c2 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -228,6 +228,13 @@ describe(AssetService.name, () => { await sut.update(authStub.admin, 'asset-1', { description: 'Test description' }); expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' }); }); + + it('should update the exif rating', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + assetMock.getById.mockResolvedValue(assetStub.image); + await sut.update(authStub.admin, 'asset-1', { rating: 3 }); + expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 }); + }); }); describe('updateAll', () => { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 5c6cce27d..a34349498 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -158,8 +158,8 @@ export class AssetService { async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); - const { description, dateTimeOriginal, latitude, longitude, ...rest } = dto; - await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude }); + const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; + await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); await this.assetRepository.update({ id, ...rest }); const asset = await this.assetRepository.getById(id, { @@ -405,8 +405,8 @@ export class AssetService { } private async updateMetadata(dto: ISidecarWriteJob) { - const { id, description, dateTimeOriginal, latitude, longitude } = dto; - const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude }, _.isUndefined); + const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; + const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); if (Object.keys(writes).length > 0) { await this.assetRepository.upsertExif({ assetId: id, ...writes }); await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } }); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index d1806a1f4..3adae8637 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -606,6 +606,7 @@ describe(MetadataService.name, () => { ProfileDescription: 'extensive description', ProjectionType: 'equirectangular', tz: '+02:00', + Rating: 3, }; assetMock.getByIds.mockResolvedValue([assetStub.image]); metadataMock.readTags.mockResolvedValue(tags); @@ -638,6 +639,7 @@ describe(MetadataService.name, () => { profileDescription: tags.ProfileDescription, projectionType: 'EQUIRECTANGULAR', timeZone: tags.tz, + rating: tags.Rating, }); expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 126a49ee6..7e940744e 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -273,7 +273,7 @@ export class MetadataService implements OnEvents { } async handleSidecarWrite(job: ISidecarWriteJob): Promise { - const { id, description, dateTimeOriginal, latitude, longitude } = job; + const { id, description, dateTimeOriginal, latitude, longitude, rating } = job; const [asset] = await this.assetRepository.getByIds([id]); if (!asset) { return JobStatus.FAILED; @@ -287,6 +287,7 @@ export class MetadataService implements OnEvents { DateTimeOriginal: dateTimeOriginal, GPSLatitude: latitude, GPSLongitude: longitude, + Rating: rating, }, _.isUndefined, ); @@ -503,6 +504,7 @@ export class MetadataService implements OnEvents { profileDescription: tags.ProfileDescription || null, projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null, timeZone: tags.tz ?? null, + rating: tags.Rating ?? null, }; if (exifData.latitude === 0 && exifData.longitude === 0) { diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 4d661bc57..1120e15e9 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -253,6 +253,7 @@ export const sharedLinkStub = { bitsPerSample: 8, colorspace: 'sRGB', autoStackId: null, + rating: 3, }, tags: [], sharedLinks: [], diff --git a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte new file mode 100644 index 000000000..131d2ca43 --- /dev/null +++ b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte @@ -0,0 +1,27 @@ + + +{#if !isSharedLink() && $preferences?.rating?.enabled} +
+ handlePromiseError(handleChangeRating(rating))} /> +
+{/if} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 708d841a0..268de61f0 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -41,6 +41,7 @@ import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import AlbumListItemDetails from './album-list-item-details.svelte'; import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.svelte'; + import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte'; import { t } from 'svelte-i18n'; import { goto } from '$app/navigation'; @@ -162,6 +163,7 @@ {/if} + {#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0}
diff --git a/web/src/lib/components/elements/icon.svelte b/web/src/lib/components/elements/icon.svelte index bb8377e65..bb2227628 100644 --- a/web/src/lib/components/elements/icon.svelte +++ b/web/src/lib/components/elements/icon.svelte @@ -14,6 +14,8 @@ export let ariaHidden: boolean | undefined = undefined; export let ariaLabel: string | undefined = undefined; export let ariaLabelledby: string | undefined = undefined; + export let strokeWidth: number = 0; + export let strokeColor: string = 'currentColor'; + import Icon from '$lib/components/elements/icon.svelte'; + + export let count = 5; + export let rating: number; + export let readOnly = false; + export let onRating: (rating: number) => void | undefined; + + let hoverRating = 0; + + const starIcon = + 'M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z'; + + const handleSelect = (newRating: number) => { + if (readOnly) { + return; + } + + if (newRating === rating) { + newRating = 0; + } + + rating = newRating; + + onRating?.(rating); + }; + + +
(hoverRating = 0)} on:blur|preventDefault> + {#each { length: count } as _, index} + {@const value = index + 1} + {@const filled = hoverRating >= value || (hoverRating === 0 && rating >= value)} + + {/each} +
diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte index 47e1c88a6..cd1177d27 100644 --- a/web/src/lib/components/user-settings-page/app-settings.svelte +++ b/web/src/lib/components/user-settings-page/app-settings.svelte @@ -19,6 +19,13 @@ import { locale as i18nLocale, t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; import { invalidateAll } from '$app/navigation'; + import { preferences } from '$lib/stores/user.store'; + import { updateMyPreferences } from '@immich/sdk'; + import { handleError } from '../../utils/handle-error'; + import { + notificationController, + NotificationType, + } from '$lib/components/shared-components/notification/notification'; let time = new Date(); @@ -39,6 +46,7 @@ label: findLocale(editedLocale).name || fallbackLocale.name, }; $: closestLanguage = getClosestAvailableLocale([$lang], langCodes); + $: ratingEnabled = $preferences?.rating?.enabled; onMount(() => { const interval = setInterval(() => { @@ -90,6 +98,17 @@ $locale = newLocale; } }; + + const handleRatingChange = async (enabled: boolean) => { + try { + const data = await updateMyPreferences({ userPreferencesUpdateDto: { rating: { enabled } } }); + $preferences.rating.enabled = data.rating.enabled; + + notificationController.show({ message: $t('saved_settings'), type: NotificationType.Info }); + } catch (error) { + handleError(error, $t('errors.unable_to_update_settings')); + } + };
@@ -185,6 +204,14 @@ bind:checked={$sidebarSettings.sharing} /> +
+ handleRatingChange(enabled)} + /> +
diff --git a/web/src/lib/i18n/de.json b/web/src/lib/i18n/de.json index 70f33111c..781b8ce51 100644 --- a/web/src/lib/i18n/de.json +++ b/web/src/lib/i18n/de.json @@ -1021,6 +1021,8 @@ "purchase_server_title": "Server", "purchase_settings_server_activated": "Der Server Produktschlüssel wird durch den Administrator verwaltet", "range": "Reichweite", + "rating": "Bewertung", + "rating_description": "Stellt die Exif-Bewertung im Informationsbereich dar", "raw": "RAW", "reaction_options": "Reaktionsmöglichkeiten", "read_changelog": "Changelog lesen", diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 807a19201..8c08114fe 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -957,6 +957,8 @@ "purchase_server_description_2": "Supporter status", "purchase_server_title": "Server", "purchase_settings_server_activated": "The server product key is managed by the admin", + "rating": "Star rating", + "rating_description": "Display the exif rating in the info panel", "reaction_options": "Reaction options", "read_changelog": "Read Changelog", "reassign": "Reassign",