diff --git a/docs/docs/features/reverse-geocoding.md b/docs/docs/features/reverse-geocoding.md index 399bdd9b4851b..7a3c1e3899b37 100644 --- a/docs/docs/features/reverse-geocoding.md +++ b/docs/docs/features/reverse-geocoding.md @@ -6,6 +6,8 @@ Immich supports local [Reverse Geocoding](https://en.wikipedia.org/wiki/Reverse_ During Exif Extraction, assets with latitudes and longitudes are reverse geocoded to determine their City, State, and Country. +Note that if your assets already contain any of the IPTC tags (`City`, `Country-PrimaryLocationName`, `Province-State`), the reverse geocoding will not be performed and these tag values will be used instead + ## Usage Data from a reverse geocode is displayed in the image details, and used in [Smart Search](/docs/features/searching.md). diff --git a/server/src/interfaces/metadata.interface.ts b/server/src/interfaces/metadata.interface.ts new file mode 100644 index 0000000000000..07266226fd067 --- /dev/null +++ b/server/src/interfaces/metadata.interface.ts @@ -0,0 +1,77 @@ +import { BinaryField, Tags } from 'exiftool-vendored'; + +export const IMetadataRepository = 'IMetadataRepository'; + +export interface ExifDuration { + Value: number; + Scale?: number; +} + +type StringOrNumber = string | number; + +type TagsWithWrongTypes = + | 'FocalLength' + | 'Duration' + | 'Description' + | 'ImageDescription' + | 'City' + | 'State' + | 'Country' + | '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; + City?: StringOrNumber; + State?: StringOrNumber; + Country?: 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; + }[]; + }; +} + +export interface IMetadataRepository { + teardown(): Promise; + readTags(path: string): Promise; + writeTags(path: string, tags: Partial): Promise; + extractBinaryTag(tagName: string, path: string): Promise; +} diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 3f297d709b2fd..77e9c129cf75f 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -15,6 +15,9 @@ type TagsWithWrongTypes = | 'Duration' | 'Description' | 'ImageDescription' + | 'City' + | 'State' + | 'Country' | 'RegionInfo' | 'TagsList' | 'Keywords' @@ -41,6 +44,9 @@ export interface ImmichTags extends Omit { // Type is wrong, can also be number. Description?: StringOrNumber; ImageDescription?: StringOrNumber; + City?: StringOrNumber; + State?: StringOrNumber; + Country?: StringOrNumber; // Extended properties for image regions, such as faces RegionInfo?: { diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index ffc3c171dc9ad..bef95215fc7a0 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -362,6 +362,31 @@ describe(MetadataService.name, () => { }); }); + it('should use existing tags instead of geocoding', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.withLocation]); + systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); + mockReadTags({ + GPSLatitude: assetStub.withLocation.exifInfo!.latitude!, + GPSLongitude: assetStub.withLocation.exifInfo!.longitude!, + City: 'City', + State: 'State', + Country: 'Country', + }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }), + ); + expect(assetMock.update).toHaveBeenCalledWith({ + id: assetStub.withLocation.id, + duration: null, + fileCreatedAt: assetStub.withLocation.createdAt, + localDateTime: new Date('2023-02-22T05:06:29.716Z'), + }); + expect(mapMock.reverseGeocode).not.toHaveBeenCalled(); + }); + it('should discard latitude and longitude on null island', async () => { assetMock.getByIds.mockResolvedValue([assetStub.withLocation]); mockReadTags({ diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index db3af9fca09e3..caa5187085fc1 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -629,6 +629,13 @@ export class MetadataService extends BaseService { longitude = null; } + const city = String(tags.City || ''); + const state = String(tags.State || ''); + const country = String(tags.Country || ''); + if (city || state || country) { + return { city, state, country, latitude, longitude }; + } + let result: ReverseGeocodeResult = { country: null, state: null, city: null }; if (reverseGeocoding.enabled && longitude && latitude) { result = await this.mapRepository.reverseGeocode({ latitude, longitude });