feat(server): parse offset from "Image_UTC_Data" (Samsung)

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 unix timestamp is
1726408800 (which is 2024-09-15T16:00 UTC), we know that the offset is -07:00.

Also see
0f63a78090/lib/Image/ExifTool/Samsung.pm (L996-L1001)
This commit is contained in:
Carsten Otto 2024-09-15 20:33:35 +02:00
parent b06ea687b4
commit 0396b8f445
2 changed files with 151 additions and 2 deletions

View File

@ -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({

View File

@ -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<keyof Tags> = [
'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);