diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 2fc95df00e..834fd16afc 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1,4 +1,4 @@ -import { BinaryField } from 'exiftool-vendored'; +import { BinaryField, ExifDateTime } from 'exiftool-vendored'; import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; @@ -746,6 +746,8 @@ describe(MetadataService.name, () => { }); it('should save all metadata', async () => { + const dateForTest = new Date('1970-01-01T00:00:00.000-11:30'); + const tags: ImmichTags = { BitsPerSample: 1, ComponentBitDepth: 1, @@ -753,7 +755,7 @@ describe(MetadataService.name, () => { BitDepth: 1, ColorBitDepth: 1, ColorSpace: '1', - DateTimeOriginal: new Date('1970-01-01').toISOString(), + DateTimeOriginal: ExifDateTime.fromISO(dateForTest.toISOString()), ExposureTime: '100ms', FocalLength: 20, ImageDescription: 'test description', @@ -762,11 +764,11 @@ describe(MetadataService.name, () => { MediaGroupUUID: 'livePhoto', Make: 'test-factory', Model: "'mockel'", - ModifyDate: new Date('1970-01-01').toISOString(), + ModifyDate: ExifDateTime.fromISO(dateForTest.toISOString()), Orientation: 0, ProfileDescription: 'extensive description', ProjectionType: 'equirectangular', - tz: '+02:00', + tz: 'UTC-11:30', Rating: 3, }; assetMock.getByIds.mockResolvedValue([assetStub.image]); @@ -779,7 +781,7 @@ describe(MetadataService.name, () => { bitsPerSample: expect.any(Number), autoStackId: null, colorspace: tags.ColorSpace, - dateTimeOriginal: new Date('1970-01-01'), + dateTimeOriginal: dateForTest, description: tags.ImageDescription, exifImageHeight: null, exifImageWidth: null, @@ -805,11 +807,37 @@ describe(MetadataService.name, () => { expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, - fileCreatedAt: new Date('1970-01-01'), - localDateTime: new Date('1970-01-01'), + fileCreatedAt: dateForTest, + localDateTime: dateForTest, }); }); + it('should extract +00:00 timezone from raw value', async () => { + // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly + // https://github.com/photostructure/exiftool-vendored.js/issues/203 + + // this only tests our assumptions of exiftool-vendored, demonstrating the issue + const someDate = '2024-09-01T00:00:00.000'; + expect(ExifDateTime.fromISO(someDate + 'Z')?.zone).toBe('UTC'); + expect(ExifDateTime.fromISO(someDate + '+00:00')?.zone).toBe('UTC'); // this is the issue, should be UTC+0 + expect(ExifDateTime.fromISO(someDate + '+04:00')?.zone).toBe('UTC+4'); + + const tags: ImmichTags = { + DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), + tz: undefined, + }; + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue(tags); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + timeZone: 'UTC+0', + }), + ); + }); + it('should extract duration', async () => { assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); mediaMock.probe.mockResolvedValue({ diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 29aebc4a36..7eab4702ad 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -531,12 +531,16 @@ export class MetadataService { this.logger.verbose('Exif Tags', exifTags); + const dateTimeOriginalWithRawValue = this.getDateTimeOriginalWithRawValue(exifTags); + const dateTimeOriginal = dateTimeOriginalWithRawValue.exifDate ?? asset.fileCreatedAt; + const timeZone = this.getTimeZone(exifTags, dateTimeOriginalWithRawValue.rawValue); + const exifData = { // altitude: tags.GPSAltitude ?? null, assetId: asset.id, bitsPerSample: this.getBitsPerSample(exifTags), colorspace: exifTags.ColorSpace ?? null, - dateTimeOriginal: this.getDateTimeOriginal(exifTags) ?? asset.fileCreatedAt, + dateTimeOriginal, description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), exifImageHeight: validate(exifTags.ImageHeight), exifImageWidth: validate(exifTags.ImageWidth), @@ -557,7 +561,7 @@ export class MetadataService { orientation: validate(exifTags.Orientation)?.toString() ?? null, profileDescription: exifTags.ProfileDescription || null, projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null, - timeZone: exifTags.tz ?? null, + timeZone, rating: exifTags.Rating ?? null, }; @@ -578,10 +582,25 @@ export class MetadataService { } private getDateTimeOriginal(tags: ImmichTags | Tags | null) { + return this.getDateTimeOriginalWithRawValue(tags).exifDate; + } + + private getDateTimeOriginalWithRawValue(tags: ImmichTags | Tags | null): { exifDate: Date | null; rawValue: string } { if (!tags) { - return null; + return { exifDate: null, rawValue: '' }; } - return exifDate(firstDateTime(tags as Tags, EXIF_DATE_TAGS)); + const first = firstDateTime(tags as Tags, EXIF_DATE_TAGS); + return { exifDate: exifDate(first), rawValue: first?.rawValue ?? '' }; + } + + private getTimeZone(exifTags: ImmichTags, rawValue: string) { + const timeZone = exifTags.tz ?? null; + if (timeZone == null && rawValue.endsWith('+00:00')) { + // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly + // https://github.com/photostructure/exiftool-vendored.js/issues/203 + return 'UTC+0'; + } + return timeZone; } private getBitsPerSample(tags: ImmichTags): number | null {