Add reverse geocoding and show asset location on map in detail view (#43)

* Added reserve geocoding, location in search suggestion, and search by location
* Added mapbox sdk to app
* Added mapbox to image detailed view
This commit is contained in:
Alex 2022-03-10 16:09:03 -06:00 committed by GitHub
parent 251c92ff1e
commit 026f3c24e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 12112 additions and 184 deletions

View File

@ -38,6 +38,7 @@ This project is under heavy development, there will be continous functions, feat
- Image Tagging/Classification based on ImageNet dataset - Image Tagging/Classification based on ImageNet dataset
- Search assets based on tags and exif data (lens, make, model, orientation) - Search assets based on tags and exif data (lens, make, model, orientation)
- Upload assets from your local computer/server using [immich cli tools](https://www.npmjs.com/package/immich) - Upload assets from your local computer/server using [immich cli tools](https://www.npmjs.com/package/immich)
- Geocoding to show asset's location information on map (required MapBox registration for their generous free tier)
# Development # Development
@ -59,16 +60,12 @@ cp .env.example .env
Then populate the value in there. Then populate the value in there.
Notice that if set `ENABLE_MAPBOX` to `true`, you will have to provide `MAPBOX_KEY` for the server to run.
Pay attention to the key `UPLOAD_LOCATION`, this directory must exist and is owned by the user that run the `docker-compose` command below. Pay attention to the key `UPLOAD_LOCATION`, this directory must exist and is owned by the user that run the `docker-compose` command below.
To start, run To start, run
```bash
docker-compose -f ./docker/docker-compose.yml up
```
To force rebuild node modules after installing new packages
```bash ```bash
docker-compose -f ./docker/docker-compose.yml up --build -V docker-compose -f ./docker/docker-compose.yml up --build -V
``` ```

View File

@ -10,4 +10,9 @@ DB_DATABASE_NAME=
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
# JWT SECRET # JWT SECRET
JWT_SECRET= JWT_SECRET=
# MAPBOX
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
ENABLE_MAPBOX=
MAPBOX_KEY=

View File

@ -44,7 +44,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.example.immich_mobile" applicationId "com.example.immich_mobile"
minSdkVersion flutter.minSdkVersion minSdkVersion 20
targetSdkVersion flutter.targetSdkVersion targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName

View File

@ -1,39 +1,23 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.immich_mobile">
package="com.example.immich_mobile"> <application android:label="Immich" android:name="${applicationName}" android:usesCleartextTraffic="true" android:icon="@mipmap/ic_launcher">
<application <activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
android:label="Immich" <!-- Specifies an Android theme to apply to this Activity as soon as
android:name="${applicationName}"
android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. --> to determine the Window background behind the Flutter UI. -->
<meta-data <meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" />
android:name="io.flutter.embedding.android.NormalTheme" <intent-filter>
android:resource="@style/NormalTheme" <action android:name="android.intent.action.MAIN" />
/> <category android:name="android.intent.category.LAUNCHER" />
<intent-filter> </intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2"/>
</activity>
</application> <!-- Don't delete the meta-data below.
<uses-permission android:name="android.permission.INTERNET"/> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
</manifest> <meta-data android:name="flutterEmbedding" android:value="2" />
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
</manifest>

View File

@ -1,3 +1,3 @@
org.gradle.jvmargs=-Xmx1536M org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -34,8 +34,19 @@ target 'Runner' do
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end end
post_install do |installer| # post_install do |installer|
installer.pods_project.targets.each do |target| # installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target) # flutter_additional_ios_build_settings(target)
end # end
# end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings["EXCLUDED_ARCHS[sdk=iphonesimulator*]"] = "arm64"
config.build_settings['ENABLE_BITCODE'] = 'YES'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '11.0'
end
end
end end

View File

@ -8,6 +8,15 @@ PODS:
- FMDB (2.7.5): - FMDB (2.7.5):
- FMDB/standard (= 2.7.5) - FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5) - FMDB/standard (2.7.5)
- Mapbox-iOS-SDK (6.4.1):
- MapboxMobileEvents (~> 0.10.12)
- mapbox_gl (0.0.1):
- Flutter
- Mapbox-iOS-SDK (~> 6.4.0)
- MapboxAnnotationExtension (~> 0.0.1-beta.1)
- MapboxAnnotationExtension (0.0.1-beta.2):
- Mapbox-iOS-SDK (~> 6.0)
- MapboxMobileEvents (0.10.14)
- path_provider_ios (0.0.1): - path_provider_ios (0.0.1):
- Flutter - Flutter
- photo_manager (1.0.0): - photo_manager (1.0.0):
@ -26,6 +35,7 @@ DEPENDENCIES:
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- mapbox_gl (from `.symlinks/plugins/mapbox_gl/ios`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`)
@ -35,6 +45,9 @@ DEPENDENCIES:
SPEC REPOS: SPEC REPOS:
trunk: trunk:
- FMDB - FMDB
- Mapbox-iOS-SDK
- MapboxAnnotationExtension
- MapboxMobileEvents
- Toast - Toast
EXTERNAL SOURCES: EXTERNAL SOURCES:
@ -44,6 +57,8 @@ EXTERNAL SOURCES:
:path: Flutter :path: Flutter
fluttertoast: fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios" :path: ".symlinks/plugins/fluttertoast/ios"
mapbox_gl:
:path: ".symlinks/plugins/mapbox_gl/ios"
path_provider_ios: path_provider_ios:
:path: ".symlinks/plugins/path_provider_ios/ios" :path: ".symlinks/plugins/path_provider_ios/ios"
photo_manager: photo_manager:
@ -60,6 +75,10 @@ SPEC CHECKSUMS:
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
fluttertoast: 6122fa75143e992b1d3470f61000f591a798cc58 fluttertoast: 6122fa75143e992b1d3470f61000f591a798cc58
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
Mapbox-iOS-SDK: f870f83cbdc7aa4a74afcee143aafb0dae390c82
mapbox_gl: 33c5ab6306cbfa72289bb3606d2cd2e8baee9ff0
MapboxAnnotationExtension: 4eee6c26349ef6d909f1a23a7eae2d0f7ca5fa7d
MapboxMobileEvents: 5a172cc9bbf8ac0e45ba86095cbee685ede248cc
path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5 path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5
photo_manager: 84fa94fbeb82e607333ea9a13c43b58e0903a463 photo_manager: 84fa94fbeb82e607333ea9a13c43b58e0903a463
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
@ -67,6 +86,6 @@ SPEC CHECKSUMS:
video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c PODFILE CHECKSUM: a44d1ba6d6faf8c61ee449ab69176b941340b431
COCOAPODS: 1.10.1 COCOAPODS: 1.10.1

View File

@ -341,6 +341,7 @@
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0; IPHONEOS_DEPLOYMENT_TARGET = 9.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
NEW_SETTING = "";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos; SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@ -419,6 +420,7 @@
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0; IPHONEOS_DEPLOYMENT_TARGET = 9.0;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
NEW_SETTING = "";
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@ -468,6 +470,7 @@
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0; IPHONEOS_DEPLOYMENT_TARGET = 9.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
NEW_SETTING = "";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos; SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;

View File

@ -43,20 +43,27 @@
</array> </array>
<key>UIUserInterfaceStyle</key> <key>UIUserInterfaceStyle</key>
<string>Light</string> <string>Light</string>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true /> <true />
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string> <string>We need to manage backup your photos album</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<true/> <true />
</dict> </dict>
<key>io.flutter.embedded_views_preview</key>
<true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true />
<key>NSLocationWhenInUseUsageDescription</key>
<string>Enable location setting to show position of assets on map</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>Enable location setting to show position of assets on map</string>
</dict> </dict>
</plist> </plist>

View File

@ -1,7 +1,12 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart'; import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:mapbox_gl/mapbox_gl.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
class ExifBottomSheet extends ConsumerWidget { class ExifBottomSheet extends ConsumerWidget {
@ -11,6 +16,54 @@ class ExifBottomSheet extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
_buildMap() {
return ref.watch(serverInfoProvider).mapboxInfo.isEnable
? Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container(
height: 150,
width: MediaQuery.of(context).size.width,
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(15)),
),
child: MapboxMap(
doubleClickZoomEnabled: false,
zoomGesturesEnabled: true,
scrollGesturesEnabled: false,
accessToken: ref.watch(serverInfoProvider).mapboxInfo.mapboxSecret,
styleString: 'mapbox://styles/mapbox/streets-v11',
initialCameraPosition: CameraPosition(
zoom: 15.0,
target: LatLng(assetDetail.exifInfo!.latitude!, assetDetail.exifInfo!.longitude!),
),
onMapCreated: (MapboxMapController mapController) async {
final ByteData bytes = await rootBundle.load("assets/location-pin.png");
final Uint8List list = bytes.buffer.asUint8List();
await mapController.addImage("assetImage", list);
await mapController.addSymbol(
SymbolOptions(
geometry: LatLng(assetDetail.exifInfo!.latitude!, assetDetail.exifInfo!.longitude!),
iconImage: "assetImage",
iconSize: 0.2,
),
);
},
),
),
)
: Container();
}
_buildLocationText() {
return (assetDetail.exifInfo!.city != null && assetDetail.exifInfo!.state != null)
? Text(
"${assetDetail.exifInfo!.city}, ${assetDetail.exifInfo!.state}",
style: TextStyle(fontSize: 12, color: Colors.grey[200], fontWeight: FontWeight.bold),
)
: Container();
}
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8),
child: ListView( child: ListView(
@ -53,9 +106,11 @@ class ExifBottomSheet extends ConsumerWidget {
"LOCATION", "LOCATION",
style: TextStyle(fontSize: 11, color: Colors.grey[400]), style: TextStyle(fontSize: 11, color: Colors.grey[400]),
), ),
_buildMap(),
_buildLocationText(),
Text( Text(
"${assetDetail.exifInfo!.latitude!.toStringAsFixed(4)}, ${assetDetail.exifInfo!.longitude!.toStringAsFixed(4)}", "${assetDetail.exifInfo!.latitude!.toStringAsFixed(4)}, ${assetDetail.exifInfo!.longitude!.toStringAsFixed(4)}",
style: TextStyle(fontSize: 11, color: Colors.grey[400]), style: TextStyle(fontSize: 12, color: Colors.grey[400]),
) )
], ],
), ),
@ -89,8 +144,10 @@ class ExifBottomSheet extends ConsumerWidget {
"${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}", "${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}",
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
subtitle: Text( subtitle: assetDetail.exifInfo?.exifImageHeight != null
"${assetDetail.exifInfo?.exifImageHeight!} x ${assetDetail.exifInfo?.exifImageWidth!} ${assetDetail.exifInfo?.fileSizeInByte!}B "), ? Text(
"${assetDetail.exifInfo?.exifImageHeight} x ${assetDetail.exifInfo?.exifImageWidth} ${assetDetail.exifInfo?.fileSizeInByte!}B ")
: Container(),
), ),
assetDetail.exifInfo?.make != null assetDetail.exifInfo?.make != null
? ListTile( ? ListTile(

View File

@ -29,9 +29,9 @@ class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
iconSize: iconSize, iconSize: iconSize,
splashRadius: iconSize, splashRadius: iconSize,
onPressed: () { onPressed: () {
print("backup"); print("download");
}, },
icon: const Icon(Icons.backup_outlined), icon: const Icon(Icons.cloud_download_rounded),
), ),
IconButton( IconButton(
iconSize: iconSize, iconSize: iconSize,

View File

@ -11,6 +11,7 @@ import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart'; import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer.dart'; import 'package:immich_mobile/modules/home/ui/profile_drawer.dart';
import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:sliver_tools/sliver_tools.dart'; import 'package:sliver_tools/sliver_tools.dart';
@ -28,6 +29,7 @@ class HomePage extends HookConsumerWidget {
useEffect(() { useEffect(() {
ref.read(websocketProvider.notifier).connect(); ref.read(websocketProvider.notifier).connect();
ref.read(assetProvider.notifier).getAllAsset(); ref.read(assetProvider.notifier).getAllAsset();
ref.read(serverInfoProvider.notifier).getMapboxInfo();
return null; return null;
}, []); }, []);

View File

@ -19,6 +19,9 @@ class ImmichExif {
final double? exposureTime; final double? exposureTime;
final double? latitude; final double? latitude;
final double? longitude; final double? longitude;
final String? city;
final String? state;
final String? country;
ImmichExif({ ImmichExif({
this.id, this.id,
@ -39,6 +42,9 @@ class ImmichExif {
this.exposureTime, this.exposureTime,
this.latitude, this.latitude,
this.longitude, this.longitude,
this.city,
this.state,
this.country,
}); });
ImmichExif copyWith({ ImmichExif copyWith({
@ -60,6 +66,9 @@ class ImmichExif {
double? exposureTime, double? exposureTime,
double? latitude, double? latitude,
double? longitude, double? longitude,
String? city,
String? state,
String? country,
}) { }) {
return ImmichExif( return ImmichExif(
id: id ?? this.id, id: id ?? this.id,
@ -80,6 +89,9 @@ class ImmichExif {
exposureTime: exposureTime ?? this.exposureTime, exposureTime: exposureTime ?? this.exposureTime,
latitude: latitude ?? this.latitude, latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude, longitude: longitude ?? this.longitude,
city: city ?? this.city,
state: state ?? this.state,
country: country ?? this.country,
); );
} }
@ -103,6 +115,9 @@ class ImmichExif {
'exposureTime': exposureTime, 'exposureTime': exposureTime,
'latitude': latitude, 'latitude': latitude,
'longitude': longitude, 'longitude': longitude,
'city': city,
'state': state,
'country': country,
}; };
} }
@ -126,6 +141,9 @@ class ImmichExif {
exposureTime: map['exposureTime']?.toDouble(), exposureTime: map['exposureTime']?.toDouble(),
latitude: map['latitude']?.toDouble(), latitude: map['latitude']?.toDouble(),
longitude: map['longitude']?.toDouble(), longitude: map['longitude']?.toDouble(),
city: map['city'],
state: map['state'],
country: map['country'],
); );
} }
@ -135,7 +153,7 @@ class ImmichExif {
@override @override
String toString() { String toString() {
return 'ImmichExif(id: $id, assetId: $assetId, make: $make, model: $model, imageName: $imageName, exifImageWidth: $exifImageWidth, exifImageHeight: $exifImageHeight, fileSizeInByte: $fileSizeInByte, orientation: $orientation, dateTimeOriginal: $dateTimeOriginal, modifyDate: $modifyDate, lensModel: $lensModel, fNumber: $fNumber, focalLength: $focalLength, iso: $iso, exposureTime: $exposureTime, latitude: $latitude, longitude: $longitude)'; return 'ImmichExif(id: $id, assetId: $assetId, make: $make, model: $model, imageName: $imageName, exifImageWidth: $exifImageWidth, exifImageHeight: $exifImageHeight, fileSizeInByte: $fileSizeInByte, orientation: $orientation, dateTimeOriginal: $dateTimeOriginal, modifyDate: $modifyDate, lensModel: $lensModel, fNumber: $fNumber, focalLength: $focalLength, iso: $iso, exposureTime: $exposureTime, latitude: $latitude, longitude: $longitude, city: $city, state: $state, country: $country)';
} }
@override @override
@ -160,7 +178,10 @@ class ImmichExif {
other.iso == iso && other.iso == iso &&
other.exposureTime == exposureTime && other.exposureTime == exposureTime &&
other.latitude == latitude && other.latitude == latitude &&
other.longitude == longitude; other.longitude == longitude &&
other.city == city &&
other.state == state &&
other.country == country;
} }
@override @override
@ -182,6 +203,9 @@ class ImmichExif {
iso.hashCode ^ iso.hashCode ^
exposureTime.hashCode ^ exposureTime.hashCode ^
latitude.hashCode ^ latitude.hashCode ^
longitude.hashCode; longitude.hashCode ^
city.hashCode ^
state.hashCode ^
country.hashCode;
} }
} }

View File

@ -0,0 +1,51 @@
import 'dart:convert';
class MapboxInfo {
final bool isEnable;
final String mapboxSecret;
MapboxInfo({
required this.isEnable,
required this.mapboxSecret,
});
MapboxInfo copyWith({
bool? isEnable,
String? mapboxSecret,
}) {
return MapboxInfo(
isEnable: isEnable ?? this.isEnable,
mapboxSecret: mapboxSecret ?? this.mapboxSecret,
);
}
Map<String, dynamic> toMap() {
return {
'isEnable': isEnable,
'mapboxSecret': mapboxSecret,
};
}
factory MapboxInfo.fromMap(Map<String, dynamic> map) {
return MapboxInfo(
isEnable: map['isEnable'] ?? false,
mapboxSecret: map['mapboxSecret'] ?? '',
);
}
String toJson() => json.encode(toMap());
factory MapboxInfo.fromJson(String source) => MapboxInfo.fromMap(json.decode(source));
@override
String toString() => 'MapboxInfo(isEnable: $isEnable, mapboxSecret: $mapboxSecret)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is MapboxInfo && other.isEnable == isEnable && other.mapboxSecret == mapboxSecret;
}
@override
int get hashCode => isEnable.hashCode ^ mapboxSecret.hashCode;
}

View File

@ -0,0 +1,71 @@
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/mapbox_info.model.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart';
class ServerInfoState {
final MapboxInfo mapboxInfo;
ServerInfoState({
required this.mapboxInfo,
});
ServerInfoState copyWith({
MapboxInfo? mapboxInfo,
}) {
return ServerInfoState(
mapboxInfo: mapboxInfo ?? this.mapboxInfo,
);
}
Map<String, dynamic> toMap() {
return {
'mapboxInfo': mapboxInfo.toMap(),
};
}
factory ServerInfoState.fromMap(Map<String, dynamic> map) {
return ServerInfoState(
mapboxInfo: MapboxInfo.fromMap(map['mapboxInfo']),
);
}
String toJson() => json.encode(toMap());
factory ServerInfoState.fromJson(String source) => ServerInfoState.fromMap(json.decode(source));
@override
String toString() => 'ServerInfoState(mapboxInfo: $mapboxInfo)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ServerInfoState && other.mapboxInfo == mapboxInfo;
}
@override
int get hashCode => mapboxInfo.hashCode;
}
class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
ServerInfoNotifier()
: super(
ServerInfoState(
mapboxInfo: MapboxInfo(isEnable: false, mapboxSecret: ""),
),
);
final ServerInfoService _serverInfoService = ServerInfoService();
getMapboxInfo() async {
MapboxInfo mapboxInfoRes = await _serverInfoService.getMapboxInfo();
print(mapboxInfoRes);
state = state.copyWith(mapboxInfo: mapboxInfoRes);
}
}
final serverInfoProvider = StateNotifierProvider<ServerInfoNotifier, ServerInfoState>((ref) {
return ServerInfoNotifier();
});

View File

@ -1,6 +1,5 @@
import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:immich_mobile/shared/models/mapbox_info.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart'; import 'package:immich_mobile/shared/services/network.service.dart';
import 'package:immich_mobile/shared/models/server_info.model.dart'; import 'package:immich_mobile/shared/models/server_info.model.dart';
@ -12,4 +11,10 @@ class ServerInfoService {
return ServerInfo.fromJson(response.toString()); return ServerInfo.fromJson(response.toString());
} }
Future<MapboxInfo> getMapboxInfo() async {
Response response = await _networkService.getRequest(url: 'server-info/mapbox');
return MapboxInfo.fromJson(response.toString());
}
} }

View File

@ -513,6 +513,34 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.2" version: "1.0.2"
mapbox_gl:
dependency: "direct main"
description:
name: mapbox_gl
url: "https://pub.dartlang.org"
source: hosted
version: "0.15.0"
mapbox_gl_dart:
dependency: transitive
description:
name: mapbox_gl_dart
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.1"
mapbox_gl_platform_interface:
dependency: transitive
description:
name: mapbox_gl_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "0.15.0"
mapbox_gl_web:
dependency: transitive
description:
name: mapbox_gl_web
url: "https://pub.dartlang.org"
source: hosted
version: "0.15.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:

View File

@ -34,7 +34,8 @@ dependencies:
badges: ^2.0.2 badges: ^2.0.2
photo_view: ^0.13.0 photo_view: ^0.13.0
socket_io_client: ^2.0.0-beta.4-nullsafety.0 socket_io_client: ^2.0.0-beta.4-nullsafety.0
mapbox_gl: ^0.15.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter

11715
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,7 @@
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js" "typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js"
}, },
"dependencies": { "dependencies": {
"@mapbox/mapbox-sdk": "^0.13.3",
"@nestjs/bull": "^0.4.2", "@nestjs/bull": "^0.4.2",
"@nestjs/common": "^8.0.0", "@nestjs/common": "^8.0.0",
"@nestjs/config": "^1.1.6", "@nestjs/config": "^1.1.6",

View File

@ -249,7 +249,7 @@ export class AssetService {
const possibleSearchTerm = new Set<String>(); const possibleSearchTerm = new Set<String>();
const rows = await this.assetRepository.query( const rows = await this.assetRepository.query(
` `
select distinct si.tags, e.orientation, e."lensModel", e.make, e.model , a.type select distinct si.tags, e.orientation, e."lensModel", e.make, e.model , a.type, e.city, e.state, e.country
from assets a from assets a
left join exif e on a.id = e."assetId" left join exif e on a.id = e."assetId"
left join smart_info si on a.id = si."assetId" left join smart_info si on a.id = si."assetId"
@ -274,6 +274,11 @@ export class AssetService {
// Make and model // Make and model
possibleSearchTerm.add(row['make']?.toLowerCase()); possibleSearchTerm.add(row['make']?.toLowerCase());
possibleSearchTerm.add(row['model']?.toLowerCase()); possibleSearchTerm.add(row['model']?.toLowerCase());
// Location
possibleSearchTerm.add(row['city']?.toLowerCase());
possibleSearchTerm.add(row['state']?.toLowerCase());
possibleSearchTerm.add(row['country']?.toLowerCase());
}); });
return Array.from(possibleSearchTerm).filter((x) => x != null); return Array.from(possibleSearchTerm).filter((x) => x != null);

View File

@ -61,6 +61,15 @@ export class ExifEntity {
@Column({ type: 'float', nullable: true }) @Column({ type: 'float', nullable: true })
longitude: number; longitude: number;
@Column({ nullable: true })
city: string;
@Column({ nullable: true })
state: string;
@Column({ nullable: true })
country: string;
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true }) @OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' }) @JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
asset: ExifEntity; asset: ExifEntity;

View File

@ -1,9 +1,14 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { ServerInfoService } from './server-info.service'; import { ServerInfoService } from './server-info.service';
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
@Controller('server-info') @Controller('server-info')
export class ServerInfoController { export class ServerInfoController {
constructor(private readonly serverInfoService: ServerInfoService) {} constructor(private readonly serverInfoService: ServerInfoService, private readonly configService: ConfigService) {}
@Get() @Get()
async getServerInfo() { async getServerInfo() {
@ -16,4 +21,13 @@ export class ServerInfoController {
res: 'pong', res: 'pong',
}; };
} }
@UseGuards(JwtAuthGuard)
@Get('/mapbox')
async getMapboxInfo() {
return {
isEnable: this.configService.get('ENABLE_MAPBOX'),
mapboxSecret: this.configService.get('MAPBOX_KEY'),
};
}
} }

View File

@ -6,29 +6,4 @@ import { UpdateUserDto } from './dto/update-user.dto';
@Controller('user') @Controller('user')
export class UserController { export class UserController {
constructor(private readonly userService: UserService) {} constructor(private readonly userService: UserService) {}
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.userService.create(createUserDto);
}
@Get()
findAll() {
return this.userService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.userService.findOne(+id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
return this.userService.update(+id, updateUserDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.userService.remove(+id);
}
} }

View File

@ -11,22 +11,4 @@ export class UserService {
@InjectRepository(UserEntity) @InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>, private userRepository: Repository<UserEntity>,
) {} ) {}
create(createUserDto: CreateUserDto) {
return 'This action adds a new user';
}
async findAll() {}
findOne(id: number) {
return `This action returns a #${id} user`;
}
update(id: number, updateUserDto: UpdateUserDto) {
return `This action updates a #${id} user`;
}
remove(id: number) {
return `This action removes a #${id} user`;
}
} }

