mirror of
https://github.com/immich-app/immich.git
synced 2024-11-24 08:52:28 +02:00
feat(server): country geocoding for remote locations (#10950)
Co-authored-by: Zack Pollard <zackpollard@ymail.com> Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
parent
ee22bbc85c
commit
4f89195702
@ -507,6 +507,22 @@ describe('/asset', () => {
|
|||||||
expect(status).toEqual(200);
|
expect(status).toEqual(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should geocode country from gps data in the middle of nowhere', async () => {
|
||||||
|
const { status } = await request(app)
|
||||||
|
.put(`/assets/${user1Assets[0].id}`)
|
||||||
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
|
.send({ latitude: 42, longitude: 69 });
|
||||||
|
expect(status).toEqual(200);
|
||||||
|
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||||
|
|
||||||
|
const asset = await getAssetInfo({ id: user1Assets[0].id }, { headers: asBearerAuth(user1.accessToken) });
|
||||||
|
expect(asset).toMatchObject({
|
||||||
|
id: user1Assets[0].id,
|
||||||
|
exifInfo: expect.objectContaining({ city: null, country: 'Kazakhstan' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should set the description', async () => {
|
it('should set the description', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.put(`/assets/${user1Assets[0].id}`)
|
.put(`/assets/${user1Assets[0].id}`)
|
||||||
|
@ -43,6 +43,7 @@ export const resourcePaths = {
|
|||||||
admin1: join(folders.geodata, 'admin1CodesASCII.txt'),
|
admin1: join(folders.geodata, 'admin1CodesASCII.txt'),
|
||||||
admin2: join(folders.geodata, 'admin2Codes.txt'),
|
admin2: join(folders.geodata, 'admin2Codes.txt'),
|
||||||
cities500: join(folders.geodata, citiesFile),
|
cities500: join(folders.geodata, citiesFile),
|
||||||
|
naturalEarthCountriesPath: join(folders.geodata, 'ne_10m_admin_0_countries.geojson'),
|
||||||
},
|
},
|
||||||
web: {
|
web: {
|
||||||
root: folders.web,
|
root: folders.web,
|
||||||
|
@ -12,6 +12,7 @@ import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
|
|||||||
import { LibraryEntity } from 'src/entities/library.entity';
|
import { LibraryEntity } from 'src/entities/library.entity';
|
||||||
import { MemoryEntity } from 'src/entities/memory.entity';
|
import { MemoryEntity } from 'src/entities/memory.entity';
|
||||||
import { MoveEntity } from 'src/entities/move.entity';
|
import { MoveEntity } from 'src/entities/move.entity';
|
||||||
|
import { NaturalEarthCountriesEntity } from 'src/entities/natural-earth-countries.entity';
|
||||||
import { PartnerEntity } from 'src/entities/partner.entity';
|
import { PartnerEntity } from 'src/entities/partner.entity';
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
import { PersonEntity } from 'src/entities/person.entity';
|
||||||
import { SessionEntity } from 'src/entities/session.entity';
|
import { SessionEntity } from 'src/entities/session.entity';
|
||||||
@ -36,6 +37,7 @@ export const entities = [
|
|||||||
ExifEntity,
|
ExifEntity,
|
||||||
FaceSearchEntity,
|
FaceSearchEntity,
|
||||||
GeodataPlacesEntity,
|
GeodataPlacesEntity,
|
||||||
|
NaturalEarthCountriesEntity,
|
||||||
MemoryEntity,
|
MemoryEntity,
|
||||||
MoveEntity,
|
MoveEntity,
|
||||||
PartnerEntity,
|
PartnerEntity,
|
||||||
|
19
server/src/entities/natural-earth-countries.entity.ts
Normal file
19
server/src/entities/natural-earth-countries.entity.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('naturalearth_countries', { synchronize: false })
|
||||||
|
export class NaturalEarthCountriesEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50 })
|
||||||
|
admin!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 3 })
|
||||||
|
admin_a3!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50 })
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'polygon' })
|
||||||
|
coordinates!: string;
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class NaturalEarthCountries1720375641148 implements MigrationInterface {
|
||||||
|
name = 'NaturalEarthCountries1720375641148'
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`CREATE TABLE "naturalearth_countries" ("id" SERIAL NOT NULL, "admin" character varying(50) NOT NULL, "admin_a3" character varying(3) NOT NULL, "type" character varying(50) NOT NULL, "coordinates" polygon NOT NULL, CONSTRAINT "PK_21a6d86d1ab5d841648212e5353" PRIMARY KEY ("id"))`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE "naturalearth_countries"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -7,6 +7,7 @@ import readLine from 'node:readline';
|
|||||||
import { citiesFile, resourcePaths } from 'src/constants';
|
import { citiesFile, resourcePaths } from 'src/constants';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
|
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
|
||||||
|
import { NaturalEarthCountriesEntity } from 'src/entities/natural-earth-countries.entity';
|
||||||
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
|
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import {
|
import {
|
||||||
@ -28,6 +29,8 @@ export class MapRepository implements IMapRepository {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||||
@InjectRepository(GeodataPlacesEntity) private geodataPlacesRepository: Repository<GeodataPlacesEntity>,
|
@InjectRepository(GeodataPlacesEntity) private geodataPlacesRepository: Repository<GeodataPlacesEntity>,
|
||||||
|
@InjectRepository(NaturalEarthCountriesEntity)
|
||||||
|
private naturalEarthCountriesRepository: Repository<NaturalEarthCountriesEntity>,
|
||||||
@InjectDataSource() private dataSource: DataSource,
|
@InjectDataSource() private dataSource: DataSource,
|
||||||
@Inject(ISystemMetadataRepository) private metadataRepository: ISystemMetadataRepository,
|
@Inject(ISystemMetadataRepository) private metadataRepository: ISystemMetadataRepository,
|
||||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||||
@ -46,6 +49,7 @@ export class MapRepository implements IMapRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.importGeodata();
|
await this.importGeodata();
|
||||||
|
await this.importNaturalEarthCountries();
|
||||||
|
|
||||||
await this.metadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, {
|
await this.metadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, {
|
||||||
lastUpdate: geodataDate,
|
lastUpdate: geodataDate,
|
||||||
@ -130,22 +134,93 @@ export class MapRepository implements IMapRepository {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
if (!response) {
|
if (response) {
|
||||||
|
this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`);
|
||||||
|
|
||||||
|
const { countryCode, name: city, admin1Name } = response;
|
||||||
|
const country = getName(countryCode, 'en') ?? null;
|
||||||
|
const state = admin1Name;
|
||||||
|
|
||||||
|
return { country, state, city };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.warn(
|
||||||
|
`Response from database for reverse geocoding latitude: ${point.latitude}, longitude: ${point.longitude} was null`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const ne_response = await this.naturalEarthCountriesRepository
|
||||||
|
.createQueryBuilder('naturalearth_countries')
|
||||||
|
.where('coordinates @> point (:longitude, :latitude)', point)
|
||||||
|
.limit(1)
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!ne_response) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Response from database for reverse geocoding latitude: ${point.latitude}, longitude: ${point.longitude} was null`,
|
`Response from database for natural earth reverse geocoding latitude: ${point.latitude}, longitude: ${point.longitude} was null`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`);
|
this.logger.verbose(`Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`);
|
||||||
|
|
||||||
const { countryCode, name: city, admin1Name } = response;
|
const { admin_a3 } = ne_response;
|
||||||
const country = getName(countryCode, 'en') ?? null;
|
const country = getName(admin_a3, 'en') ?? null;
|
||||||
const state = admin1Name;
|
const state = null;
|
||||||
|
const city = null;
|
||||||
|
|
||||||
return { country, state, city };
|
return { country, state, city };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private transformCoordinatesToPolygon(coordinates: number[][][]): string {
|
||||||
|
const pointsString = coordinates.map((point) => `(${point[0]},${point[1]})`).join(', ');
|
||||||
|
return `(${pointsString})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async importNaturalEarthCountries() {
|
||||||
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
|
await queryRunner.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queryRunner.startTransaction();
|
||||||
|
await queryRunner.manager.clear(NaturalEarthCountriesEntity);
|
||||||
|
|
||||||
|
const fileContent = await readFile(resourcePaths.geodata.naturalEarthCountriesPath, 'utf8');
|
||||||
|
const geoJSONData = JSON.parse(fileContent);
|
||||||
|
|
||||||
|
if (geoJSONData.type !== 'FeatureCollection' || !Array.isArray(geoJSONData.features)) {
|
||||||
|
this.logger.fatal('Invalid GeoJSON FeatureCollection');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const feature of geoJSONData.features) {
|
||||||
|
for (const polygon of feature.geometry.coordinates) {
|
||||||
|
const featureRecord = new NaturalEarthCountriesEntity();
|
||||||
|
featureRecord.admin = feature.properties.ADMIN;
|
||||||
|
featureRecord.admin_a3 = feature.properties.ADM0_A3;
|
||||||
|
featureRecord.type = feature.properties.TYPE;
|
||||||
|
|
||||||
|
if (feature.geometry.type === 'MultiPolygon') {
|
||||||
|
featureRecord.coordinates = this.transformCoordinatesToPolygon(polygon[0]);
|
||||||
|
await queryRunner.manager.save(featureRecord);
|
||||||
|
} else if (feature.geometry.type === 'Polygon') {
|
||||||
|
featureRecord.coordinates = this.transformCoordinatesToPolygon(polygon);
|
||||||
|
await queryRunner.manager.save(featureRecord);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.commitTransaction();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.fatal('Error importing natural earth country data', error);
|
||||||
|
await queryRunner.rollbackTransaction();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async importGeodata() {
|
private async importGeodata() {
|
||||||
const queryRunner = this.dataSource.createQueryRunner();
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
await queryRunner.connect();
|
await queryRunner.connect();
|
||||||
|
@ -26,7 +26,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if asset.exifInfo?.city}
|
{#if asset.exifInfo?.country}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full text-left justify-between place-items-start gap-4 py-4"
|
class="flex w-full text-left justify-between place-items-start gap-4 py-4"
|
||||||
@ -39,7 +39,9 @@
|
|||||||
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
|
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p>{asset.exifInfo.city}</p>
|
{#if asset.exifInfo?.city}
|
||||||
|
<p>{asset.exifInfo.city}</p>
|
||||||
|
{/if}
|
||||||
{#if asset.exifInfo?.state}
|
{#if asset.exifInfo?.state}
|
||||||
<div class="flex gap-2 text-sm">
|
<div class="flex gap-2 text-sm">
|
||||||
<p>{asset.exifInfo.state}</p>
|
<p>{asset.exifInfo.state}</p>
|
||||||
|
Loading…
Reference in New Issue
Block a user