feat: exif medium tests (#23561)

This commit is contained in:
Jason Rasmussen
2025-11-04 16:03:02 -05:00
committed by GitHub
parent c34be73d81
commit 0df70365d7
11 changed files with 234 additions and 682 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 { defaults } from 'src/config';
@@ -231,7 +232,7 @@ describe(MetadataService.name, () => {
});
});
it('should account for the server being in a non-UTC timezone', async () => {
it('should determine dateTimeOriginal regardless of the server time zone', async () => {
process.env.TZ = 'America/Los_Angeles';
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar);
mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' });
@@ -239,7 +240,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({
dateTimeOriginal: new Date('2022-01-01T08:00:00.000Z'),
dateTimeOriginal: new Date('2022-01-01T00:00:00.000Z'),
}),
);
@@ -856,6 +857,7 @@ describe(MetadataService.name, () => {
tz: 'UTC-11:30',
Rating: 3,
};
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mockReadTags(tags);
@@ -897,7 +899,7 @@ describe(MetadataService.name, () => {
id: assetStub.image.id,
duration: null,
fileCreatedAt: dateForTest,
localDateTime: dateForTest,
localDateTime: DateTime.fromISO('1970-01-01T00:00:00.000Z').toJSDate(),
}),
);
});
@@ -1595,7 +1597,7 @@ describe(MetadataService.name, () => {
const result = firstDateTime(tags);
expect(result?.tag).toBe('SonyDateTime2');
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-07-07T07:00:00.000Z');
expect(result?.dateTime?.toISOString()).toBe('2023-07-07T07:00:00');
});
it('should respect full priority order with all date tags present', () => {
@@ -1624,7 +1626,7 @@ describe(MetadataService.name, () => {
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');
expect(result?.dateTime?.toISOString()).toBe('2023-01-01T01:00:00');
});
it('should handle missing SubSec tags and use available date tags', () => {
@@ -1644,7 +1646,7 @@ describe(MetadataService.name, () => {
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');
expect(result?.dateTime?.toISOString()).toBe('2023-07-07T07:00:00');
});
it('should handle invalid date formats gracefully', () => {
@@ -1658,7 +1660,7 @@ describe(MetadataService.name, () => {
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');
expect(result?.dateTime?.toISOString()).toBe('2023-10-10T10:00:00');
});
it('should prefer CreationDate over CreateDate', () => {

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { ContainerDirectoryItem, ExifDateTime, Tags } from 'exiftool-vendored';
import { Insertable } from 'kysely';
import _ from 'lodash';
import { Duration } from 'luxon';
import { DateTime, Duration } from 'luxon';
import { Stats } from 'node:fs';
import { constants } from 'node:fs/promises';
import { join, parse } from 'node:path';
@@ -866,31 +866,40 @@ export class MetadataService extends BaseService {
this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`);
}
let dateTimeOriginal = dateTime?.toDate();
let localDateTime = dateTime?.toDateTime().setZone('UTC', { keepLocalTime: true }).toJSDate();
let dateTimeOriginal = dateTime?.toDateTime();
// do not let JavaScript use local timezone
if (dateTimeOriginal && !dateTime?.hasZone) {
dateTimeOriginal = dateTimeOriginal.setZone('UTC', { keepLocalTime: true });
}
// align with whatever timeZone we chose
dateTimeOriginal = dateTimeOriginal?.setZone(timeZone ?? 'UTC');
// store as "local time"
let localDateTime = dateTimeOriginal?.setZone('UTC', { keepLocalTime: true });
if (!localDateTime || !dateTimeOriginal) {
// FileCreateDate is not available on linux, likely because exiftool hasn't integrated the statx syscall yet
// birthtime is not available in Docker on macOS, so it appears as 0
const earliestDate = new Date(
const earliestDate = DateTime.fromMillis(
Math.min(
asset.fileCreatedAt.getTime(),
stats.birthtimeMs ? Math.min(stats.mtimeMs, stats.birthtimeMs) : stats.mtime.getTime(),
),
);
this.logger.debug(
`No exif date time found, falling back on ${earliestDate.toISOString()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`,
`No exif date time found, falling back on ${earliestDate.toISO()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`,
);
dateTimeOriginal = localDateTime = earliestDate;
}
this.logger.verbose(
`Found local date time ${localDateTime.toISOString()} for asset ${asset.id}: ${asset.originalPath}`,
);
this.logger.verbose(`Found local date time ${localDateTime.toISO()} for asset ${asset.id}: ${asset.originalPath}`);
return {
dateTimeOriginal,
timeZone,
localDateTime,
localDateTime: localDateTime.toJSDate(),
dateTimeOriginal: dateTimeOriginal.toJSDate(),
};
}

View File

@@ -2,6 +2,7 @@
import { Insertable, Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { createHash, randomBytes } from 'node:crypto';
import { Stats } from 'node:fs';
import { Writable } from 'node:stream';
import { AssetFace } from 'src/database';
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
@@ -28,7 +29,9 @@ import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
import { MapRepository } from 'src/repositories/map.repository';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
@@ -61,7 +64,9 @@ import { TagAssetTable } from 'src/schema/tables/tag-asset.table';
import { TagTable } from 'src/schema/tables/tag.table';
import { UserTable } from 'src/schema/tables/user.table';
import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service';
import { MetadataService } from 'src/services/metadata.service';
import { SyncService } from 'src/services/sync.service';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory';
import { automock, wait } from 'test/utils';
@@ -305,6 +310,63 @@ export class SyncTestContext extends MediumTestContext<SyncService> {
}
}
const mockDate = new Date('2024-06-01T12:00:00.000Z');
const mockStats = {
mtime: mockDate,
atime: mockDate,
ctime: mockDate,
birthtime: mockDate,
atimeMs: 0,
mtimeMs: 0,
ctimeMs: 0,
birthtimeMs: 0,
};
export class ExifTestContext extends MediumTestContext<MetadataService> {
constructor(database: Kysely<DB>) {
super(MetadataService, {
database,
real: [AssetRepository, AssetJobRepository, MetadataRepository, SystemMetadataRepository, TagRepository],
mock: [ConfigRepository, EventRepository, LoggingRepository, MapRepository, StorageRepository],
});
this.getMock(ConfigRepository).getEnv.mockReturnValue(mockEnvData({}));
this.getMock(EventRepository).emit.mockResolvedValue();
this.getMock(MapRepository).reverseGeocode.mockResolvedValue({ country: null, state: null, city: null });
this.getMock(StorageRepository).stat.mockResolvedValue(mockStats as Stats);
}
getMockStats() {
return mockStats;
}
getGps(assetId: string) {
return this.database
.selectFrom('asset_exif')
.select(['latitude', 'longitude'])
.where('assetId', '=', assetId)
.executeTakeFirstOrThrow();
}
getTags(assetId: string) {
return this.database
.selectFrom('tag')
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagsId')
.where('tag_asset.assetsId', '=', assetId)
.selectAll()
.execute();
}
getDates(assetId: string) {
return this.database
.selectFrom('asset')
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.where('id', '=', assetId)
.select(['asset.fileCreatedAt', 'asset.localDateTime', 'asset_exif.dateTimeOriginal', 'asset_exif.timeZone'])
.executeTakeFirstOrThrow();
}
}
const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
switch (key) {
case AccessRepository:
@@ -344,6 +406,14 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
return new key(LoggingRepository.create());
}
case MetadataRepository: {
return new key(LoggingRepository.create());
}
case StorageRepository: {
return new key(LoggingRepository.create());
}
case TagRepository: {
return new key(db, LoggingRepository.create());
}
@@ -381,6 +451,10 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
return automock(key);
}
case MapRepository: {
return automock(MapRepository, { args: [undefined, undefined, { setContext: () => {} }] });
}
case TelemetryRepository: {
return newTelemetryRepositoryMock();
}

View File

@@ -0,0 +1,65 @@
import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { resolve } from 'node:path';
import { DB } from 'src/schema';
import { ExifTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>;
const setup = async (testAssetPath: string) => {
const ctx = new ExifTestContext(database);
const { user } = await ctx.newUser();
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
return { ctx, sut: ctx.sut, asset };
};
beforeAll(async () => {
database = await getKyselyDB();
});
describe('exif date time', () => {
it('should prioritize DateTimeOriginal', async () => {
const { ctx, sut, asset } = await setup('metadata/dates/date-priority-test.jpg');
await sut.handleMetadataExtraction({ id: asset.id });
await expect(ctx.getDates(asset.id)).resolves.toEqual({
timeZone: null,
dateTimeOriginal: DateTime.fromISO('2023-02-02T02:00:00.000Z').toJSDate(),
localDateTime: DateTime.fromISO('2023-02-02T02:00:00.000Z').toJSDate(),
fileCreatedAt: DateTime.fromISO('2023-02-02T02:00:00.000Z').toJSDate(),
});
});
it('should extract GPSDateTime with GPS coordinates ', async () => {
const { ctx, sut, asset } = await setup('metadata/dates/gps-datetime.jpg');
await sut.handleMetadataExtraction({ id: asset.id });
await expect(ctx.getDates(asset.id)).resolves.toEqual({
timeZone: 'America/Los_Angeles',
dateTimeOriginal: DateTime.fromISO('2023-11-15T12:30:00.000Z').toJSDate(),
localDateTime: DateTime.fromISO('2023-11-15T04:30:00.000Z').toJSDate(),
fileCreatedAt: DateTime.fromISO('2023-11-15T12:30:00.000Z').toJSDate(),
});
});
it('should ignore the TimeCreated tag', async () => {
const { ctx, sut, asset } = await setup('metadata/dates/time-created.jpg');
await sut.handleMetadataExtraction({ id: asset.id });
const stats = ctx.getMockStats();
await expect(ctx.getDates(asset.id)).resolves.toEqual({
timeZone: null,
dateTimeOriginal: stats.mtime,
localDateTime: stats.mtime,
fileCreatedAt: stats.mtime,
});
});
});

View File

@@ -0,0 +1,31 @@
import { Kysely } from 'kysely';
import { resolve } from 'node:path';
import { DB } from 'src/schema';
import { ExifTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>;
const setup = async (testAssetPath: string) => {
const ctx = new ExifTestContext(database);
const { user } = await ctx.newUser();
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
return { ctx, sut: ctx.sut, asset };
};
beforeAll(async () => {
database = await getKyselyDB();
});
describe('exif gps', () => {
it('should handle empty strings', async () => {
const { ctx, sut, asset } = await setup('metadata/gps-position/empty_gps.jpg');
await sut.handleMetadataExtraction({ id: asset.id });
await expect(ctx.getGps(asset.id)).resolves.toEqual({ latitude: null, longitude: null });
});
});

View File

@@ -0,0 +1,34 @@
import { Kysely } from 'kysely';
import { resolve } from 'node:path';
import { DB } from 'src/schema';
import { ExifTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>;
const setup = async (testAssetPath: string) => {
const ctx = new ExifTestContext(database);
const { user } = await ctx.newUser();
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
return { ctx, sut: ctx.sut, asset };
};
beforeAll(async () => {
database = await getKyselyDB();
});
describe('exif tags', () => {
it('should detect and regular tags', async () => {
const { ctx, sut, asset } = await setup('metadata/tags/picasa.jpg');
await sut.handleMetadataExtraction({ id: asset.id });
await expect(ctx.getTags(asset.id)).resolves.toEqual([
expect.objectContaining({ assetsId: asset.id, value: 'Frost', parentId: null }),
expect.objectContaining({ assetsId: asset.id, value: 'Yard', parentId: null }),
]);
});
});

View File

@@ -65,42 +65,6 @@ describe(MetadataService.name, () => {
timeZone: null,
},
},
{
description: 'should handle no time zone information and server behind UTC',
serverTimeZone: 'America/Los_Angeles',
exifData: {
DateTimeOriginal: '2022:01:01 00:00:00',
},
expected: {
localDateTime: '2022-01-01T00:00:00.000Z',
dateTimeOriginal: '2022-01-01T08:00:00.000Z',
timeZone: null,
},
},
{
description: 'should handle no time zone information and server ahead of UTC',
serverTimeZone: 'Europe/Brussels',
exifData: {
DateTimeOriginal: '2022:01:01 00:00:00',
},
expected: {
localDateTime: '2022-01-01T00:00:00.000Z',
dateTimeOriginal: '2021-12-31T23:00:00.000Z',
timeZone: null,
},
},
{
description: 'should handle no time zone information and server ahead of UTC in the summer',
serverTimeZone: 'Europe/Brussels',
exifData: {
DateTimeOriginal: '2022:06:01 00:00:00',
},
expected: {
localDateTime: '2022-06-01T00:00:00.000Z',
dateTimeOriginal: '2022-05-31T22:00:00.000Z',
timeZone: null,
},
},
{
description: 'should handle a +13:00 time zone',
exifData: {