Prechádzať zdrojové kódy

feat(server) Remove mapbox and use local reverse geocoding (#738)

* feat: local reverse geocoding implementation, removes mapbox

* Disable non-null tslintrule

* Disable non-null tslintrule

* Remove tsignore

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Zack Pollard 2 rokov pred
rodič
commit
f377b64065

+ 12 - 4
docker/.env.example

@@ -41,12 +41,20 @@ LOG_LEVEL=simple
 JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
 
 ###################################################################################
-# MAPBOX
+# Reverse Geocoding
 ####################################################################################
 
-# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
-ENABLE_MAPBOX=false
-MAPBOX_KEY=
+# DISABLE_REVERSE_GEOCODING=false
+
+# Reverse geocoding is done locally which has a small impact on memory usage
+# This memory usage can be altered by changing the REVERSE_GEOCODING_PRECISION variable
+# This ranges from 0-3 with 3 being the most precise
+# 3 - Cities > 500 population: ~200MB RAM
+# 2 - Cities > 1000 population: ~150MB RAM
+# 1 - Cities > 5000 population: ~80MB RAM
+# 0 - Cities > 15000 population: ~40MB RAM
+
+# REVERSE_GEOCODING_PRECISION=3
 
 ####################################################################################
 # WEB - Optional

+ 6 - 1
server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts

@@ -94,7 +94,12 @@ export class ScheduleTasksService {
       });
 
       for (const exif of exifInfo) {
-        await this.metadataExtractionQueue.add(reverseGeocodingProcessorName, { exif }, { jobId: randomUUID() });
+        await this.metadataExtractionQueue.add(
+          reverseGeocodingProcessorName,
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+          { exifId: exif.id, latitude: exif.latitude!, longitude: exif.longitude! },
+          { jobId: randomUUID() },
+        );
       }
     }
   }

+ 98 - 93
server/apps/microservices/src/processors/metadata-extraction.processor.ts

@@ -13,8 +13,6 @@ import {
   reverseGeocodingProcessorName,
   IReverseGeocodingProcessor,
 } from '@app/job';
-import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
-import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
 import { Process, Processor } from '@nestjs/bull';
 import { Logger } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
@@ -26,12 +24,63 @@ import ffmpeg from 'fluent-ffmpeg';
 import path from 'path';
 import sharp from 'sharp';
 import { Repository } from 'typeorm/repository/Repository';
+import geocoder, { InitOptions } from 'local-reverse-geocoder';
+import { getName } from 'i18n-iso-countries';
 import { find } from 'geo-tz';
 import * as luxon from 'luxon';
 
+function geocoderInit(init: InitOptions) {
+  return new Promise<void>(function (resolve) {
+    geocoder.init(init, () => {
+      resolve();
+    });
+  });
+}
+
+function geocoderLookup(points: { latitude: number; longitude: number }[]) {
+  return new Promise<GeoData>(function (resolve) {
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    // @ts-ignore
+    geocoder.lookUp(points, 1, (err, addresses) => {
+      resolve(addresses[0][0]);
+    });
+  });
+}
+
+const geocodingPrecisionLevels = [ "cities15000", "cities5000", "cities1000", "cities500" ]
+
+export interface AdminCode {
+  name: string;
+  asciiName: string;
+  geoNameId: string;
+}
+
+export interface GeoData {
+  geoNameId: string;
+  name: string;
+  asciiName: string;
+  alternateNames: string;
+  latitude: string;
+  longitude: string;
+  featureClass: string;
+  featureCode: string;
+  countryCode: string;
+  cc2?: any;
+  admin1Code: AdminCode;
+  admin2Code: AdminCode;
+  admin3Code: string;
+  admin4Code?: any;
+  population: string;
+  elevation: string;
+  dem: string;
+  timezone: string;
+  modificationDate: string;
+  distance: number;
+}
+
 @Processor(metadataExtractionQueueName)
 export class MetadataExtractionProcessor {
-  private geocodingClient?: GeocodeService;
+  private isGeocodeInitialized = false;
   private logLevel: ImmichLogLevel;
 
   constructor(
@@ -46,15 +95,44 @@ export class MetadataExtractionProcessor {
 
     private configService: ConfigService,
   ) {
-    if (process.env.ENABLE_MAPBOX == 'true' && process.env.MAPBOX_KEY) {
-      this.geocodingClient = mapboxGeocoding({
-        accessToken: process.env.MAPBOX_KEY,
+    if (configService.get('DISABLE_REVERSE_GEOCODING') !== 'true') {
+      Logger.log('Initialising Reverse Geocoding');
+      geocoderInit({
+        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+        // @ts-ignore
+        citiesFileOverride: geocodingPrecisionLevels[configService.get('REVERSE_GEOCODING_PRECISION')],
+        load: {
+          admin1: true,
+          admin2: true,
+          admin3And4: false,
+          alternateNames: false,
+        },
+        countries: [],
+      }).then(() => {
+        this.isGeocodeInitialized = true;
+        Logger.log('Reverse Geocoding Initialised');
       });
     }
 
     this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
   }
 
+  private async reverseGeocodeExif(latitude: number, longitude: number): Promise<{country: string, state: string, city: string}> {
+    const geoCodeInfo = await geocoderLookup([{ latitude, longitude }]);
+
+    const country = getName(geoCodeInfo.countryCode, 'en');
+    const city = geoCodeInfo.name;
+
+    let state = '';
+    if (geoCodeInfo.admin2Code.name) state += geoCodeInfo.admin2Code.name;
+    if (geoCodeInfo.admin1Code.name) {
+      if (geoCodeInfo.admin2Code.name) state += ', ';
+      state += geoCodeInfo.admin1Code.name;
+    }
+
+    return { country, state, city }
+  }
+
   @Process(exifExtractionProcessorName)
   async extractExifInfo(job: Job<IExifExtractionProcessor>) {
     try {
@@ -141,35 +219,11 @@ export class MetadataExtractionProcessor {
        * Get the city, state or region name of the asset
        * based on lat/lon GPS coordinates.
        */
-      if (this.geocodingClient && exifData['longitude'] && exifData['latitude']) {
-        const geoCodeInfo: MapiResponse = await this.geocodingClient
-          .reverseGeocode({
-            query: [exifData['longitude'], exifData['latitude']],
-            types: ['country', 'region', 'place'],
-          })
-          .send();
-
-        const res: [] = geoCodeInfo.body['features'];
-
-        let city = '';
-        let state = '';
-        let country = '';
-
-        if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
-          city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
-        }
-
-        if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
-          state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
-        }
-
-        if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
-          country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
-        }
-
-        newExif.city = city || null;
-        newExif.state = state || null;
-        newExif.country = country || null;
+      if (this.isGeocodeInitialized && newExif.latitude && newExif.longitude) {
+        const { country, state, city } = await this.reverseGeocodeExif(newExif.latitude, newExif.longitude);
+        newExif.country = country;
+        newExif.state = state;
+        newExif.city = city;
       }
 
       /**
@@ -204,35 +258,10 @@ export class MetadataExtractionProcessor {
 
   @Process({ name: reverseGeocodingProcessorName })
   async reverseGeocoding(job: Job<IReverseGeocodingProcessor>) {
-    const { exif } = job.data;
-
-    if (this.geocodingClient) {
-      const geoCodeInfo: MapiResponse = await this.geocodingClient
-        .reverseGeocode({
-          query: [Number(exif.longitude), Number(exif.latitude)],
-          types: ['country', 'region', 'place'],
-        })
-        .send();
-
-      const res: [] = geoCodeInfo.body['features'];
-
-      let city = '';
-      let state = '';
-      let country = '';
-
-      if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
-        city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
-      }
-
-      if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
-        state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
-      }
-
-      if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
-        country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
-      }
-
-      await this.exifRepository.update({ id: exif.id }, { city, state, country });
+    if (this.isGeocodeInitialized) {
+      const { latitude, longitude } = job.data;
+      const { country, state, city } = await this.reverseGeocodeExif(latitude, longitude);
+      await this.exifRepository.update({ id: job.data.exifId }, { city, state, country });
     }
   }
 
@@ -344,35 +373,11 @@ export class MetadataExtractionProcessor {
       }
 
       // Reverse GeoCoding
-      if (this.geocodingClient && newExif.longitude && newExif.latitude) {
-        const geoCodeInfo: MapiResponse = await this.geocodingClient
-          .reverseGeocode({
-            query: [newExif.longitude, newExif.latitude],
-            types: ['country', 'region', 'place'],
-          })
-          .send();
-
-        const res: [] = geoCodeInfo.body['features'];
-
-        let city = '';
-        let state = '';
-        let country = '';
-
-        if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
-          city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
-        }
-
-        if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
-          state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
-        }
-
-        if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
-          country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
-        }
-
-        newExif.city = city || null;
-        newExif.state = state || null;
-        newExif.country = country || null;
+      if (this.isGeocodeInitialized && newExif.longitude && newExif.latitude) {
+        const { country, state, city } = await this.reverseGeocodeExif(newExif.latitude, newExif.longitude);
+        newExif.country = country;
+        newExif.state = state;
+        newExif.city = city;
       }
 
       for (const stream of data.streams) {

+ 2 - 6
server/libs/common/src/config/app.config.ts

@@ -10,12 +10,8 @@ export const immichAppConfig: ConfigModuleOptions = {
     DB_PASSWORD: Joi.string().required(),
     DB_DATABASE_NAME: Joi.string().required(),
     JWT_SECRET: Joi.string().required(),
-    ENABLE_MAPBOX: Joi.boolean().required().valid(true, false),
-    MAPBOX_KEY: Joi.any().when('ENABLE_MAPBOX', {
-      is: false,
-      then: Joi.string().optional().allow(null, ''),
-      otherwise: Joi.string().required(),
-    }),
+    DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
+    REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0,1,2,3).default(3),
     LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
   }),
 };

+ 3 - 5
server/libs/job/src/interfaces/metadata-extraction.interface.ts

@@ -1,5 +1,4 @@
 import { AssetEntity } from '@app/database/entities/asset.entity';
-import { ExifEntity } from '@app/database/entities/exif.entity';
 
 export interface IExifExtractionProcessor {
   /**
@@ -36,10 +35,9 @@ export interface IVideoLengthExtractionProcessor {
 }
 
 export interface IReverseGeocodingProcessor {
-  /**
-   * The Asset entity that was saved in the database
-   */
-  exif: ExifEntity;
+  exifId: string;
+  latitude: number;
+  longitude: number;
 }
 
 export type IMetadataExtractionJob =

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 213 - 366
server/package-lock.json


+ 2 - 2
server/package.json

@@ -28,7 +28,6 @@
     "api:generate": "npm run api:typescript && npm run api:dart"
   },
   "dependencies": {
-    "@mapbox/mapbox-sdk": "^0.13.3",
     "@nestjs/bull": "^0.5.5",
     "@nestjs/common": "^8.4.7",
     "@nestjs/config": "^2.1.0",
@@ -54,7 +53,9 @@
     "exifr": "^7.1.3",
     "fluent-ffmpeg": "^2.1.2",
     "geo-tz": "^7.0.2",
+    "i18n-iso-countries": "^7.5.0",
     "joi": "^17.5.0",
+    "local-reverse-geocoder": "^0.12.2",
     "lodash": "^4.17.21",
     "luxon": "^3.0.3",
     "passport": "^0.6.0",
@@ -85,7 +86,6 @@
     "@types/imagemin": "^8.0.0",
     "@types/jest": "27.0.2",
     "@types/lodash": "^4.14.178",
-    "@types/mapbox__mapbox-sdk": "^0.13.4",
     "@types/multer": "^1.4.7",
     "@types/node": "^16.0.0",
     "@types/passport-jwt": "^3.0.6",

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov