metadata-extraction.processor.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. import { ImmichLogLevel } from '@app/common/constants/log-level.constant';
  2. import { AssetEntity } from '@app/database/entities/asset.entity';
  3. import { ExifEntity } from '@app/database/entities/exif.entity';
  4. import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
  5. import {
  6. IExifExtractionProcessor,
  7. IVideoLengthExtractionProcessor,
  8. exifExtractionProcessorName,
  9. imageTaggingProcessorName,
  10. objectDetectionProcessorName,
  11. videoMetadataExtractionProcessorName,
  12. metadataExtractionQueueName,
  13. reverseGeocodingProcessorName,
  14. IReverseGeocodingProcessor,
  15. } from '@app/job';
  16. import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
  17. import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
  18. import { Process, Processor } from '@nestjs/bull';
  19. import { Logger } from '@nestjs/common';
  20. import { ConfigService } from '@nestjs/config';
  21. import { InjectRepository } from '@nestjs/typeorm';
  22. import axios from 'axios';
  23. import { Job } from 'bull';
  24. import exifr from 'exifr';
  25. import ffmpeg from 'fluent-ffmpeg';
  26. import path from 'path';
  27. import sharp from 'sharp';
  28. import { Repository } from 'typeorm/repository/Repository';
  29. @Processor(metadataExtractionQueueName)
  30. export class MetadataExtractionProcessor {
  31. private geocodingClient?: GeocodeService;
  32. private logLevel: ImmichLogLevel;
  33. constructor(
  34. @InjectRepository(AssetEntity)
  35. private assetRepository: Repository<AssetEntity>,
  36. @InjectRepository(ExifEntity)
  37. private exifRepository: Repository<ExifEntity>,
  38. @InjectRepository(SmartInfoEntity)
  39. private smartInfoRepository: Repository<SmartInfoEntity>,
  40. private configService: ConfigService,
  41. ) {
  42. if (process.env.ENABLE_MAPBOX == 'true' && process.env.MAPBOX_KEY) {
  43. this.geocodingClient = mapboxGeocoding({
  44. accessToken: process.env.MAPBOX_KEY,
  45. });
  46. }
  47. this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
  48. }
  49. @Process(exifExtractionProcessorName)
  50. async extractExifInfo(job: Job<IExifExtractionProcessor>) {
  51. try {
  52. const { asset, fileName, fileSize }: { asset: AssetEntity; fileName: string; fileSize: number } = job.data;
  53. const exifData = await exifr.parse(asset.originalPath, {
  54. tiff: true,
  55. ifd0: true as any,
  56. ifd1: true,
  57. exif: true,
  58. gps: true,
  59. interop: true,
  60. xmp: true,
  61. icc: true,
  62. iptc: true,
  63. jfif: true,
  64. ihdr: true,
  65. });
  66. if (!exifData) {
  67. throw new Error(`can not parse exif data from file ${asset.originalPath}`);
  68. }
  69. const newExif = new ExifEntity();
  70. newExif.assetId = asset.id;
  71. newExif.make = exifData['Make'] || null;
  72. newExif.model = exifData['Model'] || null;
  73. newExif.imageName = path.parse(fileName).name || null;
  74. newExif.exifImageHeight = exifData['ExifImageHeight'] || exifData['ImageHeight'] || null;
  75. newExif.exifImageWidth = exifData['ExifImageWidth'] || exifData['ImageWidth'] || null;
  76. newExif.fileSizeInByte = fileSize || null;
  77. newExif.orientation = exifData['Orientation'] || null;
  78. newExif.dateTimeOriginal = exifData['DateTimeOriginal'] || null;
  79. newExif.modifyDate = exifData['ModifyDate'] || null;
  80. newExif.lensModel = exifData['LensModel'] || null;
  81. newExif.fNumber = exifData['FNumber'] || null;
  82. newExif.focalLength = exifData['FocalLength'] || null;
  83. newExif.iso = exifData['ISO'] || null;
  84. newExif.exposureTime = exifData['ExposureTime'] || null;
  85. newExif.latitude = exifData['latitude'] || null;
  86. newExif.longitude = exifData['longitude'] || null;
  87. // Reverse GeoCoding
  88. if (this.geocodingClient && exifData['longitude'] && exifData['latitude']) {
  89. const geoCodeInfo: MapiResponse = await this.geocodingClient
  90. .reverseGeocode({
  91. query: [exifData['longitude'], exifData['latitude']],
  92. types: ['country', 'region', 'place'],
  93. })
  94. .send();
  95. const res: [] = geoCodeInfo.body['features'];
  96. let city = '';
  97. let state = '';
  98. let country = '';
  99. if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
  100. city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
  101. }
  102. if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
  103. state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
  104. }
  105. if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
  106. country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
  107. }
  108. newExif.city = city || null;
  109. newExif.state = state || null;
  110. newExif.country = country || null;
  111. }
  112. // Enrich metadata
  113. if (!newExif.exifImageHeight || !newExif.exifImageWidth || !newExif.orientation) {
  114. const metadata = await sharp(asset.originalPath).metadata();
  115. if (newExif.exifImageHeight === null) {
  116. newExif.exifImageHeight = metadata.height || null;
  117. }
  118. if (newExif.exifImageWidth === null) {
  119. newExif.exifImageWidth = metadata.width || null;
  120. }
  121. if (newExif.orientation === null) {
  122. newExif.orientation = metadata.orientation !== undefined ? `${metadata.orientation}` : null;
  123. }
  124. }
  125. await this.exifRepository.save(newExif);
  126. } catch (e) {
  127. Logger.error(`Error extracting EXIF ${String(e)}`, 'extractExif');
  128. if (this.logLevel === ImmichLogLevel.VERBOSE) {
  129. console.trace('Error extracting EXIF', e);
  130. }
  131. }
  132. }
  133. @Process({ name: reverseGeocodingProcessorName })
  134. async reverseGeocoding(job: Job<IReverseGeocodingProcessor>) {
  135. const { exif } = job.data;
  136. if (this.geocodingClient) {
  137. const geoCodeInfo: MapiResponse = await this.geocodingClient
  138. .reverseGeocode({
  139. query: [Number(exif.longitude), Number(exif.latitude)],
  140. types: ['country', 'region', 'place'],
  141. })
  142. .send();
  143. const res: [] = geoCodeInfo.body['features'];
  144. let city = '';
  145. let state = '';
  146. let country = '';
  147. if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
  148. city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
  149. }
  150. if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
  151. state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
  152. }
  153. if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
  154. country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
  155. }
  156. await this.exifRepository.update({ id: exif.id }, { city, state, country });
  157. }
  158. }
  159. @Process({ name: imageTaggingProcessorName, concurrency: 2 })
  160. async tagImage(job: Job) {
  161. const { asset }: { asset: AssetEntity } = job.data;
  162. const res = await axios.post('http://immich-machine-learning:3003/image-classifier/tag-image', {
  163. thumbnailPath: asset.resizePath,
  164. });
  165. if (res.status == 201 && res.data.length > 0) {
  166. const smartInfo = new SmartInfoEntity();
  167. smartInfo.assetId = asset.id;
  168. smartInfo.tags = [...res.data];
  169. await this.smartInfoRepository.upsert(smartInfo, {
  170. conflictPaths: ['assetId'],
  171. });
  172. }
  173. }
  174. @Process({ name: objectDetectionProcessorName, concurrency: 2 })
  175. async detectObject(job: Job) {
  176. try {
  177. const { asset }: { asset: AssetEntity } = job.data;
  178. const res = await axios.post('http://immich-machine-learning:3003/object-detection/detect-object', {
  179. thumbnailPath: asset.resizePath,
  180. });
  181. if (res.status == 201 && res.data.length > 0) {
  182. const smartInfo = new SmartInfoEntity();
  183. smartInfo.assetId = asset.id;
  184. smartInfo.objects = [...res.data];
  185. await this.smartInfoRepository.upsert(smartInfo, {
  186. conflictPaths: ['assetId'],
  187. });
  188. }
  189. } catch (error) {
  190. Logger.error(`Failed to trigger object detection pipe line ${String(error)}`);
  191. }
  192. }
  193. @Process({ name: videoMetadataExtractionProcessorName, concurrency: 2 })
  194. async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
  195. const { asset, fileName } = job.data;
  196. try {
  197. const data = await new Promise<ffmpeg.FfprobeData>((resolve, reject) =>
  198. ffmpeg.ffprobe(asset.originalPath, (err, data) => {
  199. if (err) return reject(err);
  200. return resolve(data);
  201. }),
  202. );
  203. let durationString = asset.duration;
  204. let createdAt = asset.createdAt;
  205. if (data.format.duration) {
  206. durationString = this.extractDuration(data.format.duration);
  207. }
  208. const videoTags = data.format.tags;
  209. if (videoTags) {
  210. if (videoTags['com.apple.quicktime.creationdate']) {
  211. createdAt = String(videoTags['com.apple.quicktime.creationdate']);
  212. } else if (videoTags['creation_time']) {
  213. createdAt = String(videoTags['creation_time']);
  214. } else {
  215. createdAt = asset.createdAt;
  216. }
  217. } else {
  218. createdAt = asset.createdAt;
  219. }
  220. const newExif = new ExifEntity();
  221. newExif.assetId = asset.id;
  222. newExif.description = '';
  223. newExif.imageName = path.parse(fileName).name || null;
  224. newExif.fileSizeInByte = data.format.size || null;
  225. newExif.dateTimeOriginal = createdAt ? new Date(createdAt) : null;
  226. newExif.modifyDate = null;
  227. newExif.latitude = null;
  228. newExif.longitude = null;
  229. newExif.city = null;
  230. newExif.state = null;
  231. newExif.country = null;
  232. newExif.fps = null;
  233. if (videoTags && videoTags['location']) {
  234. const location = videoTags['location'] as string;
  235. const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/;
  236. const match = location.match(locationRegex);
  237. if (match?.length === 3) {
  238. newExif.latitude = parseFloat(match[1]);
  239. newExif.longitude = parseFloat(match[2]);
  240. }
  241. } else if (videoTags && videoTags['com.apple.quicktime.location.ISO6709']) {
  242. const location = videoTags['com.apple.quicktime.location.ISO6709'] as string;
  243. const locationRegex = /([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)([+-][0-9]+\.[0-9]+)\/$/;
  244. const match = location.match(locationRegex);
  245. if (match?.length === 4) {
  246. newExif.latitude = parseFloat(match[1]);
  247. newExif.longitude = parseFloat(match[2]);
  248. }
  249. }
  250. // Reverse GeoCoding
  251. if (this.geocodingClient && newExif.longitude && newExif.latitude) {
  252. const geoCodeInfo: MapiResponse = await this.geocodingClient
  253. .reverseGeocode({
  254. query: [newExif.longitude, newExif.latitude],
  255. types: ['country', 'region', 'place'],
  256. })
  257. .send();
  258. const res: [] = geoCodeInfo.body['features'];
  259. let city = '';
  260. let state = '';
  261. let country = '';
  262. if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
  263. city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
  264. }
  265. if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
  266. state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
  267. }
  268. if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
  269. country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
  270. }
  271. newExif.city = city || null;
  272. newExif.state = state || null;
  273. newExif.country = country || null;
  274. }
  275. for (const stream of data.streams) {
  276. if (stream.codec_type === 'video') {
  277. newExif.exifImageWidth = stream.width || null;
  278. newExif.exifImageHeight = stream.height || null;
  279. if (typeof stream.rotation === 'string') {
  280. newExif.orientation = stream.rotation;
  281. } else if (typeof stream.rotation === 'number') {
  282. newExif.orientation = `${stream.rotation}`;
  283. } else {
  284. newExif.orientation = null;
  285. }
  286. if (stream.r_frame_rate) {
  287. const fpsParts = stream.r_frame_rate.split('/');
  288. if (fpsParts.length === 2) {
  289. newExif.fps = Math.round(parseInt(fpsParts[0]) / parseInt(fpsParts[1]));
  290. }
  291. }
  292. }
  293. }
  294. await this.exifRepository.save(newExif);
  295. await this.assetRepository.update({ id: asset.id }, { duration: durationString, createdAt: createdAt });
  296. } catch (err) {
  297. // do nothing
  298. console.log('Error in video metadata extraction', err);
  299. }
  300. }
  301. private extractDuration(duration: number) {
  302. const videoDurationInSecond = parseInt(duration.toString(), 0);
  303. const hours = Math.floor(videoDurationInSecond / 3600);
  304. const minutes = Math.floor((videoDurationInSecond - hours * 3600) / 60);
  305. const seconds = videoDurationInSecond - hours * 3600 - minutes * 60;
  306. return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.000000`;
  307. }
  308. }