metadata-extraction.processor.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. import { AssetEntity, AssetType, ExifEntity } from '@app/infra';
  2. import { IReverseGeocodingJob, IAssetUploadedJob, QueueName, JobName, IAssetRepository } from '@app/domain';
  3. import { Process, Processor } from '@nestjs/bull';
  4. import { Inject, Logger } from '@nestjs/common';
  5. import { ConfigService } from '@nestjs/config';
  6. import { InjectRepository } from '@nestjs/typeorm';
  7. import { Job } from 'bull';
  8. import ffmpeg from 'fluent-ffmpeg';
  9. import path from 'path';
  10. import sharp from 'sharp';
  11. import { Repository } from 'typeorm/repository/Repository';
  12. import geocoder, { InitOptions } from 'local-reverse-geocoder';
  13. import { getName } from 'i18n-iso-countries';
  14. import fs from 'node:fs';
  15. import { ExifDateTime, exiftool, Tags } from 'exiftool-vendored';
  16. interface ImmichTags extends Tags {
  17. ContentIdentifier?: string;
  18. }
  19. function geocoderInit(init: InitOptions) {
  20. return new Promise<void>(function (resolve) {
  21. geocoder.init(init, () => {
  22. resolve();
  23. });
  24. });
  25. }
  26. function geocoderLookup(points: { latitude: number; longitude: number }[]) {
  27. return new Promise<GeoData>(function (resolve) {
  28. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  29. // @ts-ignore
  30. geocoder.lookUp(points, 1, (err, addresses) => {
  31. resolve(addresses[0][0] as GeoData);
  32. });
  33. });
  34. }
  35. const geocodingPrecisionLevels = ['cities15000', 'cities5000', 'cities1000', 'cities500'];
  36. export type AdminCode = {
  37. name: string;
  38. asciiName: string;
  39. geoNameId: string;
  40. };
  41. export type GeoData = {
  42. geoNameId: string;
  43. name: string;
  44. asciiName: string;
  45. alternateNames: string;
  46. latitude: string;
  47. longitude: string;
  48. featureClass: string;
  49. featureCode: string;
  50. countryCode: string;
  51. cc2?: any;
  52. admin1Code?: AdminCode | string;
  53. admin2Code?: AdminCode | string;
  54. admin3Code?: any;
  55. admin4Code?: any;
  56. population: string;
  57. elevation: string;
  58. dem: string;
  59. timezone: string;
  60. modificationDate: string;
  61. distance: number;
  62. };
  63. @Processor(QueueName.METADATA_EXTRACTION)
  64. export class MetadataExtractionProcessor {
  65. private logger = new Logger(MetadataExtractionProcessor.name);
  66. private isGeocodeInitialized = false;
  67. constructor(
  68. @Inject(IAssetRepository) private assetRepository: IAssetRepository,
  69. @InjectRepository(ExifEntity)
  70. private exifRepository: Repository<ExifEntity>,
  71. configService: ConfigService,
  72. ) {
  73. if (!configService.get('DISABLE_REVERSE_GEOCODING')) {
  74. this.logger.log('Initializing Reverse Geocoding');
  75. geocoderInit({
  76. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  77. // @ts-ignore
  78. citiesFileOverride: geocodingPrecisionLevels[configService.get('REVERSE_GEOCODING_PRECISION')],
  79. load: {
  80. admin1: true,
  81. admin2: true,
  82. admin3And4: false,
  83. alternateNames: false,
  84. },
  85. countries: [],
  86. dumpDirectory:
  87. configService.get('REVERSE_GEOCODING_DUMP_DIRECTORY') || process.cwd() + '/.reverse-geocoding-dump/',
  88. }).then(() => {
  89. this.isGeocodeInitialized = true;
  90. this.logger.log('Reverse Geocoding Initialised');
  91. });
  92. }
  93. }
  94. private async reverseGeocodeExif(
  95. latitude: number,
  96. longitude: number,
  97. ): Promise<{ country: string; state: string; city: string }> {
  98. const geoCodeInfo = await geocoderLookup([{ latitude, longitude }]);
  99. const country = getName(geoCodeInfo.countryCode, 'en');
  100. const city = geoCodeInfo.name;
  101. let state = '';
  102. if (geoCodeInfo.admin2Code) {
  103. const adminCode2 = geoCodeInfo.admin2Code as AdminCode;
  104. state += adminCode2.name;
  105. }
  106. if (geoCodeInfo.admin1Code) {
  107. const adminCode1 = geoCodeInfo.admin1Code as AdminCode;
  108. if (geoCodeInfo.admin2Code) {
  109. const adminCode2 = geoCodeInfo.admin2Code as AdminCode;
  110. if (adminCode2.name) {
  111. state += ', ';
  112. }
  113. }
  114. state += adminCode1.name;
  115. }
  116. return { country, state, city };
  117. }
  118. @Process(JobName.EXIF_EXTRACTION)
  119. async extractExifInfo(job: Job<IAssetUploadedJob>) {
  120. try {
  121. const { asset, fileName }: { asset: AssetEntity; fileName: string } = job.data;
  122. const exifData = await exiftool.read<ImmichTags>(asset.originalPath).catch((e) => {
  123. this.logger.warn(`The exifData parsing failed due to: ${e} on file ${asset.originalPath}`);
  124. return null;
  125. });
  126. const exifToDate = (exifDate: string | ExifDateTime | undefined) => {
  127. if (!exifDate) return null;
  128. if (typeof exifDate === 'string') {
  129. return new Date(exifDate);
  130. }
  131. return exifDate.toDate();
  132. };
  133. const fileCreatedAt = exifToDate(exifData?.DateTimeOriginal ?? exifData?.CreateDate ?? asset.fileCreatedAt);
  134. const fileModifiedAt = exifToDate(exifData?.ModifyDate ?? asset.fileModifiedAt);
  135. const fileStats = fs.statSync(asset.originalPath);
  136. const fileSizeInBytes = fileStats.size;
  137. const newExif = new ExifEntity();
  138. newExif.assetId = asset.id;
  139. newExif.imageName = path.parse(fileName).name;
  140. newExif.fileSizeInByte = fileSizeInBytes;
  141. newExif.make = exifData?.Make || null;
  142. newExif.model = exifData?.Model || null;
  143. newExif.exifImageHeight = exifData?.ExifImageHeight || exifData?.ImageHeight || null;
  144. newExif.exifImageWidth = exifData?.ExifImageWidth || exifData?.ImageWidth || null;
  145. newExif.exposureTime = exifData?.ExposureTime || null;
  146. newExif.orientation = exifData?.Orientation?.toString() || null;
  147. newExif.dateTimeOriginal = fileCreatedAt;
  148. newExif.modifyDate = fileModifiedAt;
  149. newExif.lensModel = exifData?.LensModel || null;
  150. newExif.fNumber = exifData?.FNumber || null;
  151. newExif.focalLength = exifData?.FocalLength ? parseFloat(exifData.FocalLength) : null;
  152. newExif.iso = exifData?.ISO || null;
  153. newExif.latitude = exifData?.GPSLatitude || null;
  154. newExif.longitude = exifData?.GPSLongitude || null;
  155. newExif.livePhotoCID = exifData?.MediaGroupUUID || null;
  156. await this.assetRepository.save({
  157. id: asset.id,
  158. fileCreatedAt: fileCreatedAt?.toISOString(),
  159. });
  160. if (newExif.livePhotoCID && !asset.livePhotoVideoId) {
  161. const motionAsset = await this.assetRepository.findLivePhotoMatch(
  162. newExif.livePhotoCID,
  163. asset.id,
  164. AssetType.VIDEO,
  165. );
  166. if (motionAsset) {
  167. await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
  168. await this.assetRepository.save({ id: motionAsset.id, isVisible: false });
  169. }
  170. }
  171. /**
  172. * Reverse Geocoding
  173. *
  174. * Get the city, state or region name of the asset
  175. * based on lat/lon GPS coordinates.
  176. */
  177. if (this.isGeocodeInitialized && newExif.latitude && newExif.longitude) {
  178. const { country, state, city } = await this.reverseGeocodeExif(newExif.latitude, newExif.longitude);
  179. newExif.country = country;
  180. newExif.state = state;
  181. newExif.city = city;
  182. }
  183. /**
  184. * IF the EXIF doesn't contain the width and height of the image,
  185. * We will use Sharpjs to get the information.
  186. */
  187. if (!newExif.exifImageHeight || !newExif.exifImageWidth || !newExif.orientation) {
  188. const metadata = await sharp(asset.originalPath).metadata();
  189. if (newExif.exifImageHeight === null) {
  190. newExif.exifImageHeight = metadata.height || null;
  191. }
  192. if (newExif.exifImageWidth === null) {
  193. newExif.exifImageWidth = metadata.width || null;
  194. }
  195. if (newExif.orientation === null) {
  196. newExif.orientation = metadata.orientation !== undefined ? `${metadata.orientation}` : null;
  197. }
  198. }
  199. await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
  200. } catch (error: any) {
  201. this.logger.error(`Error extracting EXIF ${error}`, error?.stack);
  202. }
  203. }
  204. @Process({ name: JobName.REVERSE_GEOCODING })
  205. async reverseGeocoding(job: Job<IReverseGeocodingJob>) {
  206. if (this.isGeocodeInitialized) {
  207. const { latitude, longitude } = job.data;
  208. const { country, state, city } = await this.reverseGeocodeExif(latitude, longitude);
  209. await this.exifRepository.update({ assetId: job.data.assetId }, { city, state, country });
  210. }
  211. }
  212. @Process({ name: JobName.EXTRACT_VIDEO_METADATA, concurrency: 2 })
  213. async extractVideoMetadata(job: Job<IAssetUploadedJob>) {
  214. const { asset, fileName } = job.data;
  215. if (!asset.isVisible) {
  216. return;
  217. }
  218. try {
  219. const data = await new Promise<ffmpeg.FfprobeData>((resolve, reject) =>
  220. ffmpeg.ffprobe(asset.originalPath, (err, data) => {
  221. if (err) return reject(err);
  222. return resolve(data);
  223. }),
  224. );
  225. let durationString = asset.duration;
  226. let fileCreatedAt = asset.fileCreatedAt;
  227. if (data.format.duration) {
  228. durationString = this.extractDuration(data.format.duration);
  229. }
  230. const videoTags = data.format.tags;
  231. if (videoTags) {
  232. if (videoTags['com.apple.quicktime.creationdate']) {
  233. fileCreatedAt = String(videoTags['com.apple.quicktime.creationdate']);
  234. } else if (videoTags['creation_time']) {
  235. fileCreatedAt = String(videoTags['creation_time']);
  236. }
  237. }
  238. const exifData = await exiftool.read<ImmichTags>(asset.originalPath).catch((e) => {
  239. this.logger.warn(`The exifData parsing failed due to: ${e} on file ${asset.originalPath}`);
  240. return null;
  241. });
  242. const newExif = new ExifEntity();
  243. newExif.assetId = asset.id;
  244. newExif.description = '';
  245. newExif.imageName = path.parse(fileName).name || null;
  246. newExif.fileSizeInByte = data.format.size || null;
  247. newExif.dateTimeOriginal = fileCreatedAt ? new Date(fileCreatedAt) : null;
  248. newExif.modifyDate = null;
  249. newExif.latitude = null;
  250. newExif.longitude = null;
  251. newExif.city = null;
  252. newExif.state = null;
  253. newExif.country = null;
  254. newExif.fps = null;
  255. newExif.livePhotoCID = exifData?.ContentIdentifier || null;
  256. if (newExif.livePhotoCID) {
  257. const photoAsset = await this.assetRepository.findLivePhotoMatch(
  258. newExif.livePhotoCID,
  259. asset.id,
  260. AssetType.IMAGE,
  261. );
  262. if (photoAsset) {
  263. await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: asset.id });
  264. await this.assetRepository.save({ id: asset.id, isVisible: false });
  265. }
  266. }
  267. if (videoTags && videoTags['location']) {
  268. const location = videoTags['location'] as string;
  269. const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/;
  270. const match = location.match(locationRegex);
  271. if (match?.length === 3) {
  272. newExif.latitude = parseFloat(match[1]);
  273. newExif.longitude = parseFloat(match[2]);
  274. }
  275. } else if (videoTags && videoTags['com.apple.quicktime.location.ISO6709']) {
  276. const location = videoTags['com.apple.quicktime.location.ISO6709'] as string;
  277. const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/;
  278. const match = location.match(locationRegex);
  279. if (match?.length === 4) {
  280. newExif.latitude = parseFloat(match[1]);
  281. newExif.longitude = parseFloat(match[2]);
  282. }
  283. }
  284. // Reverse GeoCoding
  285. if (this.isGeocodeInitialized && newExif.longitude && newExif.latitude) {
  286. const { country, state, city } = await this.reverseGeocodeExif(newExif.latitude, newExif.longitude);
  287. newExif.country = country;
  288. newExif.state = state;
  289. newExif.city = city;
  290. }
  291. for (const stream of data.streams) {
  292. if (stream.codec_type === 'video') {
  293. newExif.exifImageWidth = stream.width || null;
  294. newExif.exifImageHeight = stream.height || null;
  295. if (typeof stream.rotation === 'string') {
  296. newExif.orientation = stream.rotation;
  297. } else if (typeof stream.rotation === 'number') {
  298. newExif.orientation = `${stream.rotation}`;
  299. } else {
  300. newExif.orientation = null;
  301. }
  302. if (stream.r_frame_rate) {
  303. const fpsParts = stream.r_frame_rate.split('/');
  304. if (fpsParts.length === 2) {
  305. newExif.fps = Math.round(parseInt(fpsParts[0]) / parseInt(fpsParts[1]));
  306. }
  307. }
  308. }
  309. }
  310. await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
  311. await this.assetRepository.save({ id: asset.id, duration: durationString, fileCreatedAt });
  312. } catch (err) {
  313. ``;
  314. // do nothing
  315. console.log('Error in video metadata extraction', err);
  316. }
  317. }
  318. private extractDuration(duration: number) {
  319. const videoDurationInSecond = parseInt(duration.toString(), 0);
  320. const hours = Math.floor(videoDurationInSecond / 3600);
  321. const minutes = Math.floor((videoDurationInSecond - hours * 3600) / 60);
  322. const seconds = videoDurationInSecond - hours * 3600 - minutes * 60;
  323. return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.000000`;
  324. }
  325. }