mirror of
https://github.com/immich-app/immich.git
synced 2025-12-18 01:11:07 +03:00
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 <christoph@suter-burri.ch> 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 <jrasm91@gmail.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -147,6 +147,7 @@ export interface ISidecarWriteJob extends IEntityJob {
|
||||
dateTimeOriginal?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
export interface IDeferrableJob extends IEntityJob {
|
||||
|
||||
14
server/src/migrations/1722753178937-AddExifRating.ts
Normal file
14
server/src/migrations/1722753178937-AddExifRating.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddRating1722753178937 implements MigrationInterface {
|
||||
name = 'AddRating1722753178937'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "exif" ADD "rating" integer`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "rating"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -158,8 +158,8 @@ export class AssetService {
|
||||
async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
|
||||
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 } });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -273,7 +273,7 @@ export class MetadataService implements OnEvents {
|
||||
}
|
||||
|
||||
async handleSidecarWrite(job: ISidecarWriteJob): Promise<JobStatus> {
|
||||
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) {
|
||||
|
||||
1
server/test/fixtures/shared-link.stub.ts
vendored
1
server/test/fixtures/shared-link.stub.ts
vendored
@@ -253,6 +253,7 @@ export const sharedLinkStub = {
|
||||
bitsPerSample: 8,
|
||||
colorspace: 'sRGB',
|
||||
autoStackId: null,
|
||||
rating: 3,
|
||||
},
|
||||
tags: [],
|
||||
sharedLinks: [],
|
||||
|
||||
Reference in New Issue
Block a user