From 94b2ea9b5fa09ead043755b55a6ed96ecc299c7b Mon Sep 17 00:00:00 2001 From: AndreAle94 Date: Sun, 2 Apr 2023 21:11:24 +0200 Subject: [PATCH] Add timezone to exif entity (#1894) * Add timezone to exif entity * Refactor logging --------- Co-authored-by: Andrea Alemani --- mobile/openapi/doc/ExifResponseDto.md | 1 + .../openapi/lib/model/exif_response_dto.dart | 13 +++++++- .../openapi/test/exif_response_dto_test.dart | 5 +++ .../metadata-extraction.processor.ts | 22 +++++++++++++ server/immich-openapi-specs.json | 5 +++ .../asset/response-dto/exif-response.dto.ts | 2 ++ server/libs/domain/test/fixtures.ts | 2 ++ server/libs/infra/src/entities/exif.entity.ts | 3 ++ .../1677497925328-AddExifTimeZone.ts | 14 ++++++++ web/src/api/open-api/api.ts | 6 ++++ .../asset-viewer/detail-panel.svelte | 33 ++++++++++++------- 11 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 server/libs/infra/src/migrations/1677497925328-AddExifTimeZone.ts diff --git a/mobile/openapi/doc/ExifResponseDto.md b/mobile/openapi/doc/ExifResponseDto.md index 5ae1099c0..7c40bc742 100644 --- a/mobile/openapi/doc/ExifResponseDto.md +++ b/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] diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index 1cb298c35..071d8caf7 100644 --- a/mobile/openapi/lib/model/exif_response_dto.dart +++ b/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 toJson() { final json = {}; @@ -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(json, r'orientation'), dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', ''), modifyDate: mapDateTime(json, r'modifyDate', ''), + timeZone: mapValueOfType(json, r'timeZone'), lensModel: mapValueOfType(json, r'lensModel'), fNumber: json[r'fNumber'] == null ? null diff --git a/mobile/openapi/test/exif_response_dto_test.dart b/mobile/openapi/test/exif_response_dto_test.dart index 173a37b3a..63623cd4f 100644 --- a/mobile/openapi/test/exif_response_dto_test.dart +++ b/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 diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index bd308a7fd..a6b6d11e7 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/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); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index dd6e71ac5..70b11afa9 100644 --- a/server/immich-openapi-specs.json +++ b/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, diff --git a/server/libs/domain/src/asset/response-dto/exif-response.dto.ts b/server/libs/domain/src/asset/response-dto/exif-response.dto.ts index 036e7e64a..83961a196 100644 --- a/server/libs/domain/src/asset/response-dto/exif-response.dto.ts +++ b/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, diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index ea092d44e..e5383703f 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/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', diff --git a/server/libs/infra/src/entities/exif.entity.ts b/server/libs/infra/src/entities/exif.entity.ts index 8375bdbfc..00b57f9e2 100644 --- a/server/libs/infra/src/entities/exif.entity.ts +++ b/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; diff --git a/server/libs/infra/src/migrations/1677497925328-AddExifTimeZone.ts b/server/libs/infra/src/migrations/1677497925328-AddExifTimeZone.ts new file mode 100644 index 000000000..33f958336 --- /dev/null +++ b/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 { + await queryRunner.query(`ALTER TABLE "exif" ADD "timeZone" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "timeZone"`); + } + +} diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 2313411d9..3853e5575 100644 --- a/web/src/api/open-api/api.ts +++ b/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} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 05575035e..9ab4d99a1 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/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 + })}
@@ -63,20 +66,26 @@

- {assetDateTimeOriginal.toLocaleDateString($locale, { - month: 'short', - day: 'numeric', - year: 'numeric' - })} + {assetDateTimeOriginal.toLocaleString( + { + month: 'short', + day: 'numeric', + year: 'numeric' + }, + { locale: $locale } + )}

- {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 } + )}