mirror of
https://github.com/immich-app/immich.git
synced 2025-12-24 01:11:32 +03:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user