fix: utc time zone upserts (#26258)

fix: utc timezone upserts
This commit is contained in:
Daniel Dietzler
2026-02-17 18:59:52 +01:00
committed by GitHub
parent 3f41916ad7
commit 8f9ea6a171
3 changed files with 71 additions and 8 deletions

View File

@@ -919,7 +919,7 @@ describe(MetadataService.name, () => {
Orientation: 0, Orientation: 0,
ProfileDescription: 'extensive description', ProfileDescription: 'extensive description',
ProjectionType: 'equirectangular', ProjectionType: 'equirectangular',
tz: 'UTC-11:30', zone: 'UTC-11:30',
TagsList: ['parent/child'], TagsList: ['parent/child'],
Rating: 3, Rating: 3,
}; };
@@ -955,7 +955,7 @@ describe(MetadataService.name, () => {
orientation: tags.Orientation?.toString(), orientation: tags.Orientation?.toString(),
profileDescription: tags.ProfileDescription, profileDescription: tags.ProfileDescription,
projectionType: 'EQUIRECTANGULAR', projectionType: 'EQUIRECTANGULAR',
timeZone: tags.tz, timeZone: tags.zone,
rating: tags.Rating, rating: tags.Rating,
country: null, country: null,
state: null, state: null,
@@ -987,7 +987,7 @@ describe(MetadataService.name, () => {
const tags: ImmichTags = { const tags: ImmichTags = {
DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'),
tz: undefined, zone: undefined,
}; };
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mockReadTags(tags); mockReadTags(tags);

View File

@@ -527,6 +527,15 @@ export class MetadataService extends BaseService {
for (const tag of EXIF_DATE_TAGS) { for (const tag of EXIF_DATE_TAGS) {
delete mediaTags[tag]; delete mediaTags[tag];
} }
// exiftool-vendored derives tz information from the date.
// if the sidecar file has date information, we also assume the tz information come from there.
//
// this is especially important in the case of UTC+0 where exiftool-vendored does not return tz/zone fields
// and as such the tags aren't overwritten when returning all tags.
for (const tag of ['zone', 'tz', 'tzSource'] as const) {
delete mediaTags[tag];
}
} }
} }
@@ -897,8 +906,8 @@ export class MetadataService extends BaseService {
} }
// timezone // timezone
let timeZone = exifTags.tz ?? null; let timeZone = exifTags.zone ?? null;
if (timeZone == null && dateTime?.rawValue?.endsWith('+00:00')) { if (timeZone == null && (dateTime?.rawValue?.endsWith('Z') || dateTime?.rawValue?.endsWith('+00:00'))) {
// exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly
// https://github.com/photostructure/exiftool-vendored.js/issues/203 // https://github.com/photostructure/exiftool-vendored.js/issues/203
timeZone = 'UTC+0'; timeZone = 'UTC+0';
@@ -906,7 +915,7 @@ export class MetadataService extends BaseService {
if (timeZone) { if (timeZone) {
this.logger.verbose( this.logger.verbose(
`Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${asset.originalPath}`, `Found timezone ${timeZone} via ${exifTags.zoneSource} for asset ${asset.id}: ${asset.originalPath}`,
); );
} else { } else {
this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`); this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`);

View File

@@ -398,6 +398,23 @@ describe(AssetService.name, () => {
}), }),
); );
}); });
it('should update dateTimeOriginal with time zone UTC+0', async () => {
const { sut, ctx } = setup();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, description: 'test', timeZone: 'UTC-7' });
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000Z' });
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
expect.objectContaining({
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: 'UTC' }),
}),
);
});
}); });
describe('updateAll', () => { describe('updateAll', () => {
@@ -456,7 +473,7 @@ describe(AssetService.name, () => {
); );
}); });
it('should relatively update an assets with timezone', async () => { it('should relatively update assets with timezone', async () => {
const { sut, ctx } = setup(); const { sut, ctx } = setup();
ctx.getMock(JobRepository).queueAll.mockResolvedValue(); ctx.getMock(JobRepository).queueAll.mockResolvedValue();
const { user } = await ctx.newUser(); const { user } = await ctx.newUser();
@@ -477,7 +494,7 @@ describe(AssetService.name, () => {
); );
}); });
it('should relatively update an assets and set a timezone', async () => { it('should relatively update assets and set a timezone', async () => {
const { sut, ctx } = setup(); const { sut, ctx } = setup();
ctx.getMock(JobRepository).queueAll.mockResolvedValue(); ctx.getMock(JobRepository).queueAll.mockResolvedValue();
const { user } = await ctx.newUser(); const { user } = await ctx.newUser();
@@ -497,6 +514,26 @@ describe(AssetService.name, () => {
); );
}); });
it('should set asset time zones to UTC', async () => {
const { sut, ctx } = setup();
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00', timeZone: 'UTC-7' });
await sut.updateAll(auth, { ids: [asset.id], timeZone: 'UTC' });
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
expect.objectContaining({
exifInfo: expect.objectContaining({
dateTimeOriginal: '2023-11-19T18:11:00+00:00',
timeZone: 'UTC',
}),
}),
);
});
it('should update dateTimeOriginal', async () => { it('should update dateTimeOriginal', async () => {
const { sut, ctx } = setup(); const { sut, ctx } = setup();
ctx.getMock(JobRepository).queueAll.mockResolvedValue(); ctx.getMock(JobRepository).queueAll.mockResolvedValue();
@@ -530,6 +567,23 @@ describe(AssetService.name, () => {
}), }),
); );
}); });
it('should update dateTimeOriginal with UTC time zone', async () => {
const { sut, ctx } = setup();
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, description: 'test', timeZone: 'UTC-7' });
await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00.000Z' });
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
expect.objectContaining({
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: 'UTC' }),
}),
);
});
}); });
describe('upsertBulkMetadata', () => { describe('upsertBulkMetadata', () => {