mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	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:
		| @@ -38,6 +38,7 @@ This project is under heavy development, there will be continous functions, feat | ||||
| - Image Tagging/Classification based on ImageNet dataset | ||||
| - 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) | ||||
| - Geocoding to show asset's location information on map (required MapBox registration for their generous free tier) | ||||
|  | ||||
| # Development | ||||
|  | ||||
| @@ -59,16 +60,12 @@ cp .env.example .env | ||||
|  | ||||
| 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. | ||||
|  | ||||
| To start, run | ||||
|  | ||||
| ```bash | ||||
| docker-compose -f ./docker/docker-compose.yml up | ||||
| ``` | ||||
|  | ||||
| To force rebuild node modules after installing new packages | ||||
|  | ||||
| ```bash | ||||
| docker-compose -f ./docker/docker-compose.yml up --build -V | ||||
| ``` | ||||
|   | ||||
| @@ -11,3 +11,8 @@ UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_ba | ||||
|  | ||||
| # JWT SECRET | ||||
| JWT_SECRET= | ||||
|  | ||||
| # MAPBOX | ||||
| ## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY | ||||
| ENABLE_MAPBOX=  | ||||
| MAPBOX_KEY= | ||||
| @@ -44,7 +44,7 @@ android { | ||||
|     defaultConfig { | ||||
|         // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). | ||||
|         applicationId "com.example.immich_mobile" | ||||
|         minSdkVersion flutter.minSdkVersion | ||||
|         minSdkVersion 20 | ||||
|         targetSdkVersion flutter.targetSdkVersion | ||||
|         versionCode flutterVersionCode.toInteger() | ||||
|         versionName flutterVersionName | ||||
|   | ||||
| @@ -1,39 +1,23 @@ | ||||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|           package="com.example.immich_mobile"> | ||||
|     <application | ||||
|             android:label="Immich" | ||||
|             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"> | ||||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.immich_mobile"> | ||||
|   <application android:label="Immich" 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 | ||||
|                  while the Flutter UI initializes. After that, this theme continues | ||||
|                  to determine the Window background behind the Flutter UI. --> | ||||
|             <meta-data | ||||
|                     android:name="io.flutter.embedding.android.NormalTheme" | ||||
|                     android:resource="@style/NormalTheme" | ||||
|             /> | ||||
|       <meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" /> | ||||
|       <intent-filter> | ||||
|                 <action android:name="android.intent.action.MAIN"/> | ||||
|                 <category android:name="android.intent.category.LAUNCHER"/> | ||||
|         <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"/> | ||||
|     <meta-data android:name="flutterEmbedding" android:value="2" /> | ||||
|  | ||||
|  | ||||
|   </application> | ||||
|     <uses-permission android:name="android.permission.INTERNET"/> | ||||
|   <uses-permission android:name="android.permission.INTERNET" /> | ||||
|   <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> | ||||
| </manifest> | ||||
							
								
								
									
										
											BIN
										
									
								
								mobile/assets/location-pin.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								mobile/assets/location-pin.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 50 KiB | 
| @@ -34,8 +34,19 @@ target 'Runner' do | ||||
|   flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) | ||||
| end | ||||
|  | ||||
| # post_install do |installer| | ||||
| #   installer.pods_project.targets.each do |target| | ||||
| #     flutter_additional_ios_build_settings(target) | ||||
| #   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 | ||||
|   | ||||
| @@ -8,6 +8,15 @@ PODS: | ||||
|   - FMDB (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): | ||||
|     - Flutter | ||||
|   - photo_manager (1.0.0): | ||||
| @@ -26,6 +35,7 @@ DEPENDENCIES: | ||||
|   - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) | ||||
|   - Flutter (from `Flutter`) | ||||
|   - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) | ||||
|   - mapbox_gl (from `.symlinks/plugins/mapbox_gl/ios`) | ||||
|   - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) | ||||
|   - photo_manager (from `.symlinks/plugins/photo_manager/ios`) | ||||
|   - sqflite (from `.symlinks/plugins/sqflite/ios`) | ||||
| @@ -35,6 +45,9 @@ DEPENDENCIES: | ||||
| SPEC REPOS: | ||||
|   trunk: | ||||
|     - FMDB | ||||
|     - Mapbox-iOS-SDK | ||||
|     - MapboxAnnotationExtension | ||||
|     - MapboxMobileEvents | ||||
|     - Toast | ||||
|  | ||||
| EXTERNAL SOURCES: | ||||
| @@ -44,6 +57,8 @@ EXTERNAL SOURCES: | ||||
|     :path: Flutter | ||||
|   fluttertoast: | ||||
|     :path: ".symlinks/plugins/fluttertoast/ios" | ||||
|   mapbox_gl: | ||||
|     :path: ".symlinks/plugins/mapbox_gl/ios" | ||||
|   path_provider_ios: | ||||
|     :path: ".symlinks/plugins/path_provider_ios/ios" | ||||
|   photo_manager: | ||||
| @@ -60,6 +75,10 @@ SPEC CHECKSUMS: | ||||
|   Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a | ||||
|   fluttertoast: 6122fa75143e992b1d3470f61000f591a798cc58 | ||||
|   FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a | ||||
|   Mapbox-iOS-SDK: f870f83cbdc7aa4a74afcee143aafb0dae390c82 | ||||
|   mapbox_gl: 33c5ab6306cbfa72289bb3606d2cd2e8baee9ff0 | ||||
|   MapboxAnnotationExtension: 4eee6c26349ef6d909f1a23a7eae2d0f7ca5fa7d | ||||
|   MapboxMobileEvents: 5a172cc9bbf8ac0e45ba86095cbee685ede248cc | ||||
|   path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5 | ||||
|   photo_manager: 84fa94fbeb82e607333ea9a13c43b58e0903a463 | ||||
|   sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 | ||||
| @@ -67,6 +86,6 @@ SPEC CHECKSUMS: | ||||
|   video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff | ||||
|   wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f | ||||
|  | ||||
| PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c | ||||
| PODFILE CHECKSUM: a44d1ba6d6faf8c61ee449ab69176b941340b431 | ||||
|  | ||||
| COCOAPODS: 1.10.1 | ||||
|   | ||||
| @@ -341,6 +341,7 @@ | ||||
| 				GCC_WARN_UNUSED_VARIABLE = YES; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 9.0; | ||||
| 				MTL_ENABLE_DEBUG_INFO = NO; | ||||
| 				NEW_SETTING = ""; | ||||
| 				SDKROOT = iphoneos; | ||||
| 				SUPPORTED_PLATFORMS = iphoneos; | ||||
| 				TARGETED_DEVICE_FAMILY = "1,2"; | ||||
| @@ -419,6 +420,7 @@ | ||||
| 				GCC_WARN_UNUSED_VARIABLE = YES; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 9.0; | ||||
| 				MTL_ENABLE_DEBUG_INFO = YES; | ||||
| 				NEW_SETTING = ""; | ||||
| 				ONLY_ACTIVE_ARCH = YES; | ||||
| 				SDKROOT = iphoneos; | ||||
| 				TARGETED_DEVICE_FAMILY = "1,2"; | ||||
| @@ -468,6 +470,7 @@ | ||||
| 				GCC_WARN_UNUSED_VARIABLE = YES; | ||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 9.0; | ||||
| 				MTL_ENABLE_DEBUG_INFO = NO; | ||||
| 				NEW_SETTING = ""; | ||||
| 				SDKROOT = iphoneos; | ||||
| 				SUPPORTED_PLATFORMS = iphoneos; | ||||
| 				SWIFT_COMPILATION_MODE = wholemodule; | ||||
|   | ||||
| @@ -54,9 +54,16 @@ | ||||
|     <key>NSAppTransportSecurity</key> | ||||
|     <dict> | ||||
|       <key>NSAllowsArbitraryLoads</key> | ||||
|         <true/> | ||||
|       <true /> | ||||
|     </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> | ||||
| </plist> | ||||
| @@ -1,7 +1,12 @@ | ||||
| import 'dart:typed_data'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.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/providers/server_info.provider.dart'; | ||||
| import 'package:intl/intl.dart'; | ||||
| import 'package:mapbox_gl/mapbox_gl.dart'; | ||||
| import 'package:path/path.dart' as p; | ||||
|  | ||||
| class ExifBottomSheet extends ConsumerWidget { | ||||
| @@ -11,6 +16,54 @@ class ExifBottomSheet extends ConsumerWidget { | ||||
|  | ||||
|   @override | ||||
|   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( | ||||
|       padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8), | ||||
|       child: ListView( | ||||
| @@ -53,9 +106,11 @@ class ExifBottomSheet extends ConsumerWidget { | ||||
|                         "LOCATION", | ||||
|                         style: TextStyle(fontSize: 11, color: Colors.grey[400]), | ||||
|                       ), | ||||
|                       _buildMap(), | ||||
|                       _buildLocationText(), | ||||
|                       Text( | ||||
|                         "${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)}", | ||||
|                           style: const TextStyle(fontWeight: FontWeight.bold), | ||||
|                         ), | ||||
|                         subtitle: Text( | ||||
|                             "${assetDetail.exifInfo?.exifImageHeight!} x ${assetDetail.exifInfo?.exifImageWidth!}  ${assetDetail.exifInfo?.fileSizeInByte!}B "), | ||||
|                         subtitle: assetDetail.exifInfo?.exifImageHeight != null | ||||
|                             ? Text( | ||||
|                                 "${assetDetail.exifInfo?.exifImageHeight} x ${assetDetail.exifInfo?.exifImageWidth}  ${assetDetail.exifInfo?.fileSizeInByte!}B ") | ||||
|                             : Container(), | ||||
|                       ), | ||||
|                       assetDetail.exifInfo?.make != null | ||||
|                           ? ListTile( | ||||
|   | ||||
| @@ -29,9 +29,9 @@ class TopControlAppBar extends StatelessWidget with PreferredSizeWidget { | ||||
|           iconSize: iconSize, | ||||
|           splashRadius: iconSize, | ||||
|           onPressed: () { | ||||
|             print("backup"); | ||||
|             print("download"); | ||||
|           }, | ||||
|           icon: const Icon(Icons.backup_outlined), | ||||
|           icon: const Icon(Icons.cloud_download_rounded), | ||||
|         ), | ||||
|         IconButton( | ||||
|           iconSize: iconSize, | ||||
|   | ||||
| @@ -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/profile_drawer.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:sliver_tools/sliver_tools.dart'; | ||||
|  | ||||
| @@ -28,6 +29,7 @@ class HomePage extends HookConsumerWidget { | ||||
|     useEffect(() { | ||||
|       ref.read(websocketProvider.notifier).connect(); | ||||
|       ref.read(assetProvider.notifier).getAllAsset(); | ||||
|       ref.read(serverInfoProvider.notifier).getMapboxInfo(); | ||||
|       return null; | ||||
|     }, []); | ||||
|  | ||||
|   | ||||
| @@ -19,6 +19,9 @@ class ImmichExif { | ||||
|   final double? exposureTime; | ||||
|   final double? latitude; | ||||
|   final double? longitude; | ||||
|   final String? city; | ||||
|   final String? state; | ||||
|   final String? country; | ||||
|  | ||||
|   ImmichExif({ | ||||
|     this.id, | ||||
| @@ -39,6 +42,9 @@ class ImmichExif { | ||||
|     this.exposureTime, | ||||
|     this.latitude, | ||||
|     this.longitude, | ||||
|     this.city, | ||||
|     this.state, | ||||
|     this.country, | ||||
|   }); | ||||
|  | ||||
|   ImmichExif copyWith({ | ||||
| @@ -60,6 +66,9 @@ class ImmichExif { | ||||
|     double? exposureTime, | ||||
|     double? latitude, | ||||
|     double? longitude, | ||||
|     String? city, | ||||
|     String? state, | ||||
|     String? country, | ||||
|   }) { | ||||
|     return ImmichExif( | ||||
|       id: id ?? this.id, | ||||
| @@ -80,6 +89,9 @@ class ImmichExif { | ||||
|       exposureTime: exposureTime ?? this.exposureTime, | ||||
|       latitude: latitude ?? this.latitude, | ||||
|       longitude: longitude ?? this.longitude, | ||||
|       city: city ?? this.city, | ||||
|       state: state ?? this.state, | ||||
|       country: country ?? this.country, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @@ -103,6 +115,9 @@ class ImmichExif { | ||||
|       'exposureTime': exposureTime, | ||||
|       'latitude': latitude, | ||||
|       'longitude': longitude, | ||||
|       'city': city, | ||||
|       'state': state, | ||||
|       'country': country, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
| @@ -126,6 +141,9 @@ class ImmichExif { | ||||
|       exposureTime: map['exposureTime']?.toDouble(), | ||||
|       latitude: map['latitude']?.toDouble(), | ||||
|       longitude: map['longitude']?.toDouble(), | ||||
|       city: map['city'], | ||||
|       state: map['state'], | ||||
|       country: map['country'], | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @@ -135,7 +153,7 @@ class ImmichExif { | ||||
|  | ||||
|   @override | ||||
|   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 | ||||
| @@ -160,7 +178,10 @@ class ImmichExif { | ||||
|         other.iso == iso && | ||||
|         other.exposureTime == exposureTime && | ||||
|         other.latitude == latitude && | ||||
|         other.longitude == longitude; | ||||
|         other.longitude == longitude && | ||||
|         other.city == city && | ||||
|         other.state == state && | ||||
|         other.country == country; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -182,6 +203,9 @@ class ImmichExif { | ||||
|         iso.hashCode ^ | ||||
|         exposureTime.hashCode ^ | ||||
|         latitude.hashCode ^ | ||||
|         longitude.hashCode; | ||||
|         longitude.hashCode ^ | ||||
|         city.hashCode ^ | ||||
|         state.hashCode ^ | ||||
|         country.hashCode; | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										51
									
								
								mobile/lib/shared/models/mapbox_info.model.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								mobile/lib/shared/models/mapbox_info.model.dart
									
									
									
									
									
										Normal 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; | ||||
| } | ||||
							
								
								
									
										71
									
								
								mobile/lib/shared/providers/server_info.provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								mobile/lib/shared/providers/server_info.provider.dart
									
									
									
									
									
										Normal 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(); | ||||
| }); | ||||
| @@ -1,6 +1,5 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| 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/models/server_info.model.dart'; | ||||
|  | ||||
| @@ -12,4 +11,10 @@ class ServerInfoService { | ||||
|  | ||||
|     return ServerInfo.fromJson(response.toString()); | ||||
|   } | ||||
|  | ||||
|   Future<MapboxInfo> getMapboxInfo() async { | ||||
|     Response response = await _networkService.getRequest(url: 'server-info/mapbox'); | ||||
|  | ||||
|     return MapboxInfo.fromJson(response.toString()); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -513,6 +513,34 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     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: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|   | ||||
| @@ -34,6 +34,7 @@ dependencies: | ||||
|   badges: ^2.0.2 | ||||
|   photo_view: ^0.13.0 | ||||
|   socket_io_client: ^2.0.0-beta.4-nullsafety.0 | ||||
|   mapbox_gl: ^0.15.0 | ||||
|    | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
|   | ||||
							
								
								
									
										11715
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										11715
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -22,6 +22,7 @@ | ||||
|     "typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@mapbox/mapbox-sdk": "^0.13.3", | ||||
|     "@nestjs/bull": "^0.4.2", | ||||
|     "@nestjs/common": "^8.0.0", | ||||
|     "@nestjs/config": "^1.1.6", | ||||
|   | ||||
| @@ -249,7 +249,7 @@ export class AssetService { | ||||
|     const possibleSearchTerm = new Set<String>(); | ||||
|     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 | ||||
|       left join exif e on a.id = e."assetId" | ||||
|       left join smart_info si on a.id = si."assetId" | ||||
| @@ -274,6 +274,11 @@ export class AssetService { | ||||
|       // Make and model | ||||
|       possibleSearchTerm.add(row['make']?.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); | ||||
|   | ||||
| @@ -61,6 +61,15 @@ export class ExifEntity { | ||||
|   @Column({ type: 'float', nullable: true }) | ||||
|   longitude: number; | ||||
|  | ||||
|   @Column({ nullable: true }) | ||||
|   city: string; | ||||
|  | ||||
|   @Column({ nullable: true }) | ||||
|   state: string; | ||||
|  | ||||
|   @Column({ nullable: true }) | ||||
|   country: string; | ||||
|  | ||||
|   @OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true }) | ||||
|   @JoinColumn({ name: 'assetId', referencedColumnName: 'id' }) | ||||
|   asset: ExifEntity; | ||||
|   | ||||
| @@ -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 mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding'; | ||||
| import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response'; | ||||
|  | ||||
| @Controller('server-info') | ||||
| export class ServerInfoController { | ||||
|   constructor(private readonly serverInfoService: ServerInfoService) {} | ||||
|   constructor(private readonly serverInfoService: ServerInfoService, private readonly configService: ConfigService) {} | ||||
|  | ||||
|   @Get() | ||||
|   async getServerInfo() { | ||||
| @@ -16,4 +21,13 @@ export class ServerInfoController { | ||||
|       res: 'pong', | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   @UseGuards(JwtAuthGuard) | ||||
|   @Get('/mapbox') | ||||
|   async getMapboxInfo() { | ||||
|     return { | ||||
|       isEnable: this.configService.get('ENABLE_MAPBOX'), | ||||
|       mapboxSecret: this.configService.get('MAPBOX_KEY'), | ||||
|     }; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -6,29 +6,4 @@ import { UpdateUserDto } from './dto/update-user.dto'; | ||||
| @Controller('user') | ||||
| export class UserController { | ||||
|   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); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -11,22 +11,4 @@ export class UserService { | ||||
|     @InjectRepository(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`; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -11,5 +11,11 @@ export const immichAppConfig: ConfigModuleOptions = { | ||||
|     DB_DATABASE_NAME: Joi.string().required(), | ||||
|     UPLOAD_LOCATION: 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, | ||||
|     }), | ||||
|   }), | ||||
| }; | ||||
|   | ||||
							
								
								
									
										29
									
								
								server/src/migration/1646709533213-AddRegionCityToExIf.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								server/src/migration/1646709533213-AddRegionCityToExIf.ts
									
									
									
									
									
										Normal 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; | ||||
|     `); | ||||
|   } | ||||
| } | ||||
| @@ -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; | ||||
|       `); | ||||
|   } | ||||
| } | ||||
| @@ -6,14 +6,18 @@ import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; | ||||
| import { ConfigService } from '@nestjs/config'; | ||||
| import exifr from 'exifr'; | ||||
| import { readFile } from 'fs/promises'; | ||||
| import fs from 'fs'; | ||||
| import fs, { rmSync } from 'fs'; | ||||
| import { Logger } from '@nestjs/common'; | ||||
| import { ExifEntity } from '../../api-v1/asset/entities/exif.entity'; | ||||
| import axios from 'axios'; | ||||
| 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') | ||||
| export class BackgroundTaskProcessor { | ||||
|   private geocodingClient: GeocodeService; | ||||
|  | ||||
|   constructor( | ||||
|     @InjectRepository(AssetEntity) | ||||
|     private assetRepository: Repository<AssetEntity>, | ||||
| @@ -25,7 +29,13 @@ export class BackgroundTaskProcessor { | ||||
|     private exifRepository: Repository<ExifEntity>, | ||||
|  | ||||
|     private configService: ConfigService, | ||||
|   ) {} | ||||
|   ) { | ||||
|     if (this.configService.get('ENABLE_MAPBOX')) { | ||||
|       this.geocodingClient = mapboxGeocoding({ | ||||
|         accessToken: this.configService.get('MAPBOX_KEY'), | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @Process('extract-exif') | ||||
|   async extractExif(job: Job) { | ||||
| @@ -55,6 +65,26 @@ export class BackgroundTaskProcessor { | ||||
|     newExif.latitude = exifData['latitude'] || 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); | ||||
|  | ||||
|     try { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user