mirror of
https://github.com/immich-app/immich.git
synced 2024-09-21 10:37:20 +00:00
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:
parent
b06ea687b4
commit
0396b8f445
@ -1,4 +1,5 @@
|
|||||||
import { BinaryField, ExifDateTime } from 'exiftool-vendored';
|
import { BinaryField, ExifDateTime } from 'exiftool-vendored';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { Stats } from 'node:fs';
|
import { Stats } from 'node:fs';
|
||||||
import { constants } from 'node:fs/promises';
|
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 () => {
|
it('should extract duration', async () => {
|
||||||
assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]);
|
assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]);
|
||||||
mediaMock.probe.mockResolvedValue({
|
mediaMock.probe.mockResolvedValue({
|
||||||
|
@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||||||
import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored';
|
import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored';
|
||||||
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
|
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { Duration } from 'luxon';
|
import { DateTime, Duration } from 'luxon';
|
||||||
import { constants } from 'node:fs/promises';
|
import { constants } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { SystemConfig } from 'src/config';
|
import { SystemConfig } from 'src/config';
|
||||||
@ -52,6 +52,7 @@ const EXIF_DATE_TAGS: Array<keyof Tags> = [
|
|||||||
'SubSecMediaCreateDate',
|
'SubSecMediaCreateDate',
|
||||||
'MediaCreateDate',
|
'MediaCreateDate',
|
||||||
'DateTimeCreated',
|
'DateTimeCreated',
|
||||||
|
'TimeStamp',
|
||||||
];
|
];
|
||||||
|
|
||||||
export enum Orientation {
|
export enum Orientation {
|
||||||
@ -615,6 +616,20 @@ export class MetadataService {
|
|||||||
timeZone = 'UTC+0';
|
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) {
|
if (timeZone) {
|
||||||
this.logger.debug(`Asset ${asset.id} timezone is ${timeZone} (via ${exifTags.tzSource})`);
|
this.logger.debug(`Asset ${asset.id} timezone is ${timeZone} (via ${exifTags.tzSource})`);
|
||||||
} else {
|
} else {
|
||||||
@ -622,7 +637,6 @@ export class MetadataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// offset minutes
|
// offset minutes
|
||||||
const offsetMinutes = dateTime?.tzoffsetMinutes || 0;
|
|
||||||
let localDateTime = dateTimeOriginal;
|
let localDateTime = dateTimeOriginal;
|
||||||
if (offsetMinutes) {
|
if (offsetMinutes) {
|
||||||
localDateTime = new Date(dateTimeOriginal.getTime() + offsetMinutes * 60_000);
|
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']) {
|
private async getGeo(tags: ImmichTags, reverseGeocoding: SystemConfig['reverseGeocoding']) {
|
||||||
let latitude = validate(tags.GPSLatitude);
|
let latitude = validate(tags.GPSLatitude);
|
||||||
let longitude = validate(tags.GPSLongitude);
|
let longitude = validate(tags.GPSLongitude);
|
||||||
|
Loading…
Reference in New Issue
Block a user