View File

@ -11,5 +11,11 @@ export const immichAppConfig: ConfigModuleOptions = {
DB_DATABASE_NAME: Joi.string().required(), DB_DATABASE_NAME: Joi.string().required(),
UPLOAD_LOCATION: Joi.string().required(), UPLOAD_LOCATION: Joi.string().required(),
JWT_SECRET: Joi.string().required(), JWT_SECRET: Joi.string().required(),
ENABLE_MAPBOX: Joi.boolean().required().valid(true, false),
MAPBOX_KEY: Joi.any().when('ENABLE_MAPBOX', {
is: true,
then: Joi.string().required(),
otherwise: Joi.string().optional,
}),
}), }),
}; };

View File

@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRegionCityToExIf1646709533213 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
ADD COLUMN city varchar;
ALTER TABLE exif
ADD COLUMN state varchar;
ALTER TABLE exif
ADD COLUMN country varchar;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
DROP COLUMN city;
ALTER TABLE exif
DROP COLUMN state;
ALTER TABLE exif
DROP COLUMN country;
`);
}
}

View File

@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLocationToExifTextSearch1646710459852 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
DROP COLUMN IF EXISTS exif_text_searchable_column;
ALTER TABLE exif
ADD COLUMN IF NOT EXISTS exif_text_searchable_column tsvector
GENERATED ALWAYS AS (
TO_TSVECTOR('english',
COALESCE(make, '') || ' ' ||
COALESCE(model, '') || ' ' ||
COALESCE(orientation, '') || ' ' ||
COALESCE("lensModel", '') || ' ' ||
COALESCE("city", '') || ' ' ||
COALESCE("state", '') || ' ' ||
COALESCE("country", '')
)
) STORED;
CREATE INDEX exif_text_searchable_idx
ON exif
USING GIN (exif_text_searchable_column);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
DROP COLUMN IF EXISTS exif_text_searchable_column;
DROP INDEX IF EXISTS exif_text_searchable_idx ON exif;
`);
}
}

