refactor(server): use exiftool for file date metadata (#16453)

* use exiftool for file date metadata

* handle tag not existing in exifinfo (?)

* update medium tests

* fix typo

* set file size too

* set file size only if undefined
This commit is contained in:
Mert
2025-03-06 11:47:12 -05:00
committed by GitHub
parent d01b7a0d67
commit deb399ea15
4 changed files with 85 additions and 52 deletions

View File

@@ -1,6 +1,5 @@
import { BinaryField, ExifDateTime } from 'exiftool-vendored';
import { randomBytes } from 'node:crypto';
import { Stats } from 'node:fs';
import { constants } from 'node:fs/promises';
import { defaults } from 'src/config';
import { AssetEntity } from 'src/entities/asset.entity';
@@ -22,8 +21,14 @@ describe(MetadataService.name, () => {
let mocks: ServiceMocks;
const mockReadTags = (exifData?: Partial<ImmichTags>, sidecarData?: Partial<ImmichTags>) => {
exifData = {
FileSize: '123456',
FileCreateDate: '2024-01-01T00:00:00.000Z',
FileModifyDate: '2024-01-01T00:00:00.000Z',
...exifData,
};
mocks.metadata.readTags.mockReset();
mocks.metadata.readTags.mockResolvedValueOnce(exifData ?? {});
mocks.metadata.readTags.mockResolvedValueOnce(exifData);
mocks.metadata.readTags.mockResolvedValueOnce(sidecarData ?? {});
};
@@ -105,10 +110,6 @@ describe(MetadataService.name, () => {
});
describe('handleMetadataExtraction', () => {
beforeEach(() => {
mocks.storage.stat.mockResolvedValue({ size: 123_456 } as Stats);
});
it('should handle an asset that could not be found', async () => {
await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);
@@ -126,19 +127,24 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.sidecar.id], { faces: { person: false } });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate }));
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.image.id,
duration: null,
fileCreatedAt: sidecarDate,
localDateTime: sidecarDate,
});
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
id: assetStub.image.id,
duration: null,
fileCreatedAt: sidecarDate,
localDateTime: sidecarDate,
}),
);
});
it('should take the file modification date when missing exif and earliest than creation date', async () => {
it('should take the file modification date when missing exif and earlier than creation date', async () => {
const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z');
const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z');
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, fileCreatedAt, fileModifiedAt }]);
mockReadTags();
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
mockReadTags({
FileCreateDate: fileCreatedAt.toISOString(),
FileModifyDate: fileModifiedAt.toISOString(),
});
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } });
@@ -149,15 +155,19 @@ describe(MetadataService.name, () => {
id: assetStub.image.id,
duration: null,
fileCreatedAt: fileModifiedAt,
fileModifiedAt,
localDateTime: fileModifiedAt,
});
});
it('should take the file creation date when missing exif and earliest than modification date', async () => {
it('should take the file creation date when missing exif and earlier than modification date', async () => {
const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z');
const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z');
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, fileCreatedAt, fileModifiedAt }]);
mockReadTags();
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
mockReadTags({
FileCreateDate: fileCreatedAt.toISOString(),
FileModifyDate: fileModifiedAt.toISOString(),
});
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } });
@@ -166,6 +176,7 @@ describe(MetadataService.name, () => {
id: assetStub.image.id,
duration: null,
fileCreatedAt,
fileModifiedAt,
localDateTime: fileCreatedAt,
});
});
@@ -191,7 +202,11 @@ describe(MetadataService.name, () => {
it('should handle lists of numbers', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
mockReadTags({ ISO: [160] });
mockReadTags({
ISO: [160],
FileCreateDate: assetStub.image.fileCreatedAt.toISOString(),
FileModifyDate: assetStub.image.fileModifiedAt.toISOString(),
});
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } });
@@ -200,6 +215,7 @@ describe(MetadataService.name, () => {
id: assetStub.image.id,
duration: null,
fileCreatedAt: assetStub.image.fileCreatedAt,
fileModifiedAt: assetStub.image.fileCreatedAt,
localDateTime: assetStub.image.fileCreatedAt,
});
});
@@ -211,6 +227,8 @@ describe(MetadataService.name, () => {
mockReadTags({
GPSLatitude: assetStub.withLocation.exifInfo!.latitude!,
GPSLongitude: assetStub.withLocation.exifInfo!.longitude!,
FileCreateDate: assetStub.withLocation.fileCreatedAt.toISOString(),
FileModifyDate: assetStub.withLocation.fileModifiedAt.toISOString(),
});
await sut.handleMetadataExtraction({ id: assetStub.image.id });
@@ -221,7 +239,8 @@ describe(MetadataService.name, () => {
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.withLocation.id,
duration: null,
fileCreatedAt: assetStub.withLocation.createdAt,
fileCreatedAt: assetStub.withLocation.fileCreatedAt,
fileModifiedAt: assetStub.withLocation.fileModifiedAt,
localDateTime: new Date('2023-02-22T05:06:29.716Z'),
});
});
@@ -460,6 +479,8 @@ describe(MetadataService.name, () => {
// instead of the EmbeddedVideoFile, since HEIC MotionPhotos include both
EmbeddedVideoFile: new BinaryField(0, ''),
EmbeddedVideoType: 'MotionPhoto_Data',
FileCreateDate: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.toISOString(),
FileModifyDate: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.toISOString(),
});
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
@@ -506,6 +527,8 @@ describe(MetadataService.name, () => {
EmbeddedVideoFile: new BinaryField(0, ''),
EmbeddedVideoType: 'MotionPhoto_Data',
MotionPhoto: 1,
FileCreateDate: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.toISOString(),
FileModifyDate: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.toISOString(),
});
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
@@ -552,6 +575,8 @@ describe(MetadataService.name, () => {
MotionPhoto: 1,
MicroVideo: 1,
MicroVideoOffset: 1,
FileCreateDate: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.toISOString(),
FileModifyDate: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.toISOString(),
});
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
@@ -745,12 +770,14 @@ describe(MetadataService.name, () => {
state: null,
city: null,
});
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.image.id,
duration: null,
fileCreatedAt: dateForTest,
localDateTime: dateForTest,
});
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
id: assetStub.image.id,
duration: null,
fileCreatedAt: dateForTest,
localDateTime: dateForTest,
}),
);
});
it('should extract +00:00 timezone from raw value', async () => {