From 75d1d21cc6c9ee5fae60f47646205e99591733a2 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler Date: Sun, 16 Mar 2025 13:17:55 +0100 Subject: [PATCH] fix: asset update race condition --- server/src/db.d.ts | 1 + server/src/entities/asset.entity.ts | 3 + .../1742127949957-AddAssetIsDirty.ts | 11 + server/src/services/asset.service.spec.ts | 6 +- server/src/services/asset.service.ts | 4 +- server/src/services/metadata.service.spec.ts | 204 +++++++++--------- server/src/services/metadata.service.ts | 27 ++- server/src/services/tag.service.ts | 3 + server/src/utils/asset.util.ts | 3 + server/test/fixtures/asset.stub.ts | 20 ++ server/test/fixtures/shared-link.stub.ts | 1 + .../medium/specs/metadata.service.spec.ts | 2 +- server/test/small.factory.ts | 1 + 13 files changed, 172 insertions(+), 114 deletions(-) create mode 100644 server/src/migrations/1742127949957-AddAssetIsDirty.ts diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 85aade2c9b..8b173f591c 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -143,6 +143,7 @@ export interface Assets { isFavorite: Generated; isOffline: Generated; isVisible: Generated; + isDirty: Generated; libraryId: string | null; livePhotoVideoId: string | null; localDateTime: Timestamp | null; diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index b2589e1231..daa68e1071 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -125,6 +125,9 @@ export class AssetEntity { @Column({ type: 'boolean', default: false }) isOffline!: boolean; + @Column({ type: 'boolean', default: false }) + isDirty!: boolean; + @Column({ type: 'bytea' }) @Index() checksum!: Buffer; // sha1 checksum diff --git a/server/src/migrations/1742127949957-AddAssetIsDirty.ts b/server/src/migrations/1742127949957-AddAssetIsDirty.ts new file mode 100644 index 0000000000..337ab7d3e6 --- /dev/null +++ b/server/src/migrations/1742127949957-AddAssetIsDirty.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAssetIsDirty1742127949957 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" ADD "isDirty" boolean NOT NULL DEFAULT false`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "isDirty"`); + } +} diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 5e7e2d79d0..b4c0125135 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -264,7 +264,7 @@ describe(AssetService.name, () => { await sut.update(authStub.admin, 'asset-1', { isFavorite: true }); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true, isDirty: true }); }); it('should update the exif description', async () => { @@ -371,6 +371,7 @@ describe(AssetService.name, () => { expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + isDirty: true, }); }); @@ -392,6 +393,7 @@ describe(AssetService.name, () => { expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: null, + isDirty: true, }); expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); expect(mocks.event.emit).toHaveBeenCalledWith('asset.show', { @@ -429,7 +431,7 @@ describe(AssetService.name, () => { await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); - expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); + expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true, isDirty: true }); }); it('should not update Assets table if no relevant fields are provided', async () => { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 56b7f7743c..a094f00b32 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -117,7 +117,7 @@ export class AssetService extends BaseService { await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); - const asset = await this.assetRepository.update({ id, ...rest }); + const asset = await this.assetRepository.update({ id, isDirty: true, ...rest }); if (previousMotion) { await onAfterUnlink(repos, { userId: auth.user.id, livePhotoVideoId: previousMotion.id }); @@ -144,7 +144,7 @@ export class AssetService extends BaseService { options.duplicateId != undefined || options.rating != undefined ) { - await this.assetRepository.updateAll(ids, options); + await this.assetRepository.updateAll(ids, { isDirty: true, ...options }); } } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 229b63f20e..118f9da74d 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -117,7 +117,7 @@ describe(MetadataService.name, () => { it('should handle an asset that could not be found', async () => { await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } }); expect(mocks.asset.upsertExif).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalled(); }); @@ -125,11 +125,11 @@ describe(MetadataService.name, () => { it('should handle a date in a sidecar file', async () => { const originalDate = new Date('2023-11-21T16:13:17.517Z'); const sidecarDate = new Date('2022-01-01T00:00:00.000Z'); - mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getById.mockResolvedValue(assetStub.sidecar); mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.sidecar.id], { faces: { person: false } }); + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.sidecar.id, { faces: { person: false } }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate })); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -144,14 +144,14 @@ describe(MetadataService.name, () => { it('should take the file modification date when missing exif and earlier than creation date', async () => { const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z'); - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ FileCreateDate: fileCreatedAt.toISOString(), FileModifyDate: fileModifiedAt.toISOString(), }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ dateTimeOriginal: fileModifiedAt }), ); @@ -167,14 +167,14 @@ describe(MetadataService.name, () => { it('should take the file creation date when missing exif and earlier than modification date', async () => { const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z'); - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ FileCreateDate: fileCreatedAt.toISOString(), FileModifyDate: fileModifiedAt.toISOString(), }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileCreatedAt })); expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, @@ -187,7 +187,7 @@ describe(MetadataService.name, () => { it('should account for the server being in a non-UTC timezone', async () => { process.env.TZ = 'America/Los_Angeles'; - mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getById.mockResolvedValue(assetStub.sidecar); mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -205,7 +205,7 @@ describe(MetadataService.name, () => { }); it('should handle lists of numbers', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ ISO: [160], FileCreateDate: assetStub.image.fileCreatedAt.toISOString(), @@ -213,7 +213,7 @@ describe(MetadataService.name, () => { }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 })); expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image.id, @@ -225,7 +225,7 @@ describe(MetadataService.name, () => { }); it('should apply reverse geocoding', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]); + mocks.asset.getById.mockResolvedValue(assetStub.withLocation); mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); mockReadTags({ @@ -236,7 +236,7 @@ describe(MetadataService.name, () => { }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }), ); @@ -250,19 +250,19 @@ describe(MetadataService.name, () => { }); it('should discard latitude and longitude on null island', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]); + mocks.asset.getById.mockResolvedValue(assetStub.withLocation); mockReadTags({ GPSLatitude: 0, GPSLongitude: 0, }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null })); }); it('should extract tags from TagsList', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ TagsList: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -272,7 +272,7 @@ describe(MetadataService.name, () => { }); it('should extract hierarchy from TagsList', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); @@ -292,7 +292,7 @@ describe(MetadataService.name, () => { }); it('should extract tags from Keywords as a string', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ Keywords: 'Parent' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -302,7 +302,7 @@ describe(MetadataService.name, () => { }); it('should extract tags from Keywords as a list', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ Keywords: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -312,7 +312,7 @@ describe(MetadataService.name, () => { }); it('should extract tags from Keywords as a list with a number', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ Keywords: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -323,7 +323,7 @@ describe(MetadataService.name, () => { }); it('should extract hierarchal tags from Keywords', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ Keywords: 'Parent/Child' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -342,7 +342,7 @@ describe(MetadataService.name, () => { }); it('should ignore Keywords when TagsList is present', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -361,7 +361,7 @@ describe(MetadataService.name, () => { }); it('should extract hierarchy from HierarchicalSubject', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); @@ -382,7 +382,7 @@ describe(MetadataService.name, () => { }); it('should extract tags from HierarchicalSubject as a list with a number', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -393,7 +393,7 @@ describe(MetadataService.name, () => { }); it('should extract ignore / characters in a HierarchicalSubject tag', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ HierarchicalSubject: ['Mom/Dad'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); @@ -407,7 +407,7 @@ describe(MetadataService.name, () => { }); it('should ignore HierarchicalSubject when TagsList is present', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -426,7 +426,7 @@ describe(MetadataService.name, () => { }); it('should remove existing tags', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({}); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -435,11 +435,11 @@ describe(MetadataService.name, () => { }); it('should not apply motion photos if asset is video', async () => { - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]); + mocks.asset.getById.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true }); mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id, { faces: { person: false }, }); expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled(); @@ -451,7 +451,7 @@ describe(MetadataService.name, () => { }); it('should handle an invalid Directory Item', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ MotionPhoto: 1, ContainerDirectory: [{ Foo: 100 }], @@ -461,20 +461,20 @@ describe(MetadataService.name, () => { }); it('should extract the correct video orientation', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getById.mockResolvedValue(assetStub.video); mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mockReadTags({}); await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.video.id, { faces: { person: false } }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }), ); }); it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => { - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); + mocks.asset.getById.mockResolvedValue({ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -497,7 +497,7 @@ describe(MetadataService.name, () => { assetStub.livePhotoWithOriginalFileName.originalPath, 'MotionPhotoVideo', ); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id, { faces: { person: false }, }); expect(mocks.asset.create).toHaveBeenCalledWith({ @@ -525,7 +525,7 @@ describe(MetadataService.name, () => { }); it('should extract the EmbeddedVideo tag from Samsung JPEG motion photos', async () => { - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); + mocks.asset.getById.mockResolvedValue({ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }); mockReadTags({ Directory: 'foo/bar/', EmbeddedVideoFile: new BinaryField(0, ''), @@ -545,7 +545,7 @@ describe(MetadataService.name, () => { assetStub.livePhotoWithOriginalFileName.originalPath, 'EmbeddedVideoFile', ); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id, { faces: { person: false }, }); expect(mocks.asset.create).toHaveBeenCalledWith({ @@ -573,7 +573,7 @@ describe(MetadataService.name, () => { }); it('should extract the motion photo video from the XMP directory entry ', async () => { - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); + mocks.asset.getById.mockResolvedValue({ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -589,7 +589,7 @@ describe(MetadataService.name, () => { mocks.storage.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id, { faces: { person: false }, }); expect(mocks.storage.readFile).toHaveBeenCalledWith( @@ -621,7 +621,7 @@ describe(MetadataService.name, () => { }); it('should delete old motion photo video assets if they do not match what is extracted', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoWithOriginalFileName]); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoWithOriginalFileName); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -647,7 +647,7 @@ describe(MetadataService.name, () => { }); it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -669,7 +669,7 @@ describe(MetadataService.name, () => { }); it('should link and hide motion video asset to still asset if the hash of the extracted video matches an existing asset', async () => { - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]); + mocks.asset.getById.mockResolvedValue({ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -694,9 +694,11 @@ describe(MetadataService.name, () => { }); it('should not update storage usage if motion photo is external', async () => { - mocks.asset.getByIds.mockResolvedValue([ - { ...assetStub.livePhotoStillAsset, livePhotoVideoId: null, isExternal: true }, - ]); + mocks.asset.getById.mockResolvedValue({ + ...assetStub.livePhotoStillAsset, + livePhotoVideoId: null, + isExternal: true, + }); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -738,11 +740,11 @@ describe(MetadataService.name, () => { tz: 'UTC-11:30', Rating: 3, }; - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags(tags); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: assetStub.image.id, bitsPerSample: expect.any(Number), @@ -798,11 +800,11 @@ describe(MetadataService.name, () => { DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), tz: undefined, }; - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags(tags); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ timeZone: 'UTC+0', @@ -811,7 +813,7 @@ describe(MetadataService.name, () => { }); it('should extract duration', async () => { - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mocks.asset.getById.mockResolvedValue({ ...assetStub.video }); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -822,7 +824,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.video.id, { faces: { person: false } }); expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -833,7 +835,7 @@ describe(MetadataService.name, () => { }); it('should only extract duration for videos', async () => { - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image }]); + mocks.asset.getById.mockResolvedValue({ ...assetStub.image }); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -843,7 +845,7 @@ describe(MetadataService.name, () => { }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } }); expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -854,7 +856,7 @@ describe(MetadataService.name, () => { }); it('should omit duration of zero', async () => { - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mocks.asset.getById.mockResolvedValue({ ...assetStub.video }); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -865,7 +867,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.video.id, { faces: { person: false } }); expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -876,7 +878,7 @@ describe(MetadataService.name, () => { }); it('should a handle duration of 1 week', async () => { - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mocks.asset.getById.mockResolvedValue({ ...assetStub.video }); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -887,7 +889,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.video.id, { faces: { person: false } }); expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -898,7 +900,7 @@ describe(MetadataService.name, () => { }); it('should ignore duration from exif data', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({}, { Duration: { Value: 123 } }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -906,7 +908,7 @@ describe(MetadataService.name, () => { }); it('should trim whitespace from description', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ Description: '\t \v \f \n \r' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -926,7 +928,7 @@ describe(MetadataService.name, () => { }); it('should handle a numeric description', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ Description: 1000 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -938,7 +940,7 @@ describe(MetadataService.name, () => { }); it('should skip importing metadata when the feature is disabled', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } }); mockReadTags(metadataStub.withFace); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -946,7 +948,7 @@ describe(MetadataService.name, () => { }); it('should skip importing metadata face for assets without tags.RegionInfo', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.empty); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -954,7 +956,7 @@ describe(MetadataService.name, () => { }); it('should skip importing faces without name', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.withFaceNoName); mocks.person.getDistinctNames.mockResolvedValue([]); @@ -966,7 +968,7 @@ describe(MetadataService.name, () => { }); it('should skip importing faces with empty name', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.withFaceEmptyName); mocks.person.getDistinctNames.mockResolvedValue([]); @@ -978,14 +980,14 @@ describe(MetadataService.name, () => { }); it('should apply metadata face tags creating new persons', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.withFace); mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([personStub.withName.id]); mocks.person.update.mockResolvedValue(personStub.withName); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.primaryImage.id, { faces: { person: false } }); expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); expect(mocks.person.createAll).toHaveBeenCalledWith([ expect.objectContaining({ name: personStub.withName.name }), @@ -1019,14 +1021,14 @@ describe(MetadataService.name, () => { }); it('should assign metadata face tags to existing persons', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); + mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(metadataStub.withFace); mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); mocks.person.createAll.mockResolvedValue([]); mocks.person.update.mockResolvedValue(personStub.withName); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.primaryImage.id, { faces: { person: false } }); expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); expect(mocks.person.createAll).not.toHaveBeenCalled(); expect(mocks.person.refreshFaces).toHaveBeenCalledWith( @@ -1051,7 +1053,7 @@ describe(MetadataService.name, () => { }); it('should handle invalid modify date', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ ModifyDate: '00:00:00.000' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -1063,7 +1065,7 @@ describe(MetadataService.name, () => { }); it('should handle invalid rating value', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ Rating: 6 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -1075,7 +1077,7 @@ describe(MetadataService.name, () => { }); it('should handle valid rating value', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ Rating: 5 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -1087,7 +1089,7 @@ describe(MetadataService.name, () => { }); it('should handle valid negative rating value', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags({ Rating: -1 }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -1099,11 +1101,11 @@ describe(MetadataService.name, () => { }); it('should handle livePhotoCID not set', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.image.id, { faces: { person: false } }); expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith(expect.objectContaining({ isVisible: false })); expect(mocks.album.removeAsset).not.toHaveBeenCalled(); @@ -1111,14 +1113,14 @@ describe(MetadataService.name, () => { it('should handle not finding a match', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); - mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); mockReadTags({ ContentIdentifier: 'CID' }); await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe( JobStatus.SUCCESS, ); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id, { faces: { person: false }, }); expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ @@ -1132,7 +1134,7 @@ describe(MetadataService.name, () => { }); it('should link photo and video', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); mockReadTags({ ContentIdentifier: 'CID' }); @@ -1140,7 +1142,7 @@ describe(MetadataService.name, () => { JobStatus.SUCCESS, ); - expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { + expect(mocks.asset.getById).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.id, { faces: { person: false }, }); expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ @@ -1158,12 +1160,10 @@ describe(MetadataService.name, () => { }); it('should notify clients on live photo link', async () => { - mocks.asset.getByIds.mockResolvedValue([ - { - ...assetStub.livePhotoStillAsset, - exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity, - }, - ]); + mocks.asset.getById.mockResolvedValue({ + ...assetStub.livePhotoStillAsset, + exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity, + }); mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); mockReadTags({ ContentIdentifier: 'CID' }); @@ -1178,12 +1178,10 @@ describe(MetadataService.name, () => { }); it('should search by libraryId', async () => { - mocks.asset.getByIds.mockResolvedValue([ - { - ...assetStub.livePhotoStillAsset, - libraryId: 'library-id', - }, - ]); + mocks.asset.getById.mockResolvedValue({ + ...assetStub.livePhotoStillAsset, + libraryId: 'library-id', + }); mockReadTags({ ContentIdentifier: 'CID' }); await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( @@ -1204,7 +1202,7 @@ describe(MetadataService.name, () => { { Device: { Manufacturer: '1', ModelName: '2' }, AndroidMake: '3', AndroidModel: '4' }, { AndroidMake: '1', AndroidModel: '2' }, ])('should read camera make and model correct place %s', async (metaData) => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mockReadTags(metaData); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -1251,19 +1249,19 @@ describe(MetadataService.name, () => { describe('handleSidecarSync', () => { it('should do nothing if asset could not be found', async () => { - mocks.asset.getByIds.mockResolvedValue([]); + mocks.asset.getById.mockResolvedValue(void 0); await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should do nothing if asset has no sidecar path', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should set sidecar path if exists (sidecar named photo.ext.xmp)', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getById.mockResolvedValue(assetStub.sidecar); mocks.storage.checkFileExists.mockResolvedValue(true); await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); @@ -1278,7 +1276,7 @@ describe(MetadataService.name, () => { }); it('should set sidecar path if exists (sidecar named photo.xmp)', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.sidecarWithoutExt]); + mocks.asset.getById.mockResolvedValue(assetStub.sidecarWithoutExt); mocks.storage.checkFileExists.mockResolvedValueOnce(false); mocks.storage.checkFileExists.mockResolvedValueOnce(true); @@ -1295,7 +1293,7 @@ describe(MetadataService.name, () => { }); it('should set sidecar path if exists (two sidecars named photo.ext.xmp and photo.xmp, should pick photo.ext.xmp)', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getById.mockResolvedValue(assetStub.sidecar); mocks.storage.checkFileExists.mockResolvedValueOnce(true); mocks.storage.checkFileExists.mockResolvedValueOnce(true); @@ -1313,7 +1311,7 @@ describe(MetadataService.name, () => { }); it('should unset sidecar path if file does not exist anymore', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getById.mockResolvedValue(assetStub.sidecar); mocks.storage.checkFileExists.mockResolvedValue(false); await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); @@ -1330,26 +1328,26 @@ describe(MetadataService.name, () => { describe('handleSidecarDiscovery', () => { it('should skip hidden assets', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); await sut.handleSidecarDiscovery({ id: assetStub.livePhotoMotionAsset.id }); expect(mocks.storage.checkFileExists).not.toHaveBeenCalled(); }); it('should skip assets with a sidecar path', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getById.mockResolvedValue(assetStub.sidecar); await sut.handleSidecarDiscovery({ id: assetStub.sidecar.id }); expect(mocks.storage.checkFileExists).not.toHaveBeenCalled(); }); it('should do nothing when a sidecar is not found ', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mocks.storage.checkFileExists.mockResolvedValue(false); await sut.handleSidecarDiscovery({ id: assetStub.image.id }); expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should update a image asset when a sidecar is found', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getById.mockResolvedValue(assetStub.image); mocks.storage.checkFileExists.mockResolvedValue(true); await sut.handleSidecarDiscovery({ id: assetStub.image.id }); expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK); @@ -1360,7 +1358,7 @@ describe(MetadataService.name, () => { }); it('should update a video asset when a sidecar is found', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.video]); + mocks.asset.getById.mockResolvedValue(assetStub.video); mocks.storage.checkFileExists.mockResolvedValue(true); await sut.handleSidecarDiscovery({ id: assetStub.video.id }); expect(mocks.storage.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK); @@ -1373,13 +1371,13 @@ describe(MetadataService.name, () => { describe('handleSidecarWrite', () => { it('should skip assets that do not exist anymore', async () => { - mocks.asset.getByIds.mockResolvedValue([]); + mocks.asset.getById.mockResolvedValue(void 0); await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.FAILED); expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); }); it('should skip jobs with not metadata', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getById.mockResolvedValue(assetStub.sidecar); await expect(sut.handleSidecarWrite({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SKIPPED); expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); }); @@ -1389,7 +1387,7 @@ describe(MetadataService.name, () => { const gps = 12; const date = '2023-11-22T04:56:12.196Z'; - mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getById.mockResolvedValue(assetStub.sidecar); await expect( sut.handleSidecarWrite({ id: assetStub.sidecar.id, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 4bf58a57fa..fdfcc8f0a5 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -29,6 +29,7 @@ import { ReverseGeocodeResult } from 'src/repositories/map.repository'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { BaseService } from 'src/services/base.service'; import { JobOf } from 'src/types'; +import { getSidecarPath } from 'src/utils/asset.util'; import { isFaceImportEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; import { upsertTags } from 'src/utils/tag'; @@ -162,15 +163,29 @@ export class MetadataService extends BaseService { @OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION }) async handleMetadataExtraction(data: JobOf): Promise { - const [{ metadata, reverseGeocoding }, [asset]] = await Promise.all([ + const [{ metadata, reverseGeocoding }, asset] = await Promise.all([ this.getConfig({ withCache: true }), - this.assetRepository.getByIds([data.id], { faces: { person: false } }), + this.assetRepository.getById(data.id, { faces: { person: false } }), ]); if (!asset) { return JobStatus.FAILED; } + if (asset.isDirty) { + const { exifInfo } = (await this.assetRepository.getById(asset.id, { exifInfo: true })) || {}; + await this.handleSidecarWrite({ + id: asset.id, + description: exifInfo?.description, + dateTimeOriginal: exifInfo?.dateTimeOriginal?.toISOString(), + latitude: exifInfo?.latitude ?? undefined, + longitude: exifInfo?.longitude ?? undefined, + rating: exifInfo?.rating ?? undefined, + tags: true, + }); + asset.sidecarPath = asset.sidecarPath || getSidecarPath(asset); + } + const exifTags = await this.getExifTags(asset); if (!exifTags.FileCreateDate || !exifTags.FileModifyDate || exifTags.FileSize === undefined) { this.logger.warn(`Missing file creation or modification date for asset ${asset.id}: ${asset.originalPath}`); @@ -314,14 +329,14 @@ export class MetadataService extends BaseService { @OnJob({ name: JobName.SIDECAR_WRITE, queue: QueueName.SIDECAR }) async handleSidecarWrite(job: JobOf): Promise { const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job; - const [asset] = await this.assetRepository.getByIds([id], { tags: true }); + const asset = await this.assetRepository.getById(id, { tags: true }); if (!asset) { return JobStatus.FAILED; } const tagsList = (asset.tags || []).map((tag) => tag.value); - const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`; + const sidecarPath = asset.sidecarPath || getSidecarPath(asset); const exif = _.omitBy( { Description: description, @@ -342,7 +357,7 @@ export class MetadataService extends BaseService { await this.metadataRepository.writeTags(sidecarPath, exif); if (!asset.sidecarPath) { - await this.assetRepository.update({ id, sidecarPath }); + await this.assetRepository.update({ id, sidecarPath, isDirty: false }); } return JobStatus.SUCCESS; @@ -754,7 +769,7 @@ export class MetadataService extends BaseService { } private async processSidecar(id: string, isSync: boolean): Promise { - const [asset] = await this.assetRepository.getByIds([id]); + const asset = await this.assetRepository.getById(id); if (!asset) { return JobStatus.FAILED; diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index ecf4d6e9fb..773bf861e4 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -90,6 +90,7 @@ export class TagService extends BaseService { const results = await this.tagRepository.upsertAssetIds(items); for (const assetId of new Set(results.map((item) => item.assetsId))) { + await this.assetRepository.update({ id: assetId, isDirty: true }); await this.eventRepository.emit('asset.tag', { assetId }); } @@ -107,6 +108,7 @@ export class TagService extends BaseService { for (const { id: assetId, success } of results) { if (success) { + await this.assetRepository.update({ id: assetId, isDirty: true }); await this.eventRepository.emit('asset.tag', { assetId }); } } @@ -125,6 +127,7 @@ export class TagService extends BaseService { for (const { id: assetId, success } of results) { if (success) { + await this.assetRepository.update({ id: assetId, isDirty: true }); await this.eventRepository.emit('asset.untag', { assetId }); } } diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index de64720a82..6bce33bb16 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -4,6 +4,7 @@ import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.respons import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; import { AssetFileType, AssetType, Permission } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AccessRepository } from 'src/repositories/access.repository'; @@ -17,6 +18,8 @@ const getFileByType = (files: AssetFileEntity[] | undefined, type: AssetFileType return (files || []).find((file) => file.type === type); }; +export const getSidecarPath = (asset: AssetEntity) => `${asset.originalPath}.xmp`; + export const getAssetFiles = (files?: AssetFileEntity[]) => ({ previewFile: getFileByType(files, AssetFileType.PREVIEW), thumbnailFile: getFileByType(files, AssetFileType.THUMBNAIL), diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index c0902dddb3..8bfab93a64 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -88,6 +88,7 @@ export const assetStub = { isExternal: false, duplicateId: null, isOffline: false, + isDirty: false, }), noWebpPath: Object.freeze({ @@ -126,6 +127,7 @@ export const assetStub = { deletedAt: null, duplicateId: null, isOffline: false, + isDirty: false, }), noThumbhash: Object.freeze({ @@ -161,6 +163,7 @@ export const assetStub = { deletedAt: null, duplicateId: null, isOffline: false, + isDirty: false, }), primaryImage: Object.freeze({ @@ -207,6 +210,7 @@ export const assetStub = { ]), duplicateId: null, isOffline: false, + isDirty: false, }), image: Object.freeze({ @@ -247,6 +251,7 @@ export const assetStub = { } as ExifEntity, duplicateId: null, isOffline: false, + isDirty: false, }), trashed: Object.freeze({ @@ -287,6 +292,7 @@ export const assetStub = { duplicateId: null, isOffline: false, status: AssetStatus.TRASHED, + isDirty: false, }), trashedOffline: Object.freeze({ @@ -328,6 +334,7 @@ export const assetStub = { } as ExifEntity, duplicateId: null, isOffline: true, + isDirty: false, }), archived: Object.freeze({ id: 'asset-id', @@ -367,6 +374,7 @@ export const assetStub = { } as ExifEntity, duplicateId: null, isOffline: false, + isDirty: false, }), external: Object.freeze({ @@ -406,6 +414,7 @@ export const assetStub = { } as ExifEntity, duplicateId: null, isOffline: false, + isDirty: false, }), image1: Object.freeze({ @@ -444,6 +453,7 @@ export const assetStub = { } as ExifEntity, duplicateId: null, isOffline: false, + isDirty: false, }), imageFrom2015: Object.freeze({ @@ -482,6 +492,7 @@ export const assetStub = { deletedAt: null, duplicateId: null, isOffline: false, + isDirty: false, }), video: Object.freeze({ @@ -522,6 +533,7 @@ export const assetStub = { deletedAt: null, duplicateId: null, isOffline: false, + isDirty: false, }), livePhotoMotionAsset: Object.freeze({ @@ -613,6 +625,7 @@ export const assetStub = { deletedAt: null, duplicateId: null, isOffline: false, + isDirty: false, }), sidecar: Object.freeze({ @@ -648,6 +661,7 @@ export const assetStub = { deletedAt: null, duplicateId: null, isOffline: false, + isDirty: false, }), sidecarWithoutExt: Object.freeze({ @@ -683,6 +697,7 @@ export const assetStub = { deletedAt: null, duplicateId: null, isOffline: false, + isDirty: false, }), hasEncodedVideo: Object.freeze({ @@ -721,6 +736,7 @@ export const assetStub = { deletedAt: null, duplicateId: null, isOffline: false, + isDirty: false, }), hasFileExtension: Object.freeze({ @@ -760,6 +776,7 @@ export const assetStub = { } as ExifEntity, duplicateId: null, isOffline: false, + isDirty: false, }), imageDng: Object.freeze({ @@ -800,6 +817,7 @@ export const assetStub = { } as ExifEntity, duplicateId: null, isOffline: false, + isDirty: false, }), hasEmbedding: Object.freeze({ @@ -842,6 +860,7 @@ export const assetStub = { embedding: '[1, 2, 3, 4]', }, isOffline: false, + isDirty: false, }), hasDupe: Object.freeze({ @@ -884,5 +903,6 @@ export const assetStub = { embedding: '[1, 2, 3, 4]', }, isOffline: false, + isDirty: false, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 6ee31c0dea..15f26e19db 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -247,6 +247,7 @@ export const sharedLinkStub = { sidecarPath: null, deletedAt: null, duplicateId: null, + isDirty: false, }, ], }, diff --git a/server/test/medium/specs/metadata.service.spec.ts b/server/test/medium/specs/metadata.service.spec.ts index 28f2c9f64f..e336f56250 100644 --- a/server/test/medium/specs/metadata.service.spec.ts +++ b/server/test/medium/specs/metadata.service.spec.ts @@ -123,7 +123,7 @@ describe(MetadataService.name, () => { process.env.TZ = serverTimeZone ?? undefined; const { filePath } = await createTestFile(exifData); - mocks.asset.getByIds.mockResolvedValue([{ id: 'asset-1', originalPath: filePath } as AssetEntity]); + mocks.asset.getById.mockResolvedValue({ id: 'asset-1', originalPath: filePath } as AssetEntity); await sut.handleMetadataExtraction({ id: 'asset-1' }); diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 1faedf311a..5f7189709b 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -105,6 +105,7 @@ const assetFactory = (asset: Partial = {}) => ({ isFavorite: false, isOffline: false, isVisible: true, + isDirty: false, libraryId: null, livePhotoVideoId: null, localDateTime: newDate(),