View File

@ -6,14 +6,18 @@ import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import exifr from 'exifr'; import exifr from 'exifr';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
import fs from 'fs'; import fs, { rmSync } from 'fs';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity'; import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
import axios from 'axios'; import axios from 'axios';
import { SmartInfoEntity } from '../../api-v1/asset/entities/smart-info.entity'; import { SmartInfoEntity } from '../../api-v1/asset/entities/smart-info.entity';
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
@Processor('background-task') @Processor('background-task')
export class BackgroundTaskProcessor { export class BackgroundTaskProcessor {
private geocodingClient: GeocodeService;
constructor( constructor(
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>, private assetRepository: Repository<AssetEntity>,
@ -25,7 +29,13 @@ export class BackgroundTaskProcessor {
private exifRepository: Repository<ExifEntity>, private exifRepository: Repository<ExifEntity>,
private configService: ConfigService, private configService: ConfigService,
) {} ) {
if (this.configService.get('ENABLE_MAPBOX')) {
this.geocodingClient = mapboxGeocoding({
accessToken: this.configService.get('MAPBOX_KEY'),
});
}
}
@Process('extract-exif') @Process('extract-exif')
async extractExif(job: Job) { async extractExif(job: Job) {
@ -55,6 +65,26 @@ export class BackgroundTaskProcessor {
newExif.latitude = exifData['latitude'] || null; newExif.latitude = exifData['latitude'] || null;
newExif.longitude = exifData['longitude'] || null; newExif.longitude = exifData['longitude'] || null;
// Reverse GeoCoding
if (this.configService.get('ENABLE_MAPBOX') && 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'];
const city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
const state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
const country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
newExif.city = city || null;
newExif.state = state || null;
newExif.country = country || null;
}
await this.exifRepository.save(newExif); await this.exifRepository.save(newExif);
try { try {