diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 229b63f20e..a0d1cdb4b4 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1,5 +1,6 @@ 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'; @@ -21,14 +22,8 @@ describe(MetadataService.name, () => { let mocks: ServiceMocks; const mockReadTags = (exifData?: Partial, sidecarData?: Partial) => { - 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 ?? {}); }; @@ -114,6 +109,17 @@ describe(MetadataService.name, () => { }); describe('handleMetadataExtraction', () => { + beforeEach(() => { + const time = new Date('2022-01-01T00:00:00.000Z'); + const timeMs = time.valueOf(); + mocks.storage.stat.mockResolvedValue({ + size: 123_456, + mtime: time, + mtimeMs: timeMs, + birthtimeMs: timeMs, + } 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); @@ -145,10 +151,13 @@ describe(MetadataService.name, () => { 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]); - mockReadTags({ - FileCreateDate: fileCreatedAt.toISOString(), - FileModifyDate: fileModifiedAt.toISOString(), - }); + mocks.storage.stat.mockResolvedValue({ + size: 123_456, + mtime: fileModifiedAt, + mtimeMs: fileModifiedAt.valueOf(), + birthtimeMs: fileCreatedAt.valueOf(), + } as Stats); + mockReadTags(); await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); @@ -168,10 +177,13 @@ describe(MetadataService.name, () => { 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]); - mockReadTags({ - FileCreateDate: fileCreatedAt.toISOString(), - FileModifyDate: fileModifiedAt.toISOString(), - }); + mocks.storage.stat.mockResolvedValue({ + size: 123_456, + mtime: fileModifiedAt, + mtimeMs: fileModifiedAt.valueOf(), + birthtimeMs: fileCreatedAt.valueOf(), + } as Stats); + mockReadTags(); await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); @@ -206,10 +218,14 @@ describe(MetadataService.name, () => { it('should handle lists of numbers', async () => { mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.storage.stat.mockResolvedValue({ + size: 123_456, + mtime: assetStub.image.fileModifiedAt, + mtimeMs: assetStub.image.fileModifiedAt.valueOf(), + birthtimeMs: assetStub.image.fileCreatedAt.valueOf(), + } as Stats); mockReadTags({ ISO: [160], - FileCreateDate: assetStub.image.fileCreatedAt.toISOString(), - FileModifyDate: assetStub.image.fileModifiedAt.toISOString(), }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -228,11 +244,15 @@ describe(MetadataService.name, () => { mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]); mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); + mocks.storage.stat.mockResolvedValue({ + size: 123_456, + mtime: assetStub.withLocation.fileModifiedAt, + mtimeMs: assetStub.withLocation.fileModifiedAt.valueOf(), + birthtimeMs: assetStub.withLocation.fileCreatedAt.valueOf(), + } as Stats); 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 }); @@ -475,6 +495,12 @@ describe(MetadataService.name, () => { it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => { mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); + mocks.storage.stat.mockResolvedValue({ + size: 123_456, + mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, + mtimeMs: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.valueOf(), + birthtimeMs: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.valueOf(), + } as Stats); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -483,8 +509,6 @@ 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); @@ -525,14 +549,18 @@ describe(MetadataService.name, () => { }); it('should extract the EmbeddedVideo tag from Samsung JPEG motion photos', async () => { + mocks.storage.stat.mockResolvedValue({ + size: 123_456, + mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, + mtimeMs: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.valueOf(), + birthtimeMs: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.valueOf(), + } as Stats); mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); mockReadTags({ Directory: 'foo/bar/', 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); @@ -574,13 +602,17 @@ describe(MetadataService.name, () => { it('should extract the motion photo video from the XMP directory entry ', async () => { mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); + mocks.storage.stat.mockResolvedValue({ + size: 123_456, + mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, + mtimeMs: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.valueOf(), + birthtimeMs: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.valueOf(), + } as Stats); mockReadTags({ Directory: 'foo/bar/', 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); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 4bf58a57fa..1e17f63283 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -1,9 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored'; +import { ContainerDirectoryItem, Maybe, Tags } from 'exiftool-vendored'; import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime'; import { Insertable } from 'kysely'; import _ from 'lodash'; import { Duration } from 'luxon'; +import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; import path from 'node:path'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; @@ -77,6 +78,11 @@ const validateRange = (value: number | undefined, min: number, max: number): Non type ImmichTagsWithFaces = ImmichTags & { RegionInfo: NonNullable }; +type Dates = { + dateTimeOriginal: Date; + localDateTime: Date; +}; + @Injectable() export class MetadataService extends BaseService { @OnEvent({ name: 'app.bootstrap', workers: [ImmichWorker.MICROSERVICES] }) @@ -171,18 +177,13 @@ export class MetadataService extends BaseService { return JobStatus.FAILED; } - const exifTags = await this.getExifTags(asset); - if (!exifTags.FileCreateDate || !exifTags.FileModifyDate || exifTags.FileSize === undefined) { - this.logger.warn(`Missing file creation or modification date for asset ${asset.id}: ${asset.originalPath}`); - const stat = await this.storageRepository.stat(asset.originalPath); - exifTags.FileCreateDate = stat.ctime.toISOString(); - exifTags.FileModifyDate = stat.mtime.toISOString(); - exifTags.FileSize = stat.size.toString(); - } - + const [exifTags, stats] = await Promise.all([ + this.getExifTags(asset), + this.storageRepository.stat(asset.originalPath), + ]); this.logger.verbose('Exif Tags', exifTags); - const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags); + const dates = this.getDates(asset, exifTags, stats); const { width, height } = this.getImageDimensions(exifTags); let geo: ReverseGeocodeResult, latitude: number | null, longitude: number | null; @@ -200,9 +201,9 @@ export class MetadataService extends BaseService { assetId: asset.id, // dates - dateTimeOriginal, - modifyDate, - timeZone, + dateTimeOriginal: dates.dateTimeOriginal, + modifyDate: stats.mtime, + timeZone: dates.timeZone, // gps latitude, @@ -212,7 +213,7 @@ export class MetadataService extends BaseService { city: geo.city, // image/file - fileSizeInByte: Number.parseInt(exifTags.FileSize!), + fileSizeInByte: stats.size, exifImageHeight: validate(height), exifImageWidth: validate(width), orientation: validate(exifTags.Orientation)?.toString() ?? null, @@ -245,15 +246,15 @@ export class MetadataService extends BaseService { this.assetRepository.update({ id: asset.id, duration: exifTags.Duration?.toString() ?? null, - localDateTime, - fileCreatedAt: exifData.dateTimeOriginal ?? undefined, - fileModifiedAt: exifData.modifyDate ?? undefined, + localDateTime: dates.localDateTime, + fileCreatedAt: dates.dateTimeOriginal ?? undefined, + fileModifiedAt: stats.mtime, }), this.applyTagList(asset, exifTags), ]; if (this.isMotionPhoto(asset, exifTags)) { - promises.push(this.applyMotionPhotos(asset, exifTags, exifData.fileSizeInByte!)); + promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats)); } if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) { @@ -432,7 +433,7 @@ export class MetadataService extends BaseService { return asset.type === AssetType.IMAGE && !!(tags.MotionPhoto || tags.MicroVideo); } - private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags, fileSize: number) { + private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags, dates: Dates, stats: Stats) { const isMotionPhoto = tags.MotionPhoto; const isMicroVideo = tags.MicroVideo; const videoOffset = tags.MicroVideoOffset; @@ -466,7 +467,7 @@ export class MetadataService extends BaseService { this.logger.debug(`Starting motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`); try { - const position = fileSize - length - padding; + const position = stats.size - length - padding; let video: Buffer; // Samsung MotionPhoto video extraction // HEIC-encoded @@ -505,13 +506,12 @@ export class MetadataService extends BaseService { } } else { const motionAssetId = this.cryptoRepository.randomUUID(); - const dates = this.getDates(asset, tags); motionAsset = await this.assetRepository.create({ id: motionAssetId, libraryId: asset.libraryId, type: AssetType.VIDEO, fileCreatedAt: dates.dateTimeOriginal, - fileModifiedAt: dates.modifyDate, + fileModifiedAt: stats.mtime, localDateTime: dates.localDateTime, checksum, ownerId: asset.ownerId, @@ -634,7 +634,7 @@ export class MetadataService extends BaseService { } } - private getDates(asset: AssetEntity, exifTags: ImmichTags) { + private getDates(asset: AssetEntity, exifTags: ImmichTags, stats: Stats) { const dateTime = firstDateTime(exifTags as Maybe, EXIF_DATE_TAGS); this.logger.verbose(`Date and time is ${dateTime} for asset ${asset.id}: ${asset.originalPath}`); @@ -654,17 +654,16 @@ export class MetadataService extends BaseService { this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`); } - const modifyDate = this.toDate(exifTags.FileModifyDate!); let dateTimeOriginal = dateTime?.toDate(); let localDateTime = dateTime?.toDateTime().setZone('UTC', { keepLocalTime: true }).toJSDate(); if (!localDateTime || !dateTimeOriginal) { - const fileCreatedAt = this.toDate(exifTags.FileCreateDate!); - const earliestDate = this.earliestDate(fileCreatedAt, modifyDate); + // 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 = stats.birthtimeMs ? new Date(Math.min(stats.mtimeMs, stats.birthtimeMs)) : stats.mtime; this.logger.debug( - `No exif date time found, falling back on ${earliestDate.toISOString()}, earliest of file creation and modification for assset ${asset.id}: ${asset.originalPath}`, + `No exif date time found, falling back on ${earliestDate.toISOString()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`, ); - dateTimeOriginal = earliestDate; - localDateTime = earliestDate; + dateTimeOriginal = localDateTime = earliestDate; } this.logger.verbose( @@ -675,18 +674,9 @@ export class MetadataService extends BaseService { dateTimeOriginal, timeZone, localDateTime, - modifyDate, }; } - private toDate(date: string | ExifDateTime): Date { - return typeof date === 'string' ? new Date(date) : date.toDate(); - } - - private earliestDate(a: Date, b: Date) { - return new Date(Math.min(a.valueOf(), b.valueOf())); - } - private hasGeo(tags: ImmichTags): tags is ImmichTags & { GPSLatitude: number; GPSLongitude: number } { return ( tags.GPSLatitude !== undefined && diff --git a/server/test/medium/specs/metadata.service.spec.ts b/server/test/medium/specs/metadata.service.spec.ts index 5613a05fd0..fbdb8b51ac 100644 --- a/server/test/medium/specs/metadata.service.spec.ts +++ b/server/test/medium/specs/metadata.service.spec.ts @@ -39,7 +39,12 @@ describe(MetadataService.name, () => { beforeEach(() => { ({ sut, mocks } = newTestService(MetadataService, { metadata: metadataRepository })); - mocks.storage.stat.mockResolvedValue({ size: 123_456, ctime: new Date(), mtime: new Date() } as Stats); + mocks.storage.stat.mockResolvedValue({ + size: 123_456, + mtime: new Date(654_321), + mtimeMs: 654_321, + birthtimeMs: 654_322, + } as Stats); delete process.env.TZ; }); @@ -54,8 +59,6 @@ describe(MetadataService.name, () => { description: 'should handle no time zone information', exifData: { DateTimeOriginal: '2022:01:01 00:00:00', - FileCreateDate: '2022:01:01 00:00:00', - FileModifyDate: '2022:01:01 00:00:00', }, expected: { localDateTime: '2022-01-01T00:00:00.000Z', @@ -68,8 +71,6 @@ describe(MetadataService.name, () => { serverTimeZone: 'America/Los_Angeles', exifData: { DateTimeOriginal: '2022:01:01 00:00:00', - FileCreateDate: '2022:01:01 00:00:00', - FileModifyDate: '2022:01:01 00:00:00', }, expected: { localDateTime: '2022-01-01T00:00:00.000Z', @@ -82,8 +83,6 @@ describe(MetadataService.name, () => { serverTimeZone: 'Europe/Brussels', exifData: { DateTimeOriginal: '2022:01:01 00:00:00', - FileCreateDate: '2022:01:01 00:00:00', - FileModifyDate: '2022:01:01 00:00:00', }, expected: { localDateTime: '2022-01-01T00:00:00.000Z', @@ -96,8 +95,6 @@ describe(MetadataService.name, () => { serverTimeZone: 'Europe/Brussels', exifData: { DateTimeOriginal: '2022:06:01 00:00:00', - FileCreateDate: '2022:06:01 00:00:00', - FileModifyDate: '2022:06:01 00:00:00', }, expected: { localDateTime: '2022-06-01T00:00:00.000Z', @@ -109,8 +106,6 @@ describe(MetadataService.name, () => { description: 'should handle a +13:00 time zone', exifData: { DateTimeOriginal: '2022:01:01 00:00:00+13:00', - FileCreateDate: '2022:01:01 00:00:00+13:00', - FileModifyDate: '2022:01:01 00:00:00+13:00', }, expected: { localDateTime: '2022-01-01T00:00:00.000Z',