import { Injectable } from '@nestjs/common'; import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored'; import geotz from 'geo-tz'; import { LoggingRepository } from 'src/repositories/logging.repository'; interface ExifDuration { Value: number; Scale?: number; } type StringOrNumber = string | number; type TagsWithWrongTypes = | 'FocalLength' | 'Duration' | 'Description' | 'ImageDescription' | 'RegionInfo' | 'TagsList' | 'Keywords' | 'HierarchicalSubject' | 'ISO'; export interface ImmichTags extends Omit { ContentIdentifier?: string; MotionPhoto?: number; MotionPhotoVersion?: number; MotionPhotoPresentationTimestampUs?: number; MediaGroupUUID?: string; ImagePixelDepth?: string; FocalLength?: number; Duration?: number | string | ExifDuration; EmbeddedVideoType?: string; EmbeddedVideoFile?: BinaryField; MotionPhotoVideo?: BinaryField; TagsList?: StringOrNumber[]; HierarchicalSubject?: StringOrNumber[]; Keywords?: StringOrNumber | StringOrNumber[]; ISO?: number | number[]; // Type is wrong, can also be number. Description?: StringOrNumber; ImageDescription?: StringOrNumber; // Extended properties for image regions, such as faces RegionInfo?: { AppliedToDimensions: { W: number; H: number; Unit: string; }; RegionList: { Area: { // (X,Y) // center of the rectangle X: number; Y: number; W: number; H: number; Unit: string; }; Rotation?: number; Type?: string; Name?: string; }[]; }; Device?: { Manufacturer?: string; ModelName?: string; }; AndroidMake?: string; AndroidModel?: string; } @Injectable() export class MetadataRepository { private exiftool = new ExifTool({ defaultVideosToUTC: true, backfillTimezones: true, inferTimezoneFromDatestamps: true, inferTimezoneFromTimeStamp: true, useMWG: true, numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength', 'FileSize'], /* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */ geoTz: (lat, lon) => geotz.find(lat, lon)[0], // Enable exiftool LFS to parse metadata for files larger than 2GB. readArgs: ['-api', 'largefilesupport=1'], writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'], }); constructor(private logger: LoggingRepository) { this.logger.setContext(MetadataRepository.name); } setMaxConcurrency(concurrency: number) { this.exiftool.batchCluster.setMaxProcs(concurrency); } async teardown() { await this.exiftool.end(); } readTags(path: string): Promise { return this.exiftool.read(path).catch((error) => { this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`); return {}; }) as Promise; } extractBinaryTag(path: string, tagName: string): Promise { return this.exiftool.extractBinaryTagToBuffer(tagName, path); } async writeTags(path: string, tags: Partial): Promise { try { await this.exiftool.write(path, tags); } catch (error) { this.logger.warn(`Error writing exif data (${path}): ${error}`); } } }