From 644e52b1534692f896e4b6f1c67a5e4f787e5809 Mon Sep 17 00:00:00 2001 From: YFrendo Date: Thu, 30 Nov 2023 04:52:28 +0100 Subject: [PATCH] feat: Edit metadata (#5066) * chore: rebase and clean-up * feat: sync description, add e2e tests * feat: simplify web code * chore: unit tests * fix: linting * Bug fix with the arrows key * timezone typeahead filter timezone typeahead filter * small stlying * format fix * Bug fix in the map selection Bug fix in the map selection * Websocket basic Websocket basic * Update metadata visualisation through the websocket * Update timeline * fix merge * fix web * fix web * maplibre system * format fix * format fix * refactor: clean up * Fix small bug in the hour/timezone * Don't diplay modify for readOnly asset * Add log in case of failure * Formater + try/catch error * Remove everything related to websocket * Revert "Remove everything related to websocket" This reverts commit 14bcb9e1e4398e8211adfe6c14348ef8f3f5fce4. * remove notification * fix test --------- Co-authored-by: Jason Rasmussen Co-authored-by: Alex Tran --- cli/src/api/open-api/api.ts | 36 +++ mobile/openapi/doc/AssetBulkUpdateDto.md | Bin 617 -> 749 bytes mobile/openapi/doc/UpdateAssetDto.md | Bin 512 -> 644 bytes .../lib/model/asset_bulk_update_dto.dart | Bin 5754 -> 8035 bytes .../openapi/lib/model/update_asset_dto.dart | Bin 4666 -> 6947 bytes .../test/asset_bulk_update_dto_test.dart | Bin 1024 -> 1339 bytes .../openapi/test/update_asset_dto_test.dart | Bin 778 -> 1093 bytes server/immich-openapi-specs.json | 18 ++ server/src/domain/asset/asset.service.ts | 23 +- server/src/domain/asset/dto/asset.dto.ts | 46 +++- server/src/domain/job/job.constants.ts | 2 + server/src/domain/job/job.interface.ts | 9 +- server/src/domain/job/job.service.ts | 12 + .../domain/metadata/metadata.service.spec.ts | 57 +++- .../src/domain/metadata/metadata.service.ts | 39 ++- .../src/domain/repositories/job.repository.ts | 3 +- .../repositories/metadata.repository.ts | 3 +- .../infra/repositories/metadata.repository.ts | 12 +- server/src/microservices/app.service.ts | 1 + server/test/e2e/asset.e2e-spec.ts | 48 ++++ .../repositories/metadata.repository.mock.ts | 3 +- web/src/api/open-api/api.ts | 36 +++ .../components/album-page/album-viewer.svelte | 2 + .../asset-viewer/detail-panel.svelte | 256 +++++++++++++++--- .../elements/buttons/link-button.svelte | 3 +- .../lib/components/elements/dropdown.svelte | 15 +- .../actions/change-date-action.svelte | 39 +++ .../actions/change-location-action.svelte | 42 +++ .../shared-components/change-date.svelte | 128 +++++++++ .../shared-components/change-location.svelte | 60 ++++ .../shared-components/confirm-dialogue.svelte | 5 +- .../shared-components/map/map.svelte | 24 +- .../shared-components/update-panel.svelte | 15 + web/src/lib/stores/websocket.ts | 2 + .../(user)/albums/[albumId]/+page.svelte | 7 + web/src/routes/(user)/archive/+page.svelte | 2 + web/src/routes/(user)/favorites/+page.svelte | 6 + .../(user)/partners/[userId]/+page.svelte | 2 + .../(user)/people/[personId]/+page.svelte | 4 + web/src/routes/(user)/photos/+page.svelte | 6 + web/src/routes/(user)/search/+page.svelte | 4 + web/src/routes/(user)/trash/+page.svelte | 2 + 42 files changed, 895 insertions(+), 77 deletions(-) create mode 100644 web/src/lib/components/photos-page/actions/change-date-action.svelte create mode 100644 web/src/lib/components/photos-page/actions/change-location-action.svelte create mode 100644 web/src/lib/components/shared-components/change-date.svelte create mode 100644 web/src/lib/components/shared-components/change-location.svelte create mode 100644 web/src/lib/components/shared-components/update-panel.svelte diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index d0dd30fe9d..ac5ea101e8 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -447,6 +447,12 @@ export interface AssetBulkDeleteDto { * @interface AssetBulkUpdateDto */ export interface AssetBulkUpdateDto { + /** + * + * @type {string} + * @memberof AssetBulkUpdateDto + */ + 'dateTimeOriginal'?: string; /** * * @type {Array} @@ -465,6 +471,18 @@ export interface AssetBulkUpdateDto { * @memberof AssetBulkUpdateDto */ 'isFavorite'?: boolean; + /** + * + * @type {number} + * @memberof AssetBulkUpdateDto + */ + 'latitude'?: number; + /** + * + * @type {number} + * @memberof AssetBulkUpdateDto + */ + 'longitude'?: number; /** * * @type {boolean} @@ -4137,6 +4155,12 @@ export interface UpdateAlbumDto { * @interface UpdateAssetDto */ export interface UpdateAssetDto { + /** + * + * @type {string} + * @memberof UpdateAssetDto + */ + 'dateTimeOriginal'?: string; /** * * @type {string} @@ -4155,6 +4179,18 @@ export interface UpdateAssetDto { * @memberof UpdateAssetDto */ 'isFavorite'?: boolean; + /** + * + * @type {number} + * @memberof UpdateAssetDto + */ + 'latitude'?: number; + /** + * + * @type {number} + * @memberof UpdateAssetDto + */ + 'longitude'?: number; } /** * diff --git a/mobile/openapi/doc/AssetBulkUpdateDto.md b/mobile/openapi/doc/AssetBulkUpdateDto.md index 74fd5ec45311a6c4c35df8d5949165cccd117299..40ebe6a411297533365e138b3cc79affc8094ce2 100644 GIT binary patch delta 94 zcmaFK@|JbNUD=exlGKpQ+*JRf%=FB>#2hUxg&GAdt>BWP%)Io;4UA$F@Ae4gB$i~B al%}LYCG$#iK{CQQ`FZJB#205WG6DdDn;_u; delta 14 WcmaFM`jTbB-N^!sJ&WHmG6DcH;sy@@ diff --git a/mobile/openapi/doc/UpdateAssetDto.md b/mobile/openapi/doc/UpdateAssetDto.md index d214ebd47633d65bd40fcb27fa240fbf7f2d13e0..cfd8f604d249caee3ed6e9929791537d9559a478 100644 GIT binary patch delta 79 zcmZo*XH6$}P)xRh+Ju@#cXW|p(i8q@>w6t;(OEODJQ&P3G6lxT-wDL-G UCw>$Y&dJY9#}F2o9L=~C0KIn|)c^nh delta 14 VcmZo+ZD5&jYcelm?1|!6`9896}n}NNh!@Z98v+e4v>`vMx?0jkX$z$51NZww}tb_`yj-jayo%Ykw@+Ru7k+bpk;oTwsfmj<-s#k(;pmKnuYHHqtC|4@pvvF8x gMT4m>spx;gQ3-U)<3IG5A delta 72 zcmV-O0Js0+KKd-Mj{%da0Yj4%0xz?@16Bl+F9im(_zL;~lO+vjlhh5Gldukfla~*u elVcD%lgAK=v+of$0kbC*hys)R7VNXv7>ouZW*u?> diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index d1f3570ef8fb39e60cae453bcd2d328e029e6681..d90b365b72705dffea67fdbc8788f40e3d62e99d 100644 GIT binary patch delta 995 zcmbtTJ4?e*6egf;(kd0jf+F=;i*0NOc5$eL4jpt59O|TPG15yLNfXHfQ3n@)fN+0- zI=To!3JxN;xV!l;oL$W8rs>V-9lv|N$2s@h*V5zm^DQ$cTj)?3q60(eE1Ikd_>yTB z0jz;yimGfWmfb@_2HX=sy4LqFXT%sxjv_m6On1upU^>jKW)B-c_%41*ppcLADSMrw zgCs$Z!tizKViz_x`~;v|17t+8yn13mmxMORuOXg4sn!mq@7-&*szL*{pq{W_69=+6 zXzD%0LlsNuha;%^Q7Vz~qyT{@h#@1MDdwq?cwgxzJrb!z4@3aG$nNn%)VWk5o-Rf# zT#%@?**5)7c%NI^q=m~Jkb*a+Z}2a>nvi(n3eJeVWAHo2rNabR&Mn|BmtH1`#+n3i zwF*&rEtB`?Y?L+RuMhym1W0S1Ld0-tnXAMa;+Jf3o_gmpFW~#^=M6-2tHYV8YsW^J zMDx6YWeAqd+j;ji{Qa1K>Mk@C&t=0zm4t|B=`G7pw0?!>gOB5>gdO#JXzWWm`k(Pw MCFbm$%zRJ(0cqAv-T(jq delta 71 zcmV-N0J#67Ho7FRgaMPE0XPDEDYIb)Vga+<1pES%w+e5QSqo>A2n+f{Dz!n{P3CGIHmY q<|^bQmSmQcrc6G?q{IPbr%nc{-~mhK=cU7BIdh;Ku=HeQ=DPrVH7-N| delta 16 XcmdnZ)xfdg7UO1rCQru6aV&QMG(83R diff --git a/mobile/openapi/test/update_asset_dto_test.dart b/mobile/openapi/test/update_asset_dto_test.dart index b2966e961a594b8ae461a59a3d08e04abf7fc37f..9d9874beb8fa296b2d003cdc443a926e492c3110 100644 GIT binary patch delta 124 zcmeBTJIb-)8Y6#VNoq)DZmNG#W_o5`V$S3Tj7k_{JWRTq&oLS?3M&ACzP>_UX|6&} kVo7F6X-X { await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, id); - const { description, ...rest } = dto; - if (description !== undefined) { - await this.assetRepository.upsertExif({ assetId: id, description }); - } + const { description, dateTimeOriginal, latitude, longitude, ...rest } = dto; + await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude }); const asset = await this.assetRepository.save({ id, ...rest }); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [id] } }); @@ -404,7 +402,7 @@ export class AssetService { } async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise { - const { ids, removeParent, ...options } = dto; + const { ids, removeParent, dateTimeOriginal, latitude, longitude, ...options } = dto; await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids); if (removeParent) { @@ -424,6 +422,10 @@ export class AssetService { await this.assetRepository.updateAll([options.stackParentId], { stackParentId: null }); } + for (const id of ids) { + await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); + } + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); await this.assetRepository.updateAll(ids, options); this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids); @@ -587,4 +589,13 @@ export class AssetService { } } } + + private async updateMetadata(dto: ISidecarWriteJob) { + const { id, description, dateTimeOriginal, latitude, longitude } = dto; + const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude }, _.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/domain/asset/dto/asset.dto.ts b/server/src/domain/asset/dto/asset.dto.ts index c7c371706e..ac50f22426 100644 --- a/server/src/domain/asset/dto/asset.dto.ts +++ b/server/src/domain/asset/dto/asset.dto.ts @@ -1,7 +1,19 @@ import { AssetType } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsBoolean, IsEnum, IsInt, IsPositive, IsString, Min } from 'class-validator'; +import { + IsBoolean, + IsDateString, + IsEnum, + IsInt, + IsLatitude, + IsLongitude, + IsNotEmpty, + IsPositive, + IsString, + Min, + ValidateIf, +} from 'class-validator'; import { Optional, QueryBoolean, QueryDate, ValidateUUID } from '../../domain.util'; import { BulkIdsDto } from '../response-dto'; @@ -10,6 +22,10 @@ export enum AssetOrder { DESC = 'desc', } +const hasGPS = (o: { latitude: undefined; longitude: undefined }) => + o.latitude !== undefined || o.longitude !== undefined; +const ValidateGPS = () => ValidateIf(hasGPS); + export class AssetSearchDto { @ValidateUUID({ optional: true }) id?: string; @@ -172,6 +188,20 @@ export class AssetBulkUpdateDto extends BulkIdsDto { @Optional() @IsBoolean() removeParent?: boolean; + + @Optional() + @IsDateString() + dateTimeOriginal?: string; + + @ValidateGPS() + @IsLatitude() + @IsNotEmpty() + latitude?: number; + + @ValidateGPS() + @IsLongitude() + @IsNotEmpty() + longitude?: number; } export class UpdateAssetDto { @@ -186,6 +216,20 @@ export class UpdateAssetDto { @Optional() @IsString() description?: string; + + @Optional() + @IsDateString() + dateTimeOriginal?: string; + + @ValidateGPS() + @IsLatitude() + @IsNotEmpty() + latitude?: number; + + @ValidateGPS() + @IsLongitude() + @IsNotEmpty() + longitude?: number; } export class RandomAssetsDto { diff --git a/server/src/domain/job/job.constants.ts b/server/src/domain/job/job.constants.ts index c5b4fe235b..a7f4677849 100644 --- a/server/src/domain/job/job.constants.ts +++ b/server/src/domain/job/job.constants.ts @@ -96,6 +96,7 @@ export enum JobName { QUEUE_SIDECAR = 'queue-sidecar', SIDECAR_DISCOVERY = 'sidecar-discovery', SIDECAR_SYNC = 'sidecar-sync', + SIDECAR_WRITE = 'sidecar-write', } export const JOBS_ASSET_PAGINATION_SIZE = 1000; @@ -168,6 +169,7 @@ export const JOBS_TO_QUEUE: Record = { [JobName.QUEUE_SIDECAR]: QueueName.SIDECAR, [JobName.SIDECAR_DISCOVERY]: QueueName.SIDECAR, [JobName.SIDECAR_SYNC]: QueueName.SIDECAR, + [JobName.SIDECAR_WRITE]: QueueName.SIDECAR, // Library management [JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY, diff --git a/server/src/domain/job/job.interface.ts b/server/src/domain/job/job.interface.ts index 033dfdac4a..be76f6645e 100644 --- a/server/src/domain/job/job.interface.ts +++ b/server/src/domain/job/job.interface.ts @@ -9,7 +9,7 @@ export interface IAssetFaceJob extends IBaseJob { export interface IEntityJob extends IBaseJob { id: string; - source?: 'upload'; + source?: 'upload' | 'sidecar-write'; } export interface IAssetDeletionJob extends IEntityJob { @@ -33,3 +33,10 @@ export interface IBulkEntityJob extends IBaseJob { export interface IDeleteFilesJob extends IBaseJob { files: Array; } + +export interface ISidecarWriteJob extends IEntityJob { + description?: string; + dateTimeOriginal?: string; + latitude?: number; + longitude?: number; +} diff --git a/server/src/domain/job/job.service.ts b/server/src/domain/job/job.service.ts index 7ebffcc693..4735eb6b59 100644 --- a/server/src/domain/job/job.service.ts +++ b/server/src/domain/job/job.service.ts @@ -165,7 +165,19 @@ export class JobService { await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: item.data }); break; + case JobName.SIDECAR_WRITE: + await this.jobRepository.queue({ + name: JobName.METADATA_EXTRACTION, + data: { id: item.data.id, source: 'sidecar-write' }, + }); + case JobName.METADATA_EXTRACTION: + if (item.data.source === 'sidecar-write') { + const [asset] = await this.assetRepository.getByIds([item.data.id]); + if (asset) { + this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, asset.ownerId, mapAsset(asset)); + } + } await this.jobRepository.queue({ name: JobName.LINK_LIVE_PHOTOS, data: item.data }); break; diff --git a/server/src/domain/metadata/metadata.service.spec.ts b/server/src/domain/metadata/metadata.service.spec.ts index 7ce7db054a..0ef5dfd736 100644 --- a/server/src/domain/metadata/metadata.service.spec.ts +++ b/server/src/domain/metadata/metadata.service.spec.ts @@ -218,11 +218,11 @@ describe(MetadataService.name, () => { const originalDate = new Date('2023-11-21T16:13:17.517Z'); const sidecarDate = new Date('2022-01-01T00:00:00.000Z'); assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); - when(metadataMock.getExifTags) + when(metadataMock.readTags) .calledWith(assetStub.sidecar.originalPath) // higher priority tag .mockResolvedValue({ CreationDate: originalDate.toISOString() }); - when(metadataMock.getExifTags) + when(metadataMock.readTags) .calledWith(assetStub.sidecar.sidecarPath as string) // lower priority tag, but in sidecar .mockResolvedValue({ CreateDate: sidecarDate.toISOString() }); @@ -240,7 +240,7 @@ describe(MetadataService.name, () => { it('should handle lists of numbers', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.getExifTags.mockResolvedValue({ ISO: [160] as any }); + metadataMock.readTags.mockResolvedValue({ ISO: [160] as any }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); @@ -257,7 +257,7 @@ describe(MetadataService.name, () => { assetMock.getByIds.mockResolvedValue([assetStub.withLocation]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: true }]); metadataMock.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); - metadataMock.getExifTags.mockResolvedValue({ + metadataMock.readTags.mockResolvedValue({ GPSLatitude: assetStub.withLocation.exifInfo!.latitude!, GPSLongitude: assetStub.withLocation.exifInfo!.longitude!, }); @@ -289,7 +289,7 @@ describe(MetadataService.name, () => { it('should apply motion photos', async () => { assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]); - metadataMock.getExifTags.mockResolvedValue({ + metadataMock.readTags.mockResolvedValue({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, @@ -310,7 +310,7 @@ describe(MetadataService.name, () => { it('should create new motion asset if not found and link it with the photo', async () => { assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]); - metadataMock.getExifTags.mockResolvedValue({ + metadataMock.readTags.mockResolvedValue({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, @@ -367,7 +367,7 @@ describe(MetadataService.name, () => { tz: '+02:00', }; assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.getExifTags.mockResolvedValue(tags); + metadataMock.readTags.mockResolvedValue(tags); await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); @@ -406,7 +406,7 @@ describe(MetadataService.name, () => { it('should handle duration', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.getExifTags.mockResolvedValue({ Duration: 6.21 }); + metadataMock.readTags.mockResolvedValue({ Duration: 6.21 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -422,7 +422,7 @@ describe(MetadataService.name, () => { it('should handle duration as an object without Scale', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.getExifTags.mockResolvedValue({ Duration: { Value: 6.2 } }); + metadataMock.readTags.mockResolvedValue({ Duration: { Value: 6.2 } }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -438,7 +438,7 @@ describe(MetadataService.name, () => { it('should handle duration with scale', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.getExifTags.mockResolvedValue({ Duration: { Scale: 1.11111111111111e-5, Value: 558720 } }); + metadataMock.readTags.mockResolvedValue({ Duration: { Scale: 1.11111111111111e-5, Value: 558720 } }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -531,4 +531,41 @@ describe(MetadataService.name, () => { }); }); }); + + describe('handleSidecarWrite', () => { + it('should skip assets that do not exist anymore', async () => { + assetMock.getByIds.mockResolvedValue([]); + await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(false); + expect(metadataMock.writeTags).not.toHaveBeenCalled(); + }); + + it('should skip jobs with not metadata', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + await expect(sut.handleSidecarWrite({ id: assetStub.sidecar.id })).resolves.toBe(true); + expect(metadataMock.writeTags).not.toHaveBeenCalled(); + }); + + it('should write tags', async () => { + const description = 'this is a description'; + const gps = 12; + const date = '2023-11-22T04:56:12.196Z'; + + assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + await expect( + sut.handleSidecarWrite({ + id: assetStub.sidecar.id, + description, + latitude: gps, + longitude: gps, + dateTimeOriginal: date, + }), + ).resolves.toBe(true); + expect(metadataMock.writeTags).toHaveBeenCalledWith(assetStub.sidecar.sidecarPath, { + ImageDescription: description, + CreationDate: date, + GPSLatitude: gps, + GPSLongitude: gps, + }); + }); + }); }); diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts index 77dcfecb0b..e9c7ff9318 100644 --- a/server/src/domain/metadata/metadata.service.ts +++ b/server/src/domain/metadata/metadata.service.ts @@ -3,10 +3,11 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { ExifDateTime, Tags } from 'exiftool-vendored'; import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime'; import { constants } from 'fs/promises'; +import _ from 'lodash'; import { Duration } from 'luxon'; import { Subscription } from 'rxjs'; import { usePagination } from '../domain.util'; -import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job'; +import { IBaseJob, IEntityJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job'; import { ExifDuration, IAlbumRepository, @@ -79,7 +80,6 @@ export class MetadataService { private logger = new Logger(MetadataService.name); private storageCore: StorageCore; private configCore: SystemConfigCore; - private oldCities?: string; private subscription: Subscription | null = null; constructor( @@ -244,6 +244,37 @@ export class MetadataService { return true; } + async handleSidecarWrite(job: ISidecarWriteJob) { + const { id, description, dateTimeOriginal, latitude, longitude } = job; + const [asset] = await this.assetRepository.getByIds([id]); + if (!asset) { + return false; + } + + const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`; + const exif = _.omitBy( + { + ImageDescription: description, + CreationDate: dateTimeOriginal, + GPSLatitude: latitude, + GPSLongitude: longitude, + }, + _.isUndefined, + ); + + if (Object.keys(exif).length === 0) { + return true; + } + + await this.repository.writeTags(sidecarPath, exif); + + if (!asset.sidecarPath) { + await this.assetRepository.save({ id, sidecarPath }); + } + + return true; + } + private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) { const { latitude, longitude } = exifData; if (!(await this.configCore.hasFeature(FeatureFlag.REVERSE_GEOCODING)) || !longitude || !latitude) { @@ -346,8 +377,8 @@ export class MetadataService { asset: AssetEntity, ): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; tags: ImmichTags }> { const stats = await this.storageRepository.stat(asset.originalPath); - const mediaTags = await this.repository.getExifTags(asset.originalPath); - const sidecarTags = asset.sidecarPath ? await this.repository.getExifTags(asset.sidecarPath) : null; + const mediaTags = await this.repository.readTags(asset.originalPath); + const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null; // ensure date from sidecar is used if present const hasDateOverride = !!this.getDateTimeOriginal(sidecarTags); diff --git a/server/src/domain/repositories/job.repository.ts b/server/src/domain/repositories/job.repository.ts index 4b426062f2..7b9deabbd5 100644 --- a/server/src/domain/repositories/job.repository.ts +++ b/server/src/domain/repositories/job.repository.ts @@ -9,6 +9,7 @@ import { IEntityJob, ILibraryFileJob, ILibraryRefreshJob, + ISidecarWriteJob, } from '../job/job.interface'; export interface JobCounts { @@ -54,11 +55,11 @@ export type JobItem = | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob } | { name: JobName.METADATA_EXTRACTION; data: IEntityJob } | { name: JobName.LINK_LIVE_PHOTOS; data: IEntityJob } - // Sidecar Scanning | { name: JobName.QUEUE_SIDECAR; data: IBaseJob } | { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob } | { name: JobName.SIDECAR_SYNC; data: IEntityJob } + | { name: JobName.SIDECAR_WRITE; data: ISidecarWriteJob } // Object Tagging | { name: JobName.QUEUE_OBJECT_TAGGING; data: IBaseJob } diff --git a/server/src/domain/repositories/metadata.repository.ts b/server/src/domain/repositories/metadata.repository.ts index c0a0fef46a..e8d4d1e4e4 100644 --- a/server/src/domain/repositories/metadata.repository.ts +++ b/server/src/domain/repositories/metadata.repository.ts @@ -33,5 +33,6 @@ export interface IMetadataRepository { init(): Promise; teardown(): Promise; reverseGeocode(point: GeoPoint): Promise; - getExifTags(path: string): Promise; + readTags(path: string): Promise; + writeTags(path: string, tags: Partial): Promise; } diff --git a/server/src/infra/repositories/metadata.repository.ts b/server/src/infra/repositories/metadata.repository.ts index 8f8d068e56..d8f91dd1a2 100644 --- a/server/src/infra/repositories/metadata.repository.ts +++ b/server/src/infra/repositories/metadata.repository.ts @@ -9,7 +9,7 @@ import { GeodataAdmin1Entity, GeodataAdmin2Entity, GeodataPlacesEntity, SystemMe import { DatabaseLock } from '@app/infra/utils/database-locks'; import { Inject, Logger } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; -import { DefaultReadTaskOptions, exiftool } from 'exiftool-vendored'; +import { DefaultReadTaskOptions, exiftool, Tags } from 'exiftool-vendored'; import { createReadStream, existsSync } from 'fs'; import { readFile } from 'fs/promises'; import * as geotz from 'geo-tz'; @@ -181,7 +181,7 @@ export class MetadataRepository implements IMetadataRepository { return { country, state, city }; } - getExifTags(path: string): Promise { + readTags(path: string): Promise { return exiftool .read(path, undefined, { ...DefaultReadTaskOptions, @@ -198,4 +198,12 @@ export class MetadataRepository implements IMetadataRepository { return null; }) as Promise; } + + async writeTags(path: string, tags: Partial): Promise { + try { + await exiftool.write(path, tags, ['-overwrite_original']); + } catch (error) { + this.logger.warn(`Error writing exif data (${path}): ${error}`); + } + } } diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts index 554519114e..3f89fa06fa 100644 --- a/server/src/microservices/app.service.ts +++ b/server/src/microservices/app.service.ts @@ -84,6 +84,7 @@ export class AppService { [JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(data), [JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data), [JobName.SIDECAR_SYNC]: () => this.metadataService.handleSidecarSync(), + [JobName.SIDECAR_WRITE]: (data) => this.metadataService.handleSidecarWrite(data), [JobName.LIBRARY_SCAN_ASSET]: (data) => this.libraryService.handleAssetRefresh(data), [JobName.LIBRARY_SCAN]: (data) => this.libraryService.handleQueueAssetRefresh(data), [JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data), diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts index 53468a480f..95cc92b6ac 100644 --- a/server/test/e2e/asset.e2e-spec.ts +++ b/server/test/e2e/asset.e2e-spec.ts @@ -700,6 +700,54 @@ describe(`${AssetController.name} (e2e)`, () => { expect(status).toEqual(200); }); + it('should update date time original', async () => { + const { status, body } = await request(server) + .put(`/asset/${asset1.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' }); + + expect(body).toMatchObject({ + id: asset1.id, + exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00.000Z' }), + }); + expect(status).toEqual(200); + }); + + it('should reject invalid gps coordinates', async () => { + for (const test of [ + { latitude: 12 }, + { longitude: 12 }, + { latitude: 12, longitude: 'abc' }, + { latitude: 'abc', longitude: 12 }, + { latitude: null, longitude: 12 }, + { latitude: 12, longitude: null }, + { latitude: 91, longitude: 12 }, + { latitude: -91, longitude: 12 }, + { latitude: 12, longitude: -181 }, + { latitude: 12, longitude: 181 }, + ]) { + const { status, body } = await request(server) + .put(`/asset/${asset1.id}`) + .send(test) + .set('Authorization', `Bearer ${user1.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest()); + } + }); + + it('should update gps data', async () => { + const { status, body } = await request(server) + .put(`/asset/${asset1.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ latitude: 12, longitude: 12 }); + + expect(body).toMatchObject({ + id: asset1.id, + exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }), + }); + expect(status).toEqual(200); + }); + it('should set the description', async () => { const { status, body } = await request(server) .put(`/asset/${asset1.id}`) diff --git a/server/test/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts index c602c54d56..3e97cb327e 100644 --- a/server/test/repositories/metadata.repository.mock.ts +++ b/server/test/repositories/metadata.repository.mock.ts @@ -2,9 +2,10 @@ import { IMetadataRepository } from '@app/domain'; export const newMetadataRepositoryMock = (): jest.Mocked => { return { - getExifTags: jest.fn(), init: jest.fn(), teardown: jest.fn(), reverseGeocode: jest.fn(), + readTags: jest.fn(), + writeTags: jest.fn(), }; }; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index d0dd30fe9d..ac5ea101e8 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -447,6 +447,12 @@ export interface AssetBulkDeleteDto { * @interface AssetBulkUpdateDto */ export interface AssetBulkUpdateDto { + /** + * + * @type {string} + * @memberof AssetBulkUpdateDto + */ + 'dateTimeOriginal'?: string; /** * * @type {Array} @@ -465,6 +471,18 @@ export interface AssetBulkUpdateDto { * @memberof AssetBulkUpdateDto */ 'isFavorite'?: boolean; + /** + * + * @type {number} + * @memberof AssetBulkUpdateDto + */ + 'latitude'?: number; + /** + * + * @type {number} + * @memberof AssetBulkUpdateDto + */ + 'longitude'?: number; /** * * @type {boolean} @@ -4137,6 +4155,12 @@ export interface UpdateAlbumDto { * @interface UpdateAssetDto */ export interface UpdateAssetDto { + /** + * + * @type {string} + * @memberof UpdateAssetDto + */ + 'dateTimeOriginal'?: string; /** * * @type {string} @@ -4155,6 +4179,18 @@ export interface UpdateAssetDto { * @memberof UpdateAssetDto */ 'isFavorite'?: boolean; + /** + * + * @type {number} + * @memberof UpdateAssetDto + */ + 'latitude'?: number; + /** + * + * @type {number} + * @memberof UpdateAssetDto + */ + 'longitude'?: number; } /** * diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 5b10ab1e87..ba71c396d8 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -20,6 +20,7 @@ import ThemeButton from '../shared-components/theme-button.svelte'; import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js'; + import UpdatePanel from '../shared-components/update-panel.svelte'; export let sharedLink: SharedLinkResponseDto; export let user: UserResponseDto | undefined = undefined; @@ -167,4 +168,5 @@

+ diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 366c4e9301..29b91a228b 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -5,22 +5,27 @@ import { getAssetFilename } from '$lib/utils/asset-utils'; import { AlbumResponseDto, AssetResponseDto, ThumbnailFormat, api } from '@api'; import { DateTime } from 'luxon'; - import { createEventDispatcher } from 'svelte'; + import { createEventDispatcher, onDestroy } from 'svelte'; import { slide } from 'svelte/transition'; import { asByteUnitString } from '../../utils/byte-units'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import UserAvatar from '../shared-components/user-avatar.svelte'; + import ChangeDate from '$lib/components/shared-components/change-date.svelte'; import { mdiCalendar, mdiCameraIris, mdiClose, + mdiPencil, mdiImageOutline, mdiMapMarkerOutline, mdiInformationOutline, } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; import Map from '../shared-components/map/map.svelte'; + import { websocketStore } from '$lib/stores/websocket'; import { AppRoute } from '$lib/constants'; + import ChangeLocation from '../shared-components/change-location.svelte'; + import { handleError } from '../../utils/handle-error'; export let asset: AssetResponseDto; export let albums: AlbumResponseDto[] = []; @@ -52,6 +57,16 @@ $: people = asset.people || []; + const unsubscribe = websocketStore.onAssetUpdate.subscribe((assetUpdate) => { + if (assetUpdate && assetUpdate.id === asset.id) { + asset = assetUpdate; + } + }); + + onDestroy(() => { + unsubscribe(); + }); + const dispatch = createEventDispatcher(); const getMegapixel = (width: number, height: number): number | undefined => { @@ -79,9 +94,7 @@ try { await api.assetApi.updateAsset({ id: asset.id, - updateAssetDto: { - description: description, - }, + updateAssetDto: { description }, }); } catch (error) { console.error(error); @@ -90,6 +103,35 @@ let showAssetPath = false; const toggleAssetPath = () => (showAssetPath = !showAssetPath); + + let isShowChangeDate = false; + + async function handleConfirmChangeDate(dateTimeOriginal: string) { + isShowChangeDate = false; + try { + await api.assetApi.updateAsset({ id: asset.id, updateAssetDto: { dateTimeOriginal } }); + } catch (error) { + handleError(error, 'Unable to change date'); + } + } + + let isShowChangeLocation = false; + + async function handleConfirmChangeLocation(gps: { lng: number; lat: number }) { + isShowChangeLocation = false; + + try { + await api.assetApi.updateAsset({ + id: asset.id, + updateAssetDto: { + latitude: gps.lat, + longitude: gps.lng, + }, + }); + } catch (error) { + handleError(error, 'Unable to change location'); + } + }
@@ -191,41 +233,115 @@

DETAILS

{/if} - {#if asset.exifInfo?.dateTimeOriginal} + {#if asset.exifInfo?.dateTimeOriginal && !asset.isReadOnly} {@const assetDateTimeOriginal = DateTime.fromISO(asset.exifInfo.dateTimeOriginal, { zone: asset.exifInfo.timeZone ?? undefined, })} -
-
- -
+
(isShowChangeDate = true)} + on:keydown={(event) => event.key === 'Enter' && (isShowChangeDate = true)} + tabindex="0" + role="button" + title="Edit date" + > +
+
+ +
-
-

- {assetDateTimeOriginal.toLocaleString( - { - month: 'short', - day: 'numeric', - year: 'numeric', - }, - { locale: $locale }, - )} -

-
+

{assetDateTimeOriginal.toLocaleString( { - weekday: 'short', - hour: 'numeric', - minute: '2-digit', - timeZoneName: 'longOffset', + month: 'short', + day: 'numeric', + year: 'numeric', }, { locale: $locale }, )}

+
+

+ {assetDateTimeOriginal.toLocaleString( + { + weekday: 'short', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'longOffset', + }, + { locale: $locale }, + )} +

+
-
{/if} + +
+ {:else if !asset.exifInfo?.dateTimeOriginal && !asset.isReadOnly} +
+
+
+ +
+
+ +
+ {:else if asset.exifInfo?.dateTimeOriginal && asset.isReadOnly} + {@const assetDateTimeOriginal = DateTime.fromISO(asset.exifInfo.dateTimeOriginal, { + zone: asset.exifInfo.timeZone ?? undefined, + })} +
+
+
+ +
+ +
+

+ {assetDateTimeOriginal.toLocaleString( + { + month: 'short', + day: 'numeric', + year: 'numeric', + }, + { locale: $locale }, + )} +

+
+

+ {assetDateTimeOriginal.toLocaleString( + { + weekday: 'short', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'longOffset', + }, + { locale: $locale }, + )} +

+
+
+
+
+ {/if} + + {#if isShowChangeDate} + {@const assetDateTimeOriginal = asset.exifInfo?.dateTimeOriginal + ? DateTime.fromISO(asset.exifInfo.dateTimeOriginal, { + zone: asset.exifInfo.timeZone ?? undefined, + }) + : DateTime.now()} + handleConfirmChangeDate(date)} + on:cancel={() => (isShowChangeDate = false)} + /> + {/if} {#if asset.exifInfo?.fileSizeInByte}
@@ -292,24 +408,84 @@
{/if} - {#if asset.exifInfo?.city} -
-
+ {#if asset.exifInfo?.city && !asset.isReadOnly} +
(isShowChangeLocation = true)} + on:keydown={(event) => event.key === 'Enter' && (isShowChangeLocation = true)} + tabindex="0" + role="button" + title="Edit location" + > +
+
+ +
+

{asset.exifInfo.city}

+ {#if asset.exifInfo?.state} +
+

{asset.exifInfo.state}

+
+ {/if} + {#if asset.exifInfo?.country} +
+

{asset.exifInfo.country}

+
+ {/if} +
+
-

{asset.exifInfo.city}

- {#if asset.exifInfo?.state} -
-

{asset.exifInfo.state}

-
- {/if} - {#if asset.exifInfo?.country} -
-

{asset.exifInfo.country}

-
- {/if} +
+ {:else if !asset.exifInfo?.city && !asset.isReadOnly} +
(isShowChangeLocation = true)} + on:keydown={(event) => event.key === 'Enter' && (isShowChangeLocation = true)} + tabindex="0" + role="button" + title="Add location" + > +
+
+
+
+ +

Add a location

+
+
+ +
+
+ {:else if asset.exifInfo?.city && asset.isReadOnly} +
+
+
+ +
+

{asset.exifInfo.city}

+ {#if asset.exifInfo?.state} +
+

{asset.exifInfo.state}

+
+ {/if} + {#if asset.exifInfo?.country} +
+

{asset.exifInfo.country}

+
+ {/if} +
+
+
+ {/if} + {#if isShowChangeLocation} + handleConfirmChangeLocation(gps)} + on:cancel={() => (isShowChangeLocation = false)} + /> {/if}
diff --git a/web/src/lib/components/elements/buttons/link-button.svelte b/web/src/lib/components/elements/buttons/link-button.svelte index d5fe8b29b5..2cb22d41da 100644 --- a/web/src/lib/components/elements/buttons/link-button.svelte +++ b/web/src/lib/components/elements/buttons/link-button.svelte @@ -7,8 +7,9 @@ export let color: Color = 'transparent-gray'; export let disabled = false; + export let fullwidth = false; - diff --git a/web/src/lib/components/elements/dropdown.svelte b/web/src/lib/components/elements/dropdown.svelte index 65c551e432..6bf9b55d65 100644 --- a/web/src/lib/components/elements/dropdown.svelte +++ b/web/src/lib/components/elements/dropdown.svelte @@ -29,10 +29,13 @@ icon?: string; }; - let showMenu = false; + export let showMenu = false; + export let controlable = false; const handleClickOutside = () => { - showMenu = false; + if (!controlable) { + showMenu = false; + } }; const handleSelectOption = (option: T) => { @@ -60,7 +63,7 @@