diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 19aaa2ea1a323..7747bf66916c5 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1,4 +1,5 @@ import { BinaryField, ExifDateTime } from 'exiftool-vendored'; +import { DateTime } from 'luxon'; import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; @@ -862,6 +863,99 @@ describe(MetadataService.name, () => { ); }); + it('should extract timezone offset from from Image_UTC_Data', async () => { + // A Samsung phone might provide the local time (e.g. 09:00) without any timezone or offset information. If the + // file also includes the non-standard trailer tag "TimeStamp" in "Image_UTC_Data", we can use the unix timestamp + // contained within to deduce the offset. + // + // As an example, if the local date/time is "2024-09-15T09:00" and the Image_UTC_Data Timestamp contains the + // unix timestamp is 1726408800 (which is 2024-09-15T16:00 UTC), we know that the offset is -07:00. + // + // Note that exiftool-vendored returns the ImageTag with the offset of the server's timezone. We are only + // interested in the underlying UTC value, though. As such, 2024-09-15T18:00[Europe/Berlin] is the same as + // 2024-09-15T16:00 UTC. + // + // Also see + // https://github.com/exiftool/exiftool/blob/0f63a780906abcccba796761fc2e66a0737e2f16/lib/Image/ExifTool/Samsung.pm#L996-L1001 + + const localDateWithoutTimezoneOrOffset = new ExifDateTime(2024, 9, 15, 9, 0, 0); + const sameDateWithTimezone = ExifDateTime.fromDateTime( + DateTime.fromISO('2024-09-15T18:00', { zone: 'Europe/Berlin' }), + ); + const tags: ImmichTags = { + DateTimeOriginal: localDateWithoutTimezoneOrOffset, + TimeStamp: sameDateWithTimezone, + tz: undefined, + }; + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue(tags); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + timeZone: 'UTC-7', + dateTimeOriginal: DateTime.fromISO('2024-09-15T09:00-07:00').toJSDate(), + }), + ); + expect(assetMock.update).toHaveBeenCalledWith( + expect.objectContaining({ + localDateTime: DateTime.fromISO('2024-09-15T09:00Z').toJSDate(), + }), + ); + }); + + it('should extract timezone offset from from Image_UTC_Data with 15min offset', async () => { + const localDateWithoutTimezoneOrOffset = new ExifDateTime(2024, 9, 15, 18, 15, 0); + const sameDateWithTimezone = ExifDateTime.fromDateTime(DateTime.fromISO('2024-09-15T16:00', { zone: 'utc' })); + const tags: ImmichTags = { + DateTimeOriginal: localDateWithoutTimezoneOrOffset, + TimeStamp: sameDateWithTimezone, + tz: undefined, + }; + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue(tags); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + timeZone: 'UTC+2:15', + dateTimeOriginal: DateTime.fromISO('2024-09-15T18:15+02:15').toJSDate(), + }), + ); + expect(assetMock.update).toHaveBeenCalledWith( + expect.objectContaining({ + localDateTime: DateTime.fromISO('2024-09-15T18:15Z').toJSDate(), + }), + ); + }); + + it('should ignore timezone offset with +2:16 offset', async () => { + const localDateWithoutTimezoneOrOffset = new ExifDateTime(2024, 9, 15, 18, 16, 0); + const sameDateWithTimezone = ExifDateTime.fromDateTime(DateTime.fromISO('2024-09-15T16:00', { zone: 'utc' })); + const tags: ImmichTags = { + DateTimeOriginal: localDateWithoutTimezoneOrOffset, + TimeStamp: sameDateWithTimezone, + tz: undefined, + }; + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue(tags); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + timeZone: null, + // note: no "Z", this uses the server's local time + dateTimeOriginal: DateTime.fromISO('2024-09-15T18:16').toJSDate(), + }), + ); + expect(assetMock.update).toHaveBeenCalledWith( + expect.objectContaining({ + // note: no "Z", this uses the server's local time + localDateTime: DateTime.fromISO('2024-09-15T18:16').toJSDate(), + }), + ); + }); + 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 eaa491c3ee7d8..4d0829f0d2c1f 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored'; import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime'; import _ from 'lodash'; -import { Duration } from 'luxon'; +import { DateTime, Duration } from 'luxon'; import { constants } from 'node:fs/promises'; import path from 'node:path'; import { SystemConfig } from 'src/config'; @@ -52,6 +52,7 @@ const EXIF_DATE_TAGS: Array = [ 'SubSecMediaCreateDate', 'MediaCreateDate', 'DateTimeCreated', + 'TimeStamp', ]; export enum Orientation { @@ -615,6 +616,20 @@ export class MetadataService { timeZone = 'UTC+0'; } + let offsetMinutes = dateTime?.tzoffsetMinutes || 0; + if (dateTime && timeZone == null) { + const { parsedTimezone, parsedOffsetMinutes } = this.parseSamsungTimeStamp(dateTime, exifTags); + if (parsedTimezone) { + timeZone = parsedTimezone; + dateTimeOriginal = ExifDateTime.fromMillis( + dateTime.toEpochSeconds() * 1000 - + parsedOffsetMinutes * 60 * 1000 - + dateTimeOriginal.getTimezoneOffset() * 60 * 1000, + ).toDate(); + offsetMinutes = parsedOffsetMinutes; + } + } + if (timeZone) { this.logger.debug(`Asset ${asset.id} timezone is ${timeZone} (via ${exifTags.tzSource})`); } else { @@ -622,7 +637,6 @@ export class MetadataService { } // offset minutes - const offsetMinutes = dateTime?.tzoffsetMinutes || 0; let localDateTime = dateTimeOriginal; if (offsetMinutes) { localDateTime = new Date(dateTimeOriginal.getTime() + offsetMinutes * 60_000); @@ -642,6 +656,47 @@ export class MetadataService { }; } + /** + * Samsung devices may add information about the (timezone) offset in a non-standard tag, while not mentioning this + * information anywhere else. We can extract this offset information, which works by comparing the local time (for + * which we do not know the offset) with the UTC time. + */ + private parseSamsungTimeStamp( + dateTime: ExifDateTime | undefined, + exifTags: ImmichTags, + ): { + parsedTimezone: string | null; + parsedOffsetMinutes: number; + } { + if (!dateTime || !exifTags.TimeStamp || !(exifTags.TimeStamp instanceof ExifDateTime)) { + return { parsedTimezone: null, parsedOffsetMinutes: 0 }; + } + + // we do not know the offset for the local time, just assume UTC for now + const localTimeAssumedUTC = DateTime.fromISO(dateTime.toISOString() + 'Z'); + const timeStamp = exifTags.TimeStamp as ExifDateTime; + + // timeStamp contains the local time in UTC: any difference between the two times is the offset we are looking for + const offsetSeconds = localTimeAssumedUTC.toUnixInteger() - timeStamp.toEpochSeconds(); + const offsetMinutes = Math.floor(offsetSeconds / 60); + const offsetJustHours = Math.floor(Math.abs(offsetSeconds) / 60 / 60); + const offsetJustMinutes = (Math.abs(offsetSeconds) / 60) % 60; + + // sanity check, offsets range from -12:00 to +14:00 with +13:45 and +05:45 as weird yet valid offsets + if (offsetSeconds < -12 * 60 * 60 || offsetSeconds > 14 * 60 * 60 || offsetJustMinutes % 15 != 0) { + this.logger.warn(`Unable to use Image_UTC_Data TimeStamp (${exifTags.TimeStamp}) to determine missing offset`); + return { parsedTimezone: null, parsedOffsetMinutes: 0 }; + } + + const sign = offsetSeconds >= 0 ? '+' : '-'; + const timezone = + offsetMinutes > 0 ? `UTC${sign}${offsetJustHours}:${offsetJustMinutes}` : `UTC${sign}${offsetJustHours}`; + this.logger.debug( + `Determined timezone offset ${timezone} (${offsetMinutes} minutes) based on Samsung Image_UTC_Data TimeStamp`, + ); + return { parsedTimezone: timezone, parsedOffsetMinutes: offsetMinutes }; + } + private async getGeo(tags: ImmichTags, reverseGeocoding: SystemConfig['reverseGeocoding']) { let latitude = validate(tags.GPSLatitude); let longitude = validate(tags.GPSLongitude);