diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 5df37a5ea7..3e9bec3d87 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -73,7 +73,7 @@ export class MetadataRepository { inferTimezoneFromDatestamps: true, inferTimezoneFromTimeStamp: true, useMWG: true, - numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength'], + numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength', 'FileSize'], /* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */ geoTz: (lat, lon) => geotz.find(lat, lon)[0], // Enable exiftool LFS to parse metadata for files larger than 2GB. diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 765c6bb936..2164715f84 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1,6 +1,5 @@ import { BinaryField, ExifDateTime } from 'exiftool-vendored'; import { randomBytes } from 'node:crypto'; -import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; import { defaults } from 'src/config'; import { AssetEntity } from 'src/entities/asset.entity'; @@ -22,8 +21,14 @@ describe(MetadataService.name, () => { let mocks: ServiceMocks; const mockReadTags = (exifData?: Partial, sidecarData?: Partial) => { + exifData = { + FileSize: '123456', + FileCreateDate: '2024-01-01T00:00:00.000Z', + FileModifyDate: '2024-01-01T00:00:00.000Z', + ...exifData, + }; mocks.metadata.readTags.mockReset(); - mocks.metadata.readTags.mockResolvedValueOnce(exifData ?? {}); + mocks.metadata.readTags.mockResolvedValueOnce(exifData); mocks.metadata.readTags.mockResolvedValueOnce(sidecarData ?? {}); }; @@ -105,10 +110,6 @@ describe(MetadataService.name, () => { }); describe('handleMetadataExtraction', () => { - beforeEach(() => { - mocks.storage.stat.mockResolvedValue({ size: 123_456 } as Stats); - }); - it('should handle an asset that could not be found', async () => { await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); @@ -126,19 +127,24 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.sidecar.id], { faces: { person: false } }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate })); - expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.image.id, - duration: null, - fileCreatedAt: sidecarDate, - localDateTime: sidecarDate, - }); + expect(mocks.asset.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: assetStub.image.id, + duration: null, + fileCreatedAt: sidecarDate, + localDateTime: sidecarDate, + }), + ); }); - it('should take the file modification date when missing exif and earliest than creation date', async () => { + 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, fileCreatedAt, fileModifiedAt }]); - mockReadTags(); + mocks.asset.getByIds.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 } }); @@ -149,15 +155,19 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt: fileModifiedAt, + fileModifiedAt, localDateTime: fileModifiedAt, }); }); - it('should take the file creation date when missing exif and earliest than modification date', async () => { + 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, fileCreatedAt, fileModifiedAt }]); - mockReadTags(); + mocks.asset.getByIds.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 } }); @@ -166,6 +176,7 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt, + fileModifiedAt, localDateTime: fileCreatedAt, }); }); @@ -191,7 +202,11 @@ describe(MetadataService.name, () => { it('should handle lists of numbers', async () => { mocks.asset.getByIds.mockResolvedValue([assetStub.image]); - mockReadTags({ ISO: [160] }); + mockReadTags({ + ISO: [160], + FileCreateDate: assetStub.image.fileCreatedAt.toISOString(), + FileModifyDate: assetStub.image.fileModifiedAt.toISOString(), + }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); @@ -200,6 +215,7 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt: assetStub.image.fileCreatedAt, + fileModifiedAt: assetStub.image.fileCreatedAt, localDateTime: assetStub.image.fileCreatedAt, }); }); @@ -211,6 +227,8 @@ describe(MetadataService.name, () => { mockReadTags({ GPSLatitude: assetStub.withLocation.exifInfo!.latitude!, GPSLongitude: assetStub.withLocation.exifInfo!.longitude!, + FileCreateDate: assetStub.withLocation.fileCreatedAt.toISOString(), + FileModifyDate: assetStub.withLocation.fileModifiedAt.toISOString(), }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -221,7 +239,8 @@ describe(MetadataService.name, () => { expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.withLocation.id, duration: null, - fileCreatedAt: assetStub.withLocation.createdAt, + fileCreatedAt: assetStub.withLocation.fileCreatedAt, + fileModifiedAt: assetStub.withLocation.fileModifiedAt, localDateTime: new Date('2023-02-22T05:06:29.716Z'), }); }); @@ -460,6 +479,8 @@ describe(MetadataService.name, () => { // instead of the EmbeddedVideoFile, since HEIC MotionPhotos include both EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoType: 'MotionPhoto_Data', + FileCreateDate: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.toISOString(), + FileModifyDate: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.toISOString(), }); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); @@ -506,6 +527,8 @@ describe(MetadataService.name, () => { EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoType: 'MotionPhoto_Data', MotionPhoto: 1, + FileCreateDate: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.toISOString(), + FileModifyDate: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.toISOString(), }); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); @@ -552,6 +575,8 @@ describe(MetadataService.name, () => { MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, + FileCreateDate: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.toISOString(), + FileModifyDate: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.toISOString(), }); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); @@ -745,12 +770,14 @@ describe(MetadataService.name, () => { state: null, city: null, }); - expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.image.id, - duration: null, - fileCreatedAt: dateForTest, - localDateTime: dateForTest, - }); + expect(mocks.asset.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: assetStub.image.id, + duration: null, + fileCreatedAt: dateForTest, + localDateTime: dateForTest, + }), + ); }); it('should extract +00:00 timezone from raw value', async () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 281c619f19..2f541409f4 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -171,21 +171,17 @@ export class MetadataService extends BaseService { return JobStatus.FAILED; } - const [stats, exifTags] = await Promise.all([ - this.storageRepository.stat(asset.originalPath), - this.getExifTags(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}`); + const stat = await this.storageRepository.stat(asset.originalPath); + exifTags.FileCreateDate = stat.ctime.toISOString(); + exifTags.FileModifyDate = stat.mtime.toISOString(); + exifTags.FileSize = stat.size.toString(); + } this.logger.verbose('Exif Tags', exifTags); - if (!asset.fileCreatedAt) { - asset.fileCreatedAt = stats.mtime; - } - - if (!asset.fileModifiedAt) { - asset.fileModifiedAt = stats.mtime; - } - const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags); const { width, height } = this.getImageDimensions(exifTags); @@ -216,7 +212,7 @@ export class MetadataService extends BaseService { city: geo.city, // image/file - fileSizeInByte: stats.size, + fileSizeInByte: Number.parseInt(exifTags.FileSize!), exifImageHeight: validate(height), exifImageWidth: validate(width), orientation: validate(exifTags.Orientation)?.toString() ?? null, @@ -251,13 +247,13 @@ export class MetadataService extends BaseService { duration: exifTags.Duration?.toString() ?? null, localDateTime, fileCreatedAt: exifData.dateTimeOriginal ?? undefined, - fileModifiedAt: stats.mtime, + fileModifiedAt: exifData.modifyDate ?? undefined, }), this.applyTagList(asset, exifTags), ]; if (this.isMotionPhoto(asset, exifTags)) { - promises.push(this.applyMotionPhotos(asset, exifTags)); + promises.push(this.applyMotionPhotos(asset, exifTags, exifData.fileSizeInByte!)); } if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) { @@ -436,7 +432,7 @@ export class MetadataService extends BaseService { return asset.type === AssetType.IMAGE && !!(tags.MotionPhoto || tags.MicroVideo); } - private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) { + private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags, fileSize: number) { const isMotionPhoto = tags.MotionPhoto; const isMicroVideo = tags.MicroVideo; const videoOffset = tags.MicroVideoOffset; @@ -470,8 +466,7 @@ export class MetadataService extends BaseService { this.logger.debug(`Starting motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`); try { - const stat = await this.storageRepository.stat(asset.originalPath); - const position = stat.size - length - padding; + const position = fileSize - length - padding; let video: Buffer; // Samsung MotionPhoto video extraction // HEIC-encoded @@ -659,10 +654,12 @@ export class MetadataService extends BaseService { this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`); } + const modifyDate = this.toDate(exifTags.FileModifyDate!); let dateTimeOriginal = dateTime?.toDate(); let localDateTime = dateTime?.toDateTime().setZone('UTC', { keepLocalTime: true }).toJSDate(); if (!localDateTime || !dateTimeOriginal) { - const earliestDate = this.earliestDate(asset.fileModifiedAt, asset.fileCreatedAt); + const fileCreatedAt = this.toDate(exifTags.FileCreateDate!); + const earliestDate = this.earliestDate(fileCreatedAt, modifyDate); this.logger.debug( `No exif date time found, falling back on ${earliestDate.toISOString()}, earliest of file creation and modification for assset ${asset.id}: ${asset.originalPath}`, ); @@ -674,11 +671,6 @@ export class MetadataService extends BaseService { `Found local date time ${localDateTime.toISOString()} for asset ${asset.id}: ${asset.originalPath}`, ); - let modifyDate = asset.fileModifiedAt; - try { - modifyDate = (exifTags.ModifyDate as ExifDateTime)?.toDate() ?? modifyDate; - } catch {} - return { dateTimeOriginal, timeZone, @@ -687,6 +679,10 @@ export class MetadataService extends BaseService { }; } + private toDate(date: string | ExifDateTime): Date { + return typeof date === 'string' ? new Date(date) : date.toDate(); + } + private earliestDate(a: Date, b: Date) { return new Date(Math.min(a.valueOf(), b.valueOf())); } diff --git a/server/test/medium/specs/metadata.service.spec.ts b/server/test/medium/specs/metadata.service.spec.ts index 22b9174ccd..aefb518c18 100644 --- a/server/test/medium/specs/metadata.service.spec.ts +++ b/server/test/medium/specs/metadata.service.spec.ts @@ -36,7 +36,7 @@ describe(MetadataService.name, () => { beforeEach(() => { ({ sut, mocks } = newTestService(MetadataService, { metadata: metadataRepository })); - mocks.storage.stat.mockResolvedValue({ size: 123_456 } as Stats); + mocks.storage.stat.mockResolvedValue({ size: 123_456, ctime: new Date(), mtime: new Date() } as Stats); delete process.env.TZ; }); @@ -51,6 +51,8 @@ describe(MetadataService.name, () => { description: 'should handle no time zone information', exifData: { DateTimeOriginal: '2022:01:01 00:00:00', + FileCreateDate: '2022:01:01 00:00:00', + FileModifyDate: '2022:01:01 00:00:00', }, expected: { localDateTime: '2022-01-01T00:00:00.000Z', @@ -63,6 +65,8 @@ describe(MetadataService.name, () => { serverTimeZone: 'America/Los_Angeles', exifData: { DateTimeOriginal: '2022:01:01 00:00:00', + FileCreateDate: '2022:01:01 00:00:00', + FileModifyDate: '2022:01:01 00:00:00', }, expected: { localDateTime: '2022-01-01T00:00:00.000Z', @@ -75,6 +79,8 @@ describe(MetadataService.name, () => { serverTimeZone: 'Europe/Brussels', exifData: { DateTimeOriginal: '2022:01:01 00:00:00', + FileCreateDate: '2022:01:01 00:00:00', + FileModifyDate: '2022:01:01 00:00:00', }, expected: { localDateTime: '2022-01-01T00:00:00.000Z', @@ -87,6 +93,8 @@ describe(MetadataService.name, () => { serverTimeZone: 'Europe/Brussels', exifData: { DateTimeOriginal: '2022:06:01 00:00:00', + FileCreateDate: '2022:06:01 00:00:00', + FileModifyDate: '2022:06:01 00:00:00', }, expected: { localDateTime: '2022-06-01T00:00:00.000Z', @@ -98,6 +106,8 @@ describe(MetadataService.name, () => { description: 'should handle a +13:00 time zone', exifData: { DateTimeOriginal: '2022:01:01 00:00:00+13:00', + FileCreateDate: '2022:01:01 00:00:00+13:00', + FileModifyDate: '2022:01:01 00:00:00+13:00', }, expected: { localDateTime: '2022-01-01T00:00:00.000Z',