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 74fd5ec453..40ebe6a411 100644 Binary files a/mobile/openapi/doc/AssetBulkUpdateDto.md and b/mobile/openapi/doc/AssetBulkUpdateDto.md differ diff --git a/mobile/openapi/doc/UpdateAssetDto.md b/mobile/openapi/doc/UpdateAssetDto.md index d214ebd476..cfd8f604d2 100644 Binary files a/mobile/openapi/doc/UpdateAssetDto.md and b/mobile/openapi/doc/UpdateAssetDto.md differ diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index 64c8d1e7e7..60cab8c749 100644 Binary files a/mobile/openapi/lib/model/asset_bulk_update_dto.dart and b/mobile/openapi/lib/model/asset_bulk_update_dto.dart differ diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index d1f3570ef8..d90b365b72 100644 Binary files a/mobile/openapi/lib/model/update_asset_dto.dart and b/mobile/openapi/lib/model/update_asset_dto.dart differ diff --git a/mobile/openapi/test/asset_bulk_update_dto_test.dart b/mobile/openapi/test/asset_bulk_update_dto_test.dart index 06f65de666..d04bdd8091 100644 Binary files a/mobile/openapi/test/asset_bulk_update_dto_test.dart and b/mobile/openapi/test/asset_bulk_update_dto_test.dart differ diff --git a/mobile/openapi/test/update_asset_dto_test.dart b/mobile/openapi/test/update_asset_dto_test.dart index b2966e961a..9d9874beb8 100644 Binary files a/mobile/openapi/test/update_asset_dto_test.dart and b/mobile/openapi/test/update_asset_dto_test.dart differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 66ebe19207..3563998138 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -6449,6 +6449,9 @@ }, "AssetBulkUpdateDto": { "properties": { + "dateTimeOriginal": { + "type": "string" + }, "ids": { "items": { "format": "uuid", @@ -6462,6 +6465,12 @@ "isFavorite": { "type": "boolean" }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + }, "removeParent": { "type": "boolean" }, @@ -9343,6 +9352,9 @@ }, "UpdateAssetDto": { "properties": { + "dateTimeOriginal": { + "type": "string" + }, "description": { "type": "string" }, @@ -9351,6 +9363,12 @@ }, "isFavorite": { "type": "boolean" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" } }, "type": "object" diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 86e4809325..c547d6a6db 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -8,7 +8,7 @@ import { AccessCore, Permission } from '../access'; import { AuthUserDto } from '../auth'; import { mimeTypes } from '../domain.constant'; import { HumanReadableSize, usePagination } from '../domain.util'; -import { IAssetDeletionJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; +import { IAssetDeletionJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; import { CommunicationEvent, IAccessRepository, @@ -393,10 +393,8 @@ export class AssetService { async update(authUser: AuthUserDto, id: string, dto: UpdateAssetDto): Promise { 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 @@