feat(server): check additional exif date tags (#19216)

* feat(server): check additional exif date tags

- Add support for UTC date tags (GPSDateTime, DateTimeUTC, GPSDateStamp, SonyDateTime2)
- This matches tags that exiftool-vendored uses for tzSource in extractTzOffsetFromUTCOffset()

* Review comments

* nit

* review comments

* lots of tests for exif datetime

* missed

* format

* format again

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis
2025-06-26 11:18:40 -04:00
committed by GitHub
parent a43159f4ba
commit 934649c8df
7 changed files with 1213 additions and 12 deletions

View File

@@ -6,7 +6,7 @@ import { defaults } from 'src/config';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetType, AssetVisibility, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
import { ImmichTags } from 'src/repositories/metadata.repository';
import { MetadataService } from 'src/services/metadata.service';
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { probeStub } from 'test/fixtures/media.stub';
@@ -1639,4 +1639,80 @@ describe(MetadataService.name, () => {
});
});
});
describe('firstDateTime', () => {
it('should ignore date-only tags like GPSDateStamp', () => {
const tags = {
GPSDateStamp: '2023:08:08', // Date-only tag, should be ignored
SonyDateTime2: '2023:07:07 07:00:00',
};
const result = firstDateTime(tags);
expect(result?.tag).toBe('SonyDateTime2');
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-07-07T07:00:00.000Z');
});
it('should respect full priority order with all date tags present', () => {
const tags = {
// SubSec and standard EXIF date tags
SubSecDateTimeOriginal: '2023:01:01 01:00:00',
SubSecCreateDate: '2023:02:02 02:00:00',
SubSecMediaCreateDate: '2023:03:03 03:00:00',
DateTimeOriginal: '2023:04:04 04:00:00',
CreateDate: '2023:05:05 05:00:00',
MediaCreateDate: '2023:06:06 06:00:00',
CreationDate: '2023:07:07 07:00:00',
DateTimeCreated: '2023:08:08 08:00:00',
// Additional date tags
TimeCreated: '2023:09:09 09:00:00',
GPSDateTime: '2023:10:10 10:00:00',
DateTimeUTC: '2023:11:11 11:00:00',
GPSDateStamp: '2023:12:12', // Date-only tag, should be ignored
SonyDateTime2: '2023:13:13 13:00:00',
// Non-standard tag
SourceImageCreateTime: '2023:14:14 14:00:00',
};
const result = firstDateTime(tags);
// Should use SubSecDateTimeOriginal as it has highest priority
expect(result?.tag).toBe('SubSecDateTimeOriginal');
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-01-01T01:00:00.000Z');
});
it('should handle missing SubSec tags and use available date tags', () => {
const tags = {
// Standard date tags
CreationDate: '2023:07:07 07:00:00',
DateTimeCreated: '2023:08:08 08:00:00',
// Additional date tags
TimeCreated: '2023:09:09 09:00:00',
GPSDateTime: '2023:10:10 10:00:00',
DateTimeUTC: '2023:11:11 11:00:00',
GPSDateStamp: '2023:12:12', // Date-only tag, should be ignored
SonyDateTime2: '2023:13:13 13:00:00',
};
const result = firstDateTime(tags);
// Should use CreationDate when available
expect(result?.tag).toBe('CreationDate');
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-07-07T07:00:00.000Z');
});
it('should handle invalid date formats gracefully', () => {
const tags = {
TimeCreated: 'invalid-date',
GPSDateTime: '2023:10:10 10:00:00',
DateTimeUTC: 'also-invalid',
SonyDateTime2: '2023:13:13 13:00:00',
};
const result = firstDateTime(tags);
// Should skip invalid dates and use the first valid one
expect(result?.tag).toBe('GPSDateTime');
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-10-10T10:00:00.000Z');
});
});
});

View File

@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ContainerDirectoryItem, Maybe, Tags } from 'exiftool-vendored';
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
import { ContainerDirectoryItem, ExifDateTime, Tags } from 'exiftool-vendored';
import { Insertable } from 'kysely';
import _ from 'lodash';
import { Duration } from 'luxon';
@@ -32,19 +31,47 @@ import { isFaceImportEnabled } from 'src/utils/misc';
import { upsertTags } from 'src/utils/tag';
/** look for a date from these tags (in order) */
const EXIF_DATE_TAGS: Array<keyof Tags> = [
const EXIF_DATE_TAGS: Array<keyof ImmichTags> = [
'SubSecDateTimeOriginal',
'DateTimeOriginal',
'SubSecCreateDate',
'CreationDate',
'CreateDate',
'SubSecMediaCreateDate',
'DateTimeOriginal',
'CreateDate',
'MediaCreateDate',
'CreationDate',
'DateTimeCreated',
'GPSDateTime',
'DateTimeUTC',
'SonyDateTime2',
// Undocumented, non-standard tag from insta360 in xmp.GPano namespace
'SourceImageCreateTime' as keyof Tags,
'SourceImageCreateTime' as keyof ImmichTags,
];
export function firstDateTime(tags: ImmichTags) {
for (const tag of EXIF_DATE_TAGS) {
const tagValue = tags?.[tag];
if (tagValue instanceof ExifDateTime) {
return {
tag,
dateTime: tagValue,
};
}
if (typeof tagValue !== 'string') {
continue;
}
const exifDateTime = ExifDateTime.fromEXIF(tagValue);
if (exifDateTime) {
return {
tag,
dateTime: exifDateTime,
};
}
}
}
const validate = <T>(value: T): NonNullable<T> | null => {
// handle lists of numbers
if (Array.isArray(value)) {
@@ -407,7 +434,8 @@ export class MetadataService extends BaseService {
// prefer dates from sidecar tags
if (sidecarTags) {
const sidecarDate = firstDateTime(sidecarTags as Tags, EXIF_DATE_TAGS);
const result = firstDateTime(sidecarTags);
const sidecarDate = result?.dateTime;
if (sidecarDate) {
for (const tag of EXIF_DATE_TAGS) {
delete mediaTags[tag];
@@ -748,8 +776,12 @@ export class MetadataService extends BaseService {
}
private getDates(asset: { id: string; originalPath: string }, exifTags: ImmichTags, stats: Stats) {
const dateTime = firstDateTime(exifTags as Maybe<Tags>, EXIF_DATE_TAGS);
this.logger.verbose(`Date and time is ${dateTime} for asset ${asset.id}: ${asset.originalPath}`);
const result = firstDateTime(exifTags);
const tag = result?.tag;
const dateTime = result?.dateTime;
this.logger.verbose(
`Date and time is ${dateTime} using exifTag ${tag} for asset ${asset.id}: ${asset.originalPath}`,
);
// timezone
let timeZone = exifTags.tz ?? null;