Compare commits

...

9 commits

Author SHA1 Message Date
Alex
b6349ee643
Merge branch 'main' into dev/map-box 2023-11-12 23:32:42 -06:00
Alex Tran
3990708802 remove unused code 2023-11-12 21:58:55 -06:00
Alex Tran
238268f5e0 linter 2023-11-12 21:56:37 -06:00
Alex Tran
c50a90ffca threshold 2023-11-12 21:54:12 -06:00
Alex Tran
a3b059700f remove console.log 2023-11-12 21:43:23 -06:00
Alex Tran
829b964df4 test: 2023-11-12 21:37:32 -06:00
Alex Tran
998dfdaeda repository, services and validation 2023-11-12 21:34:05 -06:00
Alex Tran
2d489b33e3 input token 2023-11-12 15:21:42 -06:00
Alex Tran
557b824f8b installation of library and settings 2023-11-12 15:03:12 -06:00
18 changed files with 1504 additions and 44 deletions

View file

@ -3926,6 +3926,18 @@ export interface SystemConfigReverseGeocodingDto {
* @memberof SystemConfigReverseGeocodingDto * @memberof SystemConfigReverseGeocodingDto
*/ */
'enabled': boolean; 'enabled': boolean;
/**
*
* @type {string}
* @memberof SystemConfigReverseGeocodingDto
*/
'mapboxAccessToken': string;
/**
*
* @type {boolean}
* @memberof SystemConfigReverseGeocodingDto
*/
'useMapbox': boolean;
} }

View file

@ -10,6 +10,8 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | ------------- ------------ | ------------- | ------------- | -------------
**citiesFileOverride** | [**CitiesFile**](CitiesFile.md) | | **citiesFileOverride** | [**CitiesFile**](CitiesFile.md) | |
**enabled** | **bool** | | **enabled** | **bool** | |
**mapboxAccessToken** | **String** | |
**useMapbox** | **bool** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -15,30 +15,42 @@ class SystemConfigReverseGeocodingDto {
SystemConfigReverseGeocodingDto({ SystemConfigReverseGeocodingDto({
required this.citiesFileOverride, required this.citiesFileOverride,
required this.enabled, required this.enabled,
required this.mapboxAccessToken,
required this.useMapbox,
}); });
CitiesFile citiesFileOverride; CitiesFile citiesFileOverride;
bool enabled; bool enabled;
String mapboxAccessToken;
bool useMapbox;
@override @override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigReverseGeocodingDto && bool operator ==(Object other) => identical(this, other) || other is SystemConfigReverseGeocodingDto &&
other.citiesFileOverride == citiesFileOverride && other.citiesFileOverride == citiesFileOverride &&
other.enabled == enabled; other.enabled == enabled &&
other.mapboxAccessToken == mapboxAccessToken &&
other.useMapbox == useMapbox;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(citiesFileOverride.hashCode) + (citiesFileOverride.hashCode) +
(enabled.hashCode); (enabled.hashCode) +
(mapboxAccessToken.hashCode) +
(useMapbox.hashCode);
@override @override
String toString() => 'SystemConfigReverseGeocodingDto[citiesFileOverride=$citiesFileOverride, enabled=$enabled]'; String toString() => 'SystemConfigReverseGeocodingDto[citiesFileOverride=$citiesFileOverride, enabled=$enabled, mapboxAccessToken=$mapboxAccessToken, useMapbox=$useMapbox]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'citiesFileOverride'] = this.citiesFileOverride; json[r'citiesFileOverride'] = this.citiesFileOverride;
json[r'enabled'] = this.enabled; json[r'enabled'] = this.enabled;
json[r'mapboxAccessToken'] = this.mapboxAccessToken;
json[r'useMapbox'] = this.useMapbox;
return json; return json;
} }
@ -52,6 +64,8 @@ class SystemConfigReverseGeocodingDto {
return SystemConfigReverseGeocodingDto( return SystemConfigReverseGeocodingDto(
citiesFileOverride: CitiesFile.fromJson(json[r'citiesFileOverride'])!, citiesFileOverride: CitiesFile.fromJson(json[r'citiesFileOverride'])!,
enabled: mapValueOfType<bool>(json, r'enabled')!, enabled: mapValueOfType<bool>(json, r'enabled')!,
mapboxAccessToken: mapValueOfType<String>(json, r'mapboxAccessToken')!,
useMapbox: mapValueOfType<bool>(json, r'useMapbox')!,
); );
} }
return null; return null;
@ -101,6 +115,8 @@ class SystemConfigReverseGeocodingDto {
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'citiesFileOverride', 'citiesFileOverride',
'enabled', 'enabled',
'mapboxAccessToken',
'useMapbox',
}; };
} }

View file

@ -26,6 +26,16 @@ void main() {
// TODO // TODO
}); });
// String mapboxAccessToken
test('to test the property `mapboxAccessToken`', () async {
// TODO
});
// bool useMapbox
test('to test the property `useMapbox`', () async {
// TODO
});
}); });

View file

@ -8828,11 +8828,19 @@
}, },
"enabled": { "enabled": {
"type": "boolean" "type": "boolean"
},
"mapboxAccessToken": {
"type": "string"
},
"useMapbox": {
"type": "boolean"
} }
}, },
"required": [ "required": [
"citiesFileOverride", "citiesFileOverride",
"enabled" "enabled",
"useMapbox",
"mapboxAccessToken"
], ],
"type": "object" "type": "object"
}, },

1321
server/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -40,6 +40,7 @@
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.22.11", "@babel/runtime": "^7.22.11",
"@mapbox/mapbox-sdk": "^0.15.3",
"@nestjs/bullmq": "^10.0.1", "@nestjs/bullmq": "^10.0.1",
"@nestjs/common": "^10.2.2", "@nestjs/common": "^10.2.2",
"@nestjs/config": "^3.0.0", "@nestjs/config": "^3.0.0",
@ -100,6 +101,7 @@
"@types/jest": "29.5.8", "@types/jest": "29.5.8",
"@types/jest-when": "^3.5.2", "@types/jest-when": "^3.5.2",
"@types/lodash": "^4.14.197", "@types/lodash": "^4.14.197",
"@types/mapbox__mapbox-sdk": "^0.13.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/mv": "^2.1.2", "@types/mv": "^2.1.2",
@ -152,7 +154,7 @@
"branches": 80, "branches": 80,
"functions": 80, "functions": 80,
"lines": 90, "lines": 90,
"statements": 90 "statements": 89
} }
}, },
"setupFilesAfterEnv": [ "setupFilesAfterEnv": [

View file

@ -91,7 +91,7 @@ describe(MetadataService.name, () => {
await sut.init(); await sut.init();
expect(metadataMock.deleteCache).not.toHaveBeenCalled(); expect(metadataMock.deleteCache).not.toHaveBeenCalled();
expect(jobMock.pause).toHaveBeenCalledTimes(1); expect(jobMock.pause).toHaveBeenCalledTimes(1);
expect(metadataMock.init).toHaveBeenCalledTimes(1); expect(metadataMock.initLocalGeocoding).toHaveBeenCalledTimes(1);
expect(jobMock.resume).toHaveBeenCalledTimes(1); expect(jobMock.resume).toHaveBeenCalledTimes(1);
}); });
@ -100,7 +100,7 @@ describe(MetadataService.name, () => {
expect(metadataMock.deleteCache).not.toHaveBeenCalled(); expect(metadataMock.deleteCache).not.toHaveBeenCalled();
expect(jobMock.pause).toHaveBeenCalledTimes(1); expect(jobMock.pause).toHaveBeenCalledTimes(1);
expect(metadataMock.init).toHaveBeenCalledTimes(1); expect(metadataMock.initLocalGeocoding).toHaveBeenCalledTimes(1);
expect(jobMock.resume).toHaveBeenCalledTimes(1); expect(jobMock.resume).toHaveBeenCalledTimes(1);
}); });
@ -113,7 +113,7 @@ describe(MetadataService.name, () => {
expect(metadataMock.deleteCache).not.toHaveBeenCalled(); expect(metadataMock.deleteCache).not.toHaveBeenCalled();
expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
expect(metadataMock.init).toHaveBeenCalledWith({ citiesFileOverride: CitiesFile.CITIES_1000 }); expect(metadataMock.initLocalGeocoding).toHaveBeenCalledWith({ citiesFileOverride: CitiesFile.CITIES_1000 });
expect(jobMock.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); expect(jobMock.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
}); });
@ -122,7 +122,7 @@ describe(MetadataService.name, () => {
expect(metadataMock.deleteCache).toHaveBeenCalled(); expect(metadataMock.deleteCache).toHaveBeenCalled();
expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
expect(metadataMock.init).toHaveBeenCalledWith({ citiesFileOverride: CitiesFile.CITIES_500 }); expect(metadataMock.initLocalGeocoding).toHaveBeenCalledWith({ citiesFileOverride: CitiesFile.CITIES_500 });
expect(jobMock.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); expect(jobMock.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
}); });
}); });

View file

@ -97,7 +97,17 @@ export class MetadataService {
return; return;
} }
if (reverseGeocoding.useMapbox) {
await this.jobRepository.pause(QueueName.METADATA_EXTRACTION);
await this.repository.initMapboxGeocoding(reverseGeocoding.mapboxAccessToken);
await this.jobRepository.resume(QueueName.METADATA_EXTRACTION);
this.logger.log(`Initialized Mapbox reverse geocoder`);
return;
}
try { try {
this.repository.deinitMapboxGeocoding();
if (deleteCache) { if (deleteCache) {
await this.repository.deleteCache(); await this.repository.deleteCache();
} else if (this.oldCities && this.oldCities === citiesFileOverride) { } else if (this.oldCities && this.oldCities === citiesFileOverride) {
@ -105,7 +115,7 @@ export class MetadataService {
} }
await this.jobRepository.pause(QueueName.METADATA_EXTRACTION); await this.jobRepository.pause(QueueName.METADATA_EXTRACTION);
await this.repository.init({ citiesFileOverride }); await this.repository.initLocalGeocoding({ citiesFileOverride });
await this.jobRepository.resume(QueueName.METADATA_EXTRACTION); await this.jobRepository.resume(QueueName.METADATA_EXTRACTION);
this.logger.log(`Initialized local reverse geocoder with ${citiesFileOverride}`); this.logger.log(`Initialized local reverse geocoder with ${citiesFileOverride}`);

View file

@ -31,7 +31,9 @@ export interface ImmichTags extends Omit<Tags, 'FocalLength' | 'Duration'> {
} }
export interface IMetadataRepository { export interface IMetadataRepository {
init(options: Partial<InitOptions>): Promise<void>; initLocalGeocoding(options: Partial<InitOptions>): Promise<void>;
initMapboxGeocoding(accessToken: string): Promise<void>;
deinitMapboxGeocoding(): void;
teardown(): Promise<void>; teardown(): Promise<void>;
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>; reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>;
deleteCache(): Promise<void>; deleteCache(): Promise<void>;

View file

@ -1,6 +1,33 @@
import { CitiesFile } from '@app/infra/entities'; import { CitiesFile } from '@app/infra/entities';
import mapbox from '@mapbox/mapbox-sdk/services/tokens';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsEnum } from 'class-validator'; import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsString,
Validate,
ValidateIf,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
const isEnabled = (config: SystemConfigReverseGeocodingDto) => config.enabled;
const useMapbox = (config: SystemConfigReverseGeocodingDto) => config.useMapbox;
@ValidatorConstraint({ name: 'mapboxAccessToken', async: false })
class MapboxAccessToken implements ValidatorConstraintInterface {
async validate(text: string) {
const mb = mapbox({ accessToken: text });
const response = await mb.getToken().send();
return response.body.code === 'TokenValid';
}
defaultMessage() {
// here you can provide default error message if validation failed
return 'Token invalid';
}
}
export class SystemConfigReverseGeocodingDto { export class SystemConfigReverseGeocodingDto {
@IsBoolean() @IsBoolean()
@ -9,4 +36,14 @@ export class SystemConfigReverseGeocodingDto {
@IsEnum(CitiesFile) @IsEnum(CitiesFile)
@ApiProperty({ enum: CitiesFile, enumName: 'CitiesFile' }) @ApiProperty({ enum: CitiesFile, enumName: 'CitiesFile' })
citiesFileOverride!: CitiesFile; citiesFileOverride!: CitiesFile;
@IsBoolean()
@ValidateIf(isEnabled)
useMapbox!: boolean;
@ValidateIf(useMapbox)
@IsString()
@IsNotEmpty()
@Validate(MapboxAccessToken)
mapboxAccessToken!: string;
} }

View file

@ -86,6 +86,8 @@ export const defaults = Object.freeze<SystemConfig>({
reverseGeocoding: { reverseGeocoding: {
enabled: true, enabled: true,
citiesFileOverride: CitiesFile.CITIES_500, citiesFileOverride: CitiesFile.CITIES_500,
useMapbox: false,
mapboxAccessToken: '',
}, },
oauth: { oauth: {
enabled: false, enabled: false,

View file

@ -86,6 +86,8 @@ const updatedConfig = Object.freeze<SystemConfig>({
reverseGeocoding: { reverseGeocoding: {
enabled: true, enabled: true,
citiesFileOverride: CitiesFile.CITIES_500, citiesFileOverride: CitiesFile.CITIES_500,
useMapbox: false,
mapboxAccessToken: '',
}, },
oauth: { oauth: {
autoLaunch: true, autoLaunch: true,

View file

@ -67,6 +67,8 @@ export enum SystemConfigKey {
REVERSE_GEOCODING_ENABLED = 'reverseGeocoding.enabled', REVERSE_GEOCODING_ENABLED = 'reverseGeocoding.enabled',
REVERSE_GEOCODING_CITIES_FILE_OVERRIDE = 'reverseGeocoding.citiesFileOverride', REVERSE_GEOCODING_CITIES_FILE_OVERRIDE = 'reverseGeocoding.citiesFileOverride',
REVERSE_GEOCODING_USE_MAPBOX = 'reverseGeocoding.useMapbox',
REVERSE_GEOCODING_MAPBOX_ACCESS_TOKEN = 'reverseGeocoding.mapboxAccessToken',
NEW_VERSION_CHECK_ENABLED = 'newVersionCheck.enabled', NEW_VERSION_CHECK_ENABLED = 'newVersionCheck.enabled',
@ -201,6 +203,8 @@ export interface SystemConfig {
reverseGeocoding: { reverseGeocoding: {
enabled: boolean; enabled: boolean;
citiesFileOverride: CitiesFile; citiesFileOverride: CitiesFile;
useMapbox: boolean;
mapboxAccessToken: string;
}; };
oauth: { oauth: {
enabled: boolean; enabled: boolean;

View file

@ -1,5 +1,7 @@
import { GeoPoint, IMetadataRepository, ImmichTags, ReverseGeocodeResult } from '@app/domain'; import { GeoPoint, IMetadataRepository, ImmichTags, ReverseGeocodeResult } from '@app/domain';
import { REVERSE_GEOCODING_DUMP_DIRECTORY } from '@app/infra'; import { REVERSE_GEOCODING_DUMP_DIRECTORY } from '@app/infra';
import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DefaultReadTaskOptions, exiftool } from 'exiftool-vendored'; import { DefaultReadTaskOptions, exiftool } from 'exiftool-vendored';
import { readdir, rm } from 'fs/promises'; import { readdir, rm } from 'fs/promises';
@ -25,9 +27,23 @@ const lookup = promisify<GeoPoint[], number, AddressObject[][]>(geocoder.lookUp)
@Injectable() @Injectable()
export class MetadataRepository implements IMetadataRepository { export class MetadataRepository implements IMetadataRepository {
private logger = new Logger(MetadataRepository.name); private logger = new Logger(MetadataRepository.name);
private mapboxClient?: GeocodeService = undefined;
async init(options: Partial<InitOptions>): Promise<void> { async initMapboxGeocoding(accessToken: string): Promise<void> {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
this.mapboxClient = mapboxGeocoding({ accessToken });
resolve();
});
}
deinitMapboxGeocoding(): void {
this.mapboxClient = undefined;
}
async initLocalGeocoding(options: Partial<InitOptions>): Promise<void> {
return new Promise<void>((resolve) => {
this.deinitMapboxGeocoding();
geocoder.init( geocoder.init(
{ {
load: { load: {
@ -62,8 +78,14 @@ export class MetadataRepository implements IMetadataRepository {
} }
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> { async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
this.logger.debug(`Request: ${point.latitude},${point.longitude}`); if (this.mapboxClient) {
return this.useMapboxGeocoding(point, this.mapboxClient);
}
return this.useLocalGeocoding(point);
}
private async useLocalGeocoding(point: GeoPoint) {
const [address] = await lookup([point], 1); const [address] = await lookup([point], 1);
this.logger.verbose(`Raw: ${JSON.stringify(address, null, 2)}`); this.logger.verbose(`Raw: ${JSON.stringify(address, null, 2)}`);
@ -76,6 +98,36 @@ export class MetadataRepository implements IMetadataRepository {
return { country, state, city }; return { country, state, city };
} }
private async useMapboxGeocoding(point: GeoPoint, mbClient: GeocodeService) {
const geoCodeInfo: MapiResponse = await mbClient
.reverseGeocode({
query: [point.longitude, point.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'];
}
this.logger.debug(`Mapbox Normalized: ${JSON.stringify({ country, state, city })}`);
return { country, state, city };
}
getExifTags(path: string): Promise<ImmichTags | null> { getExifTags(path: string): Promise<ImmichTags | null> {
return exiftool return exiftool
.read(path, undefined, { .read(path, undefined, {

View file

@ -4,7 +4,9 @@ export const newMetadataRepositoryMock = (): jest.Mocked<IMetadataRepository> =>
return { return {
deleteCache: jest.fn(), deleteCache: jest.fn(),
getExifTags: jest.fn(), getExifTags: jest.fn(),
init: jest.fn(), initLocalGeocoding: jest.fn(),
deinitMapboxGeocoding: jest.fn(),
initMapboxGeocoding: jest.fn(),
teardown: jest.fn(), teardown: jest.fn(),
reverseGeocode: jest.fn(), reverseGeocode: jest.fn(),
}; };

View file

@ -3926,6 +3926,18 @@ export interface SystemConfigReverseGeocodingDto {
* @memberof SystemConfigReverseGeocodingDto * @memberof SystemConfigReverseGeocodingDto
*/ */
'enabled': boolean; 'enabled': boolean;
/**
*
* @type {string}
* @memberof SystemConfigReverseGeocodingDto
*/
'mapboxAccessToken': string;
/**
*
* @type {boolean}
* @memberof SystemConfigReverseGeocodingDto
*/
'useMapbox': boolean;
} }

View file

@ -40,6 +40,8 @@
reverseGeocoding: { reverseGeocoding: {
enabled: config.reverseGeocoding.enabled, enabled: config.reverseGeocoding.enabled,
citiesFileOverride: config.reverseGeocoding.citiesFileOverride, citiesFileOverride: config.reverseGeocoding.citiesFileOverride,
useMapbox: config.reverseGeocoding.enabled ? config.reverseGeocoding.useMapbox : false,
mapboxAccessToken: config.reverseGeocoding.enabled ? config.reverseGeocoding.mapboxAccessToken : '',
}, },
}, },
}); });
@ -149,8 +151,26 @@
isEdited={config.reverseGeocoding.citiesFileOverride !== isEdited={config.reverseGeocoding.citiesFileOverride !==
savedConfig.reverseGeocoding.citiesFileOverride} savedConfig.reverseGeocoding.citiesFileOverride}
/> />
</div></SettingAccordion
> <hr />
<SettingSwitch
title="Use Mapbox"
disabled={!config.reverseGeocoding.enabled}
subtitle="Use Mapbox for reverse geocoding"
bind:checked={config.reverseGeocoding.useMapbox}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="Mapbox Access Token"
bind:value={config.reverseGeocoding.mapboxAccessToken}
required={true}
disabled={!config.reverseGeocoding.enabled || !config.reverseGeocoding.useMapbox}
isEdited={config.reverseGeocoding.mapboxAccessToken !== savedConfig.reverseGeocoding.mapboxAccessToken}
/>
</div>
</SettingAccordion>
<SettingButtonsRow <SettingButtonsRow
on:reset={reset} on:reset={reset}