Browse Source

Add timezone to exif entity (#1894)

* Add timezone to exif entity

* Refactor logging

---------

Co-authored-by: Andrea Alemani <andrea.alemani94@gmail.com>
AndreAle94 2 years ago
parent
commit
94b2ea9b5f

+ 1 - 0
mobile/openapi/doc/ExifResponseDto.md

@@ -17,6 +17,7 @@ Name | Type | Description | Notes
 **orientation** | **String** |  | [optional] 
 **dateTimeOriginal** | [**DateTime**](DateTime.md) |  | [optional] 
 **modifyDate** | [**DateTime**](DateTime.md) |  | [optional] 
+**timeZone** | **String** |  | [optional] 
 **lensModel** | **String** |  | [optional] 
 **fNumber** | **num** |  | [optional] 
 **focalLength** | **num** |  | [optional] 

+ 12 - 1
mobile/openapi/lib/model/exif_response_dto.dart

@@ -22,6 +22,7 @@ class ExifResponseDto {
     this.orientation,
     this.dateTimeOriginal,
     this.modifyDate,
+    this.timeZone,
     this.lensModel,
     this.fNumber,
     this.focalLength,
@@ -52,6 +53,8 @@ class ExifResponseDto {
 
   DateTime? modifyDate;
 
+  String? timeZone;
+
   String? lensModel;
 
   num? fNumber;
@@ -83,6 +86,7 @@ class ExifResponseDto {
      other.orientation == orientation &&
      other.dateTimeOriginal == dateTimeOriginal &&
      other.modifyDate == modifyDate &&
+     other.timeZone == timeZone &&
      other.lensModel == lensModel &&
      other.fNumber == fNumber &&
      other.focalLength == focalLength &&
@@ -106,6 +110,7 @@ class ExifResponseDto {
     (orientation == null ? 0 : orientation!.hashCode) +
     (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) +
     (modifyDate == null ? 0 : modifyDate!.hashCode) +
+    (timeZone == null ? 0 : timeZone!.hashCode) +
     (lensModel == null ? 0 : lensModel!.hashCode) +
     (fNumber == null ? 0 : fNumber!.hashCode) +
     (focalLength == null ? 0 : focalLength!.hashCode) +
@@ -118,7 +123,7 @@ class ExifResponseDto {
     (country == null ? 0 : country!.hashCode);
 
   @override
-  String toString() => 'ExifResponseDto[fileSizeInByte=$fileSizeInByte, make=$make, model=$model, imageName=$imageName, exifImageWidth=$exifImageWidth, exifImageHeight=$exifImageHeight, orientation=$orientation, dateTimeOriginal=$dateTimeOriginal, modifyDate=$modifyDate, lensModel=$lensModel, fNumber=$fNumber, focalLength=$focalLength, iso=$iso, exposureTime=$exposureTime, latitude=$latitude, longitude=$longitude, city=$city, state=$state, country=$country]';
+  String toString() => 'ExifResponseDto[fileSizeInByte=$fileSizeInByte, make=$make, model=$model, imageName=$imageName, exifImageWidth=$exifImageWidth, exifImageHeight=$exifImageHeight, orientation=$orientation, dateTimeOriginal=$dateTimeOriginal, modifyDate=$modifyDate, timeZone=$timeZone, lensModel=$lensModel, fNumber=$fNumber, focalLength=$focalLength, iso=$iso, exposureTime=$exposureTime, latitude=$latitude, longitude=$longitude, city=$city, state=$state, country=$country]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -167,6 +172,11 @@ class ExifResponseDto {
     } else {
       // json[r'modifyDate'] = null;
     }
+    if (this.timeZone != null) {
+      json[r'timeZone'] = this.timeZone;
+    } else {
+      // json[r'timeZone'] = null;
+    }
     if (this.lensModel != null) {
       json[r'lensModel'] = this.lensModel;
     } else {
@@ -252,6 +262,7 @@ class ExifResponseDto {
         orientation: mapValueOfType<String>(json, r'orientation'),
         dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', ''),
         modifyDate: mapDateTime(json, r'modifyDate', ''),
+        timeZone: mapValueOfType<String>(json, r'timeZone'),
         lensModel: mapValueOfType<String>(json, r'lensModel'),
         fNumber: json[r'fNumber'] == null
             ? null

+ 5 - 0
mobile/openapi/test/exif_response_dto_test.dart

@@ -61,6 +61,11 @@ void main() {
       // TODO
     });
 
+    // String timeZone
+    test('to test the property `timeZone`', () async {
+      // TODO
+    });
+
     // String lensModel
     test('to test the property `lensModel`', () async {
       // TODO

+ 22 - 0
server/apps/microservices/src/processors/metadata-extraction.processor.ts

@@ -17,6 +17,7 @@ import { ConfigService } from '@nestjs/config';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Job } from 'bull';
 import { ExifDateTime, exiftool, Tags } from 'exiftool-vendored';
+import tz_lookup from '@photostructure/tz-lookup';
 import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
 import { getName } from 'i18n-iso-countries';
 import geocoder, { InitOptions } from 'local-reverse-geocoder';
@@ -190,6 +191,17 @@ export class MetadataExtractionProcessor {
         return exifDate.toDate();
       };
 
+      const exifTimeZone = (exifDate: string | ExifDateTime | undefined) => {
+        if (!exifDate) return null;
+
+        if (typeof exifDate === 'string') {
+          return null;
+        }
+
+        return exifDate.zone ?? null;
+      };
+
+      const timeZone = exifTimeZone(exifData?.DateTimeOriginal ?? exifData?.CreateDate ?? asset.fileCreatedAt);
       const fileCreatedAt = exifToDate(exifData?.DateTimeOriginal ?? exifData?.CreateDate ?? asset.fileCreatedAt);
       const fileModifiedAt = exifToDate(exifData?.ModifyDate ?? asset.fileModifiedAt);
       const fileStats = fs.statSync(asset.originalPath);
@@ -207,6 +219,7 @@ export class MetadataExtractionProcessor {
       newExif.orientation = exifData?.Orientation?.toString() || null;
       newExif.dateTimeOriginal = fileCreatedAt;
       newExif.modifyDate = fileModifiedAt;
+      newExif.timeZone = timeZone;
       newExif.lensModel = exifData?.LensModel || null;
       newExif.fNumber = exifData?.FNumber || null;
       newExif.focalLength = exifData?.FocalLength ? parseFloat(exifData.FocalLength) : null;
@@ -308,6 +321,7 @@ export class MetadataExtractionProcessor {
       newExif.fileSizeInByte = data.format.size || null;
       newExif.dateTimeOriginal = fileCreatedAt ? new Date(fileCreatedAt) : null;
       newExif.modifyDate = null;
+      newExif.timeZone = null;
       newExif.latitude = null;
       newExif.longitude = null;
       newExif.city = null;
@@ -345,6 +359,14 @@ export class MetadataExtractionProcessor {
         }
       }
 
+      if (newExif.longitude && newExif.latitude) {
+        try {
+          newExif.timeZone = tz_lookup(newExif.latitude, newExif.longitude);
+        } catch (error: any) {
+          this.logger.warn(`Error while calculating timezone from gps coordinates: ${error}`, error?.stack);
+        }
+      }
+
       // Reverse GeoCoding
       if (this.isGeocodeInitialized && newExif.longitude && newExif.latitude) {
         const { country, state, city } = await this.reverseGeocodeExif(newExif.latitude, newExif.longitude);

+ 5 - 0
server/immich-openapi-specs.json

@@ -3537,6 +3537,11 @@
             "nullable": true,
             "default": null
           },
+          "timeZone": {
+            "type": "string",
+            "nullable": true,
+            "default": null
+          },
           "lensModel": {
             "type": "string",
             "nullable": true,

+ 2 - 0
server/libs/domain/src/asset/response-dto/exif-response.dto.ts

@@ -13,6 +13,7 @@ export class ExifResponseDto {
   orientation?: string | null = null;
   dateTimeOriginal?: Date | null = null;
   modifyDate?: Date | null = null;
+  timeZone?: string | null = null;
   lensModel?: string | null = null;
   fNumber?: number | null = null;
   focalLength?: number | null = null;
@@ -36,6 +37,7 @@ export function mapExif(entity: ExifEntity): ExifResponseDto {
     orientation: entity.orientation,
     dateTimeOriginal: entity.dateTimeOriginal,
     modifyDate: entity.modifyDate,
+    timeZone: entity.timeZone,
     lensModel: entity.lensModel,
     fNumber: entity.fNumber,
     focalLength: entity.focalLength,

+ 2 - 0
server/libs/domain/test/fixtures.ts

@@ -323,6 +323,7 @@ const assetInfo: ExifResponseDto = {
   orientation: 'orientation',
   dateTimeOriginal: today,
   modifyDate: today,
+  timeZone: 'America/Los_Angeles',
   lensModel: 'fancy',
   fNumber: 100,
   focalLength: 100,
@@ -607,6 +608,7 @@ export const sharedLinkStub = {
             orientation: 'orientation',
             dateTimeOriginal: today,
             modifyDate: today,
+            timeZone: 'America/Los_Angeles',
             latitude: 100,
             longitude: 100,
             city: 'city',

+ 3 - 0
server/libs/infra/src/entities/exif.entity.ts

@@ -34,6 +34,9 @@ export class ExifEntity {
   @Column({ type: 'timestamptz', nullable: true })
   modifyDate!: Date | null;
 
+  @Column({ type: 'varchar', nullable: true })
+  timeZone!: string | null;
+
   @Column({ type: 'float', nullable: true })
   latitude!: number | null;
 

+ 14 - 0
server/libs/infra/src/migrations/1677497925328-AddExifTimeZone.ts

@@ -0,0 +1,14 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class AddExifTimeZone1677497925328 implements MigrationInterface {
+    name = 'AddExifTimeZone1677497925328'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "exif" ADD "timeZone" character varying`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "timeZone"`);
+    }
+
+}

+ 6 - 0
web/src/api/open-api/api.ts

@@ -1126,6 +1126,12 @@ export interface ExifResponseDto {
      * @memberof ExifResponseDto
      */
     'modifyDate'?: string | null;
+    /**
+     * 
+     * @type {string}
+     * @memberof ExifResponseDto
+     */
+    'timeZone'?: string | null;
     /**
      * 
      * @type {string}

+ 21 - 12
web/src/lib/components/asset-viewer/detail-panel.svelte

@@ -8,6 +8,7 @@
 	import { AssetResponseDto, AlbumResponseDto } from '@api';
 	import { asByteUnitString } from '../../utils/byte-units';
 	import { locale } from '$lib/stores/preferences.store';
+	import { DateTime } from 'luxon';
 	import type { LatLngTuple } from 'leaflet';
 
 	export let asset: AssetResponseDto;
@@ -55,7 +56,9 @@
 		{/if}
 
 		{#if asset.exifInfo?.dateTimeOriginal}
-			{@const assetDateTimeOriginal = new Date(asset.exifInfo.dateTimeOriginal)}
+			{@const assetDateTimeOriginal = DateTime.fromISO(asset.exifInfo.dateTimeOriginal, {
+				zone: asset.exifInfo.timeZone ?? undefined
+			})}
 			<div class="flex gap-4 py-4">
 				<div>
 					<Calendar size="24" />
@@ -63,20 +66,26 @@
 
 				<div>
 					<p>
-						{assetDateTimeOriginal.toLocaleDateString($locale, {
-							month: 'short',
-							day: 'numeric',
-							year: 'numeric'
-						})}
+						{assetDateTimeOriginal.toLocaleString(
+							{
+								month: 'short',
+								day: 'numeric',
+								year: 'numeric'
+							},
+							{ locale: $locale }
+						)}
 					</p>
 					<div class="flex gap-2 text-sm">
 						<p>
-							{assetDateTimeOriginal.toLocaleString($locale, {
-								weekday: 'short',
-								hour: 'numeric',
-								minute: '2-digit',
-								timeZoneName: 'longOffset'
-							})}
+							{assetDateTimeOriginal.toLocaleString(
+								{
+									weekday: 'short',
+									hour: 'numeric',
+									minute: '2-digit',
+									timeZoneName: 'longOffset'
+								},
+								{ locale: $locale }
+							)}
 						</p>
 					</div>
 				</div>