1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-22 01:47:08 +02:00

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>
This commit is contained in:
Zack Pollard 2022-09-23 03:50:05 +01:00 committed by GitHub
parent e5459b68ff
commit f377b64065
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 905 additions and 1296 deletions

View File

@ -41,12 +41,20 @@ LOG_LEVEL=simple
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
################################################################################### ###################################################################################
# MAPBOX # Reverse Geocoding
#################################################################################### ####################################################################################
# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY # DISABLE_REVERSE_GEOCODING=false
ENABLE_MAPBOX=false
MAPBOX_KEY= # 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 # WEB - Optional

View File

@ -94,7 +94,12 @@ export class ScheduleTasksService {
}); });
for (const exif of exifInfo) { 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() },
);
} }
} }
} }

View File

@ -13,8 +13,6 @@ import {
reverseGeocodingProcessorName, reverseGeocodingProcessorName,
IReverseGeocodingProcessor, IReverseGeocodingProcessor,
} from '@app/job'; } 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 { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
@ -26,12 +24,63 @@ import ffmpeg from 'fluent-ffmpeg';
import path from 'path'; import path from 'path';
import sharp from 'sharp'; import sharp from 'sharp';
import { Repository } from 'typeorm/repository/Repository'; 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 { find } from 'geo-tz';
import * as luxon from 'luxon'; 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) @Processor(metadataExtractionQueueName)
export class MetadataExtractionProcessor { export class MetadataExtractionProcessor {
private geocodingClient?: GeocodeService; private isGeocodeInitialized = false;
private logLevel: ImmichLogLevel; private logLevel: ImmichLogLevel;
constructor( constructor(
@ -46,15 +95,44 @@ export class MetadataExtractionProcessor {
private configService: ConfigService, private configService: ConfigService,
) { ) {
if (process.env.ENABLE_MAPBOX == 'true' && process.env.MAPBOX_KEY) { if (configService.get('DISABLE_REVERSE_GEOCODING') !== 'true') {
this.geocodingClient = mapboxGeocoding({ Logger.log('Initialising Reverse Geocoding');
accessToken: process.env.MAPBOX_KEY, 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; 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) @Process(exifExtractionProcessorName)
async extractExifInfo(job: Job<IExifExtractionProcessor>) { async extractExifInfo(job: Job<IExifExtractionProcessor>) {
try { try {
@ -141,35 +219,11 @@ export class MetadataExtractionProcessor {
* Get the city, state or region name of the asset * Get the city, state or region name of the asset
* based on lat/lon GPS coordinates. * based on lat/lon GPS coordinates.
*/ */
if (this.geocodingClient && exifData['longitude'] && exifData['latitude']) { if (this.isGeocodeInitialized && newExif.latitude && newExif.longitude) {
const geoCodeInfo: MapiResponse = await this.geocodingClient const { country, state, city } = await this.reverseGeocodeExif(newExif.latitude, newExif.longitude);
.reverseGeocode({ newExif.country = country;
query: [exifData['longitude'], exifData['latitude']], newExif.state = state;
types: ['country', 'region', 'place'], newExif.city = city;
})
.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;
} }
/** /**
@ -204,35 +258,10 @@ export class MetadataExtractionProcessor {
@Process({ name: reverseGeocodingProcessorName }) @Process({ name: reverseGeocodingProcessorName })
async reverseGeocoding(job: Job<IReverseGeocodingProcessor>) { async reverseGeocoding(job: Job<IReverseGeocodingProcessor>) {
const { exif } = job.data; if (this.isGeocodeInitialized) {
const { latitude, longitude } = job.data;
if (this.geocodingClient) { const { country, state, city } = await this.reverseGeocodeExif(latitude, longitude);
const geoCodeInfo: MapiResponse = await this.geocodingClient await this.exifRepository.update({ id: job.data.exifId }, { city, state, country });
.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 });
} }
} }
@ -344,35 +373,11 @@ export class MetadataExtractionProcessor {
} }
// Reverse GeoCoding // Reverse GeoCoding
if (this.geocodingClient && newExif.longitude && newExif.latitude) { if (this.isGeocodeInitialized && newExif.longitude && newExif.latitude) {
const geoCodeInfo: MapiResponse = await this.geocodingClient const { country, state, city } = await this.reverseGeocodeExif(newExif.latitude, newExif.longitude);
.reverseGeocode({ newExif.country = country;
query: [newExif.longitude, newExif.latitude], newExif.state = state;
types: ['country', 'region', 'place'], newExif.city = city;
})
.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;
} }
for (const stream of data.streams) { for (const stream of data.streams) {

View File

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

View File

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

1967
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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