fix(server): use stat instead of exifinfo for file date metadata (#17311)

* use stat instead of filecreatedate

* update tests

* unused import
This commit is contained in:
Mert
2025-04-01 18:24:07 -04:00
committed by GitHub
parent 502854cee1
commit d911b76c08
3 changed files with 92 additions and 75 deletions
+29 -39
View File
@@ -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<ImmichTags['RegionInfo']> };
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<Tags>, 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 &&