diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 0215795188..f25a0798d2 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -1,5 +1,21 @@ -- NOTE: This file is auto generated by ./sql-generator +-- AssetRepository.upsertExif +insert into + "asset_exif" ("dateTimeOriginal", "lockedProperties") +values + ($1, $2) +on conflict ("assetId") do update +set + "dateTimeOriginal" = "excluded"."dateTimeOriginal", + "lockedProperties" = nullif( + array( + select distinct + unnest("asset_exif"."lockedProperties" || $3) + ), + '{}' + ) + -- AssetRepository.updateAllExif update "asset_exif" set diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 069a5f4b19..5ddbf09012 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -120,9 +120,15 @@ const distinctLocked = (eb: ExpressionBuild export class AssetRepository { constructor(@InjectKysely() private db: Kysely) {} + @GenerateSql({ + params: [ + { dateTimeOriginal: DummyValue.DATE, lockedProperties: ['dateTimeOriginal'] }, + { lockedPropertiesBehavior: 'append' }, + ], + }) async upsertExif( exif: Insertable, - { lockedPropertiesBehavior }: { lockedPropertiesBehavior: 'none' | 'update' | 'skip' }, + { lockedPropertiesBehavior }: { lockedPropertiesBehavior: 'override' | 'append' | 'skip' }, ): Promise { await this.db .insertInto('asset_exif') @@ -137,44 +143,46 @@ export class AssetRepository { .then(eb.ref(`asset_exif.${col}`)) .else(eb.ref(`excluded.${col}`)) .end(); - const ref = lockedPropertiesBehavior === 'update' ? updateLocked : skipLocked; - return removeUndefinedKeys( - { - description: ref('description'), - exifImageWidth: ref('exifImageWidth'), - exifImageHeight: ref('exifImageHeight'), - fileSizeInByte: ref('fileSizeInByte'), - orientation: ref('orientation'), - dateTimeOriginal: ref('dateTimeOriginal'), - modifyDate: ref('modifyDate'), - timeZone: ref('timeZone'), - latitude: ref('latitude'), - longitude: ref('longitude'), - projectionType: ref('projectionType'), - city: ref('city'), - livePhotoCID: ref('livePhotoCID'), - autoStackId: ref('autoStackId'), - state: ref('state'), - country: ref('country'), - make: ref('make'), - model: ref('model'), - lensModel: ref('lensModel'), - fNumber: ref('fNumber'), - focalLength: ref('focalLength'), - iso: ref('iso'), - exposureTime: ref('exposureTime'), - profileDescription: ref('profileDescription'), - colorspace: ref('colorspace'), - bitsPerSample: ref('bitsPerSample'), - rating: ref('rating'), - fps: ref('fps'), - lockedProperties: - exif.lockedProperties !== undefined && lockedPropertiesBehavior !== 'none' - ? distinctLocked(eb, exif.lockedProperties) - : exif.lockedProperties, - }, - exif, - ); + const ref = lockedPropertiesBehavior === 'skip' ? skipLocked : updateLocked; + return { + ...removeUndefinedKeys( + { + description: ref('description'), + exifImageWidth: ref('exifImageWidth'), + exifImageHeight: ref('exifImageHeight'), + fileSizeInByte: ref('fileSizeInByte'), + orientation: ref('orientation'), + dateTimeOriginal: ref('dateTimeOriginal'), + modifyDate: ref('modifyDate'), + timeZone: ref('timeZone'), + latitude: ref('latitude'), + longitude: ref('longitude'), + projectionType: ref('projectionType'), + city: ref('city'), + livePhotoCID: ref('livePhotoCID'), + autoStackId: ref('autoStackId'), + state: ref('state'), + country: ref('country'), + make: ref('make'), + model: ref('model'), + lensModel: ref('lensModel'), + fNumber: ref('fNumber'), + focalLength: ref('focalLength'), + iso: ref('iso'), + exposureTime: ref('exposureTime'), + profileDescription: ref('profileDescription'), + colorspace: ref('colorspace'), + bitsPerSample: ref('bitsPerSample'), + rating: ref('rating'), + fps: ref('fps'), + lockedProperties: + lockedPropertiesBehavior === 'append' + ? distinctLocked(eb, exif.lockedProperties ?? null) + : ref('lockedProperties'), + }, + exif, + ), + }; }), ) .execute(); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 8481308c67..2bb8530c1c 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -370,7 +370,10 @@ export class AssetMediaService extends BaseService { : this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar })); await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); - await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size }, { lockedPropertiesBehavior: 'none' }); + await this.assetRepository.upsertExif( + { assetId, fileSizeInByte: file.size }, + { lockedPropertiesBehavior: 'override' }, + ); await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: assetId, source: 'upload' }, @@ -401,7 +404,7 @@ export class AssetMediaService extends BaseService { const { size } = await this.storageRepository.stat(created.originalPath); await this.assetRepository.upsertExif( { assetId: created.id, fileSizeInByte: size }, - { lockedPropertiesBehavior: 'none' }, + { lockedPropertiesBehavior: 'override' }, ); await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: created.id, source: 'copy' } }); return created; @@ -445,7 +448,7 @@ export class AssetMediaService extends BaseService { await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); await this.assetRepository.upsertExif( { assetId: asset.id, fileSizeInByte: file.size }, - { lockedPropertiesBehavior: 'none' }, + { lockedPropertiesBehavior: 'override' }, ); await this.eventRepository.emit('AssetCreate', { asset }); diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 35b818bfe6..5e1cce2ccf 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -226,8 +226,8 @@ describe(AssetService.name, () => { await sut.update(authStub.admin, 'asset-1', { description: 'Test description' }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - { assetId: 'asset-1', description: 'Test description' }, - { lockedPropertiesBehavior: 'update' }, + { assetId: 'asset-1', description: 'Test description', lockedProperties: ['description'] }, + { lockedPropertiesBehavior: 'append' }, ); }); @@ -242,8 +242,9 @@ describe(AssetService.name, () => { { assetId: 'asset-1', rating: 3, + lockedProperties: ['rating'], }, - { lockedPropertiesBehavior: 'update' }, + { lockedPropertiesBehavior: 'append' }, ); }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index bb82bd031f..282d74a9b1 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -33,6 +33,7 @@ import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; import { requireElevatedPermission } from 'src/utils/access'; import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; +import { updateLockedColumns } from 'src/utils/database'; @Injectable() export class AssetService extends BaseService { @@ -438,11 +439,11 @@ export class AssetService extends BaseService { const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); if (Object.keys(writes).length > 0) { await this.assetRepository.upsertExif( - { + updateLockedColumns({ assetId: id, ...writes, - }, - { lockedPropertiesBehavior: 'update' }, + }), + { lockedPropertiesBehavior: 'append' }, ); await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id } }); } diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index f8dbd5e78c..182308037c 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -19,6 +19,7 @@ import { columns, Exif, Person } from 'src/database'; import { AssetFileType, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { DB } from 'src/schema'; +import { lockableProperties } from 'src/schema/tables/asset-exif.table'; import { DatabaseConnectionParams, VectorExtension } from 'src/types'; type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object; @@ -488,3 +489,8 @@ export function vectorIndexQuery({ vectorExtension, table, indexName, lists }: V } } } + +export const updateLockedColumns = >(exif: T) => ({ + ...exif, + lockedProperties: lockableProperties.filter((property) => property in exif), +}); diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 1fd255ef75..82ea2cd1fc 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -202,7 +202,7 @@ export class MediumTestContext { } async newExif(dto: Insertable) { - const result = await this.get(AssetRepository).upsertExif(dto, { lockedPropertiesBehavior: 'none' }); + const result = await this.get(AssetRepository).upsertExif(dto, { lockedPropertiesBehavior: 'override' }); return { result }; } diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 13bc1ca9a9..8b54019fcf 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -268,4 +268,65 @@ describe(AssetService.name, () => { }); }); }); + + describe('update', () => { + it('should update dateTimeOriginal', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: 'test' }); + + await expect( + ctx.database + .selectFrom('asset_exif') + .select('lockedProperties') + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ lockedProperties: null }); + await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' }); + + await expect( + ctx.database + .selectFrom('asset_exif') + .select('lockedProperties') + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] }); + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00' }), + }), + ); + }); + }); + + describe('updateAll', () => { + it('should relatively update assets', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queueAll.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00' }); + + await sut.updateAll(auth, { ids: [asset.id], dateTimeRelative: -11 }); + + await expect( + ctx.database + .selectFrom('asset_exif') + .select('lockedProperties') + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ lockedProperties: ['timeZone', 'dateTimeOriginal'] }); + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ + dateTimeOriginal: '2023-11-19T18:00:00+00:00', + }), + }), + ); + }); + }); }); diff --git a/server/test/medium/specs/sync/sync-album-asset-exif.spec.ts b/server/test/medium/specs/sync/sync-album-asset-exif.spec.ts index 2b53e612e2..1865fc2c80 100644 --- a/server/test/medium/specs/sync/sync-album-asset-exif.spec.ts +++ b/server/test/medium/specs/sync/sync-album-asset-exif.spec.ts @@ -2,6 +2,7 @@ import { Kysely } from 'kysely'; import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum'; import { AssetRepository } from 'src/repositories/asset.repository'; import { DB } from 'src/schema'; +import { updateLockedColumns } from 'src/utils/database'; import { SyncTestContext } from 'test/medium.factory'; import { factory } from 'test/small.factory'; import { getKyselyDB, wait } from 'test/utils'; @@ -289,11 +290,11 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => { // update the asset const assetRepository = ctx.get(AssetRepository); await assetRepository.upsertExif( - { + updateLockedColumns({ assetId: asset.id, city: 'New City', - }, - { lockedPropertiesBehavior: 'update' }, + }), + { lockedPropertiesBehavior: 'append' }, ); await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([ @@ -350,11 +351,11 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => { // update the asset const assetRepository = ctx.get(AssetRepository); await assetRepository.upsertExif( - { + updateLockedColumns({ assetId: assetDelayedExif.id, city: 'Delayed Exif', - }, - { lockedPropertiesBehavior: 'update' }, + }), + { lockedPropertiesBehavior: 'append' }, ); await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([