mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	fix: out of memory error when uploading large assets on slow internet (#224)
This commit is contained in:
		| @@ -23,6 +23,8 @@ PODS: | |||||||
|     - Flutter |     - Flutter | ||||||
|     - FMDB (>= 2.7.5) |     - FMDB (>= 2.7.5) | ||||||
|   - Toast (4.0.0) |   - Toast (4.0.0) | ||||||
|  |   - url_launcher_ios (0.0.1): | ||||||
|  |     - Flutter | ||||||
|   - video_player_avfoundation (0.0.1): |   - video_player_avfoundation (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - wakelock (0.0.1): |   - wakelock (0.0.1): | ||||||
| @@ -37,6 +39,7 @@ DEPENDENCIES: | |||||||
|   - 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`) | ||||||
|  |   - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) | ||||||
|   - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`) |   - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`) | ||||||
|   - wakelock (from `.symlinks/plugins/wakelock/ios`) |   - wakelock (from `.symlinks/plugins/wakelock/ios`) | ||||||
|  |  | ||||||
| @@ -63,6 +66,8 @@ EXTERNAL SOURCES: | |||||||
|     :path: ".symlinks/plugins/photo_manager/ios" |     :path: ".symlinks/plugins/photo_manager/ios" | ||||||
|   sqflite: |   sqflite: | ||||||
|     :path: ".symlinks/plugins/sqflite/ios" |     :path: ".symlinks/plugins/sqflite/ios" | ||||||
|  |   url_launcher_ios: | ||||||
|  |     :path: ".symlinks/plugins/url_launcher_ios/ios" | ||||||
|   video_player_avfoundation: |   video_player_avfoundation: | ||||||
|     :path: ".symlinks/plugins/video_player_avfoundation/ios" |     :path: ".symlinks/plugins/video_player_avfoundation/ios" | ||||||
|   wakelock: |   wakelock: | ||||||
| @@ -80,6 +85,7 @@ SPEC CHECKSUMS: | |||||||
|   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c |   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c | ||||||
|   sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 |   sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 | ||||||
|   Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 |   Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 | ||||||
|  |   url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de | ||||||
|   video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff |   video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff | ||||||
|   wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f |   wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import 'package:dio/dio.dart'; | import 'package:cancellation_token_http/http.dart'; | ||||||
| import 'package:equatable/equatable.dart'; | import 'package:equatable/equatable.dart'; | ||||||
| import 'package:photo_manager/photo_manager.dart'; | import 'package:photo_manager/photo_manager.dart'; | ||||||
|  |  | ||||||
| @@ -12,7 +12,7 @@ class BackUpState extends Equatable { | |||||||
|   final BackUpProgressEnum backupProgress; |   final BackUpProgressEnum backupProgress; | ||||||
|   final List<String> allAssetOnDatabase; |   final List<String> allAssetOnDatabase; | ||||||
|   final double progressInPercentage; |   final double progressInPercentage; | ||||||
|   final CancelToken cancelToken; |   final CancellationToken cancelToken; | ||||||
|   final ServerInfo serverInfo; |   final ServerInfo serverInfo; | ||||||
|  |  | ||||||
|   /// All available albums on the device |   /// All available albums on the device | ||||||
| @@ -43,7 +43,7 @@ class BackUpState extends Equatable { | |||||||
|     BackUpProgressEnum? backupProgress, |     BackUpProgressEnum? backupProgress, | ||||||
|     List<String>? allAssetOnDatabase, |     List<String>? allAssetOnDatabase, | ||||||
|     double? progressInPercentage, |     double? progressInPercentage, | ||||||
|     CancelToken? cancelToken, |     CancellationToken? cancelToken, | ||||||
|     ServerInfo? serverInfo, |     ServerInfo? serverInfo, | ||||||
|     List<AvailableAlbum>? availableAlbums, |     List<AvailableAlbum>? availableAlbums, | ||||||
|     Set<AssetPathEntity>? selectedBackupAlbums, |     Set<AssetPathEntity>? selectedBackupAlbums, | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | import 'package:cancellation_token_http/http.dart'; | ||||||
| import 'package:dio/dio.dart'; | import 'package:dio/dio.dart'; | ||||||
| import 'package:flutter/foundation.dart'; | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:hive_flutter/hive_flutter.dart'; | import 'package:hive_flutter/hive_flutter.dart'; | ||||||
| @@ -19,7 +20,7 @@ class BackupNotifier extends StateNotifier<BackUpState> { | |||||||
|             backupProgress: BackUpProgressEnum.idle, |             backupProgress: BackUpProgressEnum.idle, | ||||||
|             allAssetOnDatabase: const [], |             allAssetOnDatabase: const [], | ||||||
|             progressInPercentage: 0, |             progressInPercentage: 0, | ||||||
|             cancelToken: CancelToken(), |             cancelToken: CancellationToken(), | ||||||
|             serverInfo: ServerInfo( |             serverInfo: ServerInfo( | ||||||
|               diskAvailable: "0", |               diskAvailable: "0", | ||||||
|               diskAvailableRaw: 0, |               diskAvailableRaw: 0, | ||||||
| @@ -266,7 +267,7 @@ class BackupNotifier extends StateNotifier<BackUpState> { | |||||||
|       } |       } | ||||||
|  |  | ||||||
|       // Perform Backup |       // Perform Backup | ||||||
|       state = state.copyWith(cancelToken: CancelToken()); |       state = state.copyWith(cancelToken: CancellationToken()); | ||||||
|       _backupService.backupAsset(assetsWillBeBackup, state.cancelToken, _onAssetUploaded, _onUploadProgress); |       _backupService.backupAsset(assetsWillBeBackup, state.cancelToken, _onAssetUploaded, _onUploadProgress); | ||||||
|     } else { |     } else { | ||||||
|       PhotoManager.openSetting(); |       PhotoManager.openSetting(); | ||||||
| @@ -274,7 +275,7 @@ class BackupNotifier extends StateNotifier<BackUpState> { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   void cancelBackup() { |   void cancelBackup() { | ||||||
|     state.cancelToken.cancel('Cancel Backup'); |     state.cancelToken.cancel(); | ||||||
|     state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0); |     state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -8,11 +8,11 @@ import 'package:hive/hive.dart'; | |||||||
| import 'package:immich_mobile/constants/hive_box.dart'; | import 'package:immich_mobile/constants/hive_box.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/device_info.model.dart'; | import 'package:immich_mobile/shared/models/device_info.model.dart'; | ||||||
| import 'package:immich_mobile/utils/dio_http_interceptor.dart'; |  | ||||||
| import 'package:immich_mobile/utils/files_helper.dart'; | import 'package:immich_mobile/utils/files_helper.dart'; | ||||||
| import 'package:photo_manager/photo_manager.dart'; | import 'package:photo_manager/photo_manager.dart'; | ||||||
| import 'package:http_parser/http_parser.dart'; | import 'package:http_parser/http_parser.dart'; | ||||||
| import 'package:path/path.dart' as p; | import 'package:path/path.dart' as p; | ||||||
|  | import 'package:cancellation_token_http/http.dart' as http; | ||||||
|  |  | ||||||
| class BackupService { | class BackupService { | ||||||
|   final NetworkService _networkService = NetworkService(); |   final NetworkService _networkService = NetworkService(); | ||||||
| @@ -26,17 +26,13 @@ class BackupService { | |||||||
|     return result.cast<String>(); |     return result.cast<String>(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   backupAsset(Set<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb, |   backupAsset(Set<AssetEntity> assetList, http.CancellationToken cancelToken, | ||||||
|       Function(int, int) uploadProgress) async { |       Function(String, String) singleAssetDoneCb, Function(int, int) uploadProgress) async { | ||||||
|     var dio = Dio(); |  | ||||||
|     dio.interceptors.add(AuthenticatedRequestInterceptor()); |  | ||||||
|  |  | ||||||
|     String deviceId = Hive.box(userInfoBox).get(deviceIdKey); |     String deviceId = Hive.box(userInfoBox).get(deviceIdKey); | ||||||
|     String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); |     String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); | ||||||
|     File? file; |     File? file; | ||||||
|  |  | ||||||
|     MultipartFile assetRawUploadData; |     http.MultipartFile? thumbnailUploadData; | ||||||
|     MultipartFile thumbnailUploadData; |  | ||||||
|  |  | ||||||
|     for (var entity in assetList) { |     for (var entity in assetList) { | ||||||
|       try { |       try { | ||||||
| @@ -47,35 +43,27 @@ class BackupService { | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (file != null) { |         if (file != null) { | ||||||
|           FormData formData; |  | ||||||
|           String originalFileName = await entity.titleAsync; |           String originalFileName = await entity.titleAsync; | ||||||
|           String fileNameWithoutPath = originalFileName.toString().split(".")[0]; |           String fileNameWithoutPath = originalFileName.toString().split(".")[0]; | ||||||
|           var fileExtension = p.extension(file.path); |           var fileExtension = p.extension(file.path); | ||||||
|           var mimeType = FileHelper.getMimeType(file.path); |           var mimeType = FileHelper.getMimeType(file.path); | ||||||
|           assetRawUploadData = await MultipartFile.fromFile( |           var fileStream = file.openRead(); | ||||||
|             file.path, |           var assetRawUploadData = http.MultipartFile( | ||||||
|  |             "assetData", | ||||||
|  |             fileStream, | ||||||
|  |             file.lengthSync(), | ||||||
|             filename: fileNameWithoutPath, |             filename: fileNameWithoutPath, | ||||||
|             contentType: MediaType( |             contentType: MediaType( | ||||||
|               mimeType["type"], |               mimeType["type"], | ||||||
|               mimeType["subType"], |               mimeType["subType"], | ||||||
|             ), |             ), | ||||||
|           ); |           ); | ||||||
|           formData = FormData.fromMap({ |  | ||||||
|             'deviceAssetId': entity.id, |  | ||||||
|             'deviceId': deviceId, |  | ||||||
|             'assetType': _getAssetType(entity.type), |  | ||||||
|             'createdAt': entity.createDateTime.toIso8601String(), |  | ||||||
|             'modifiedAt': entity.modifiedDateTime.toIso8601String(), |  | ||||||
|             'isFavorite': entity.isFavorite, |  | ||||||
|             'fileExtension': fileExtension, |  | ||||||
|             'duration': entity.videoDuration, |  | ||||||
|             'assetData': [assetRawUploadData] |  | ||||||
|           }); |  | ||||||
|  |  | ||||||
|           // Build thumbnail multipart data |           // Build thumbnail multipart data | ||||||
|           var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(1440, 2560)); |           var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(1440, 2560)); | ||||||
|           if (thumbnailData != null) { |           if (thumbnailData != null) { | ||||||
|             thumbnailUploadData = MultipartFile.fromBytes( |             thumbnailUploadData = http.MultipartFile.fromBytes( | ||||||
|  |               "thumbnailData", | ||||||
|               List.from(thumbnailData), |               List.from(thumbnailData), | ||||||
|               filename: fileNameWithoutPath, |               filename: fileNameWithoutPath, | ||||||
|               contentType: MediaType( |               contentType: MediaType( | ||||||
| @@ -83,39 +71,37 @@ class BackupService { | |||||||
|                 "jpeg", |                 "jpeg", | ||||||
|               ), |               ), | ||||||
|             ); |             ); | ||||||
|  |  | ||||||
|             // Send thumbnail data if it is exist |  | ||||||
|             formData = FormData.fromMap({ |  | ||||||
|               'deviceAssetId': entity.id, |  | ||||||
|               'deviceId': deviceId, |  | ||||||
|               'assetType': _getAssetType(entity.type), |  | ||||||
|               'createdAt': entity.createDateTime.toIso8601String(), |  | ||||||
|               'modifiedAt': entity.modifiedDateTime.toIso8601String(), |  | ||||||
|               'isFavorite': entity.isFavorite, |  | ||||||
|               'fileExtension': fileExtension, |  | ||||||
|               'duration': entity.videoDuration, |  | ||||||
|               'thumbnailData': [thumbnailUploadData], |  | ||||||
|               'assetData': [assetRawUploadData] |  | ||||||
|             }); |  | ||||||
|           } |           } | ||||||
|  |  | ||||||
|           Response res = await dio.post( |           var box = Hive.box(userInfoBox); | ||||||
|             '$savedEndpoint/asset/upload', |  | ||||||
|             data: formData, |           var req = MultipartRequest('POST', Uri.parse('$savedEndpoint/asset/upload'), | ||||||
|             cancelToken: cancelToken, |               onProgress: ((bytes, totalBytes) => uploadProgress(bytes, totalBytes))); | ||||||
|             onSendProgress: (sent, total) => uploadProgress(sent, total), |           req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}"; | ||||||
|           ); |  | ||||||
|  |           req.fields['deviceAssetId'] = entity.id; | ||||||
|  |           req.fields['deviceId'] = deviceId; | ||||||
|  |           req.fields['assetType'] = _getAssetType(entity.type); | ||||||
|  |           req.fields['createdAt'] = entity.createDateTime.toIso8601String(); | ||||||
|  |           req.fields['modifiedAt'] = entity.modifiedDateTime.toIso8601String(); | ||||||
|  |           req.fields['isFavorite'] = entity.isFavorite.toString(); | ||||||
|  |           req.fields['fileExtension'] = fileExtension; | ||||||
|  |           req.fields['duration'] = entity.videoDuration.toString(); | ||||||
|  |  | ||||||
|  |           if (thumbnailUploadData != null) { | ||||||
|  |             req.files.add(thumbnailUploadData); | ||||||
|  |           } | ||||||
|  |           req.files.add(assetRawUploadData); | ||||||
|  |  | ||||||
|  |           var res = await req.send(cancellationToken: cancelToken); | ||||||
|  |  | ||||||
|           if (res.statusCode == 201) { |           if (res.statusCode == 201) { | ||||||
|             singleAssetDoneCb(entity.id, deviceId); |             singleAssetDoneCb(entity.id, deviceId); | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
|       } on DioError catch (e) { |       } on http.CancelledException { | ||||||
|         debugPrint("DioError backupAsset: ${e.response}"); |         debugPrint("Backup was cancelled by the user"); | ||||||
|         if (e.type == DioErrorType.cancel || e.type == DioErrorType.other) { |         return; | ||||||
|           return; |  | ||||||
|         } |  | ||||||
|         continue; |  | ||||||
|       } catch (e) { |       } catch (e) { | ||||||
|         debugPrint("ERROR backupAsset: ${e.toString()}"); |         debugPrint("ERROR backupAsset: ${e.toString()}"); | ||||||
|         continue; |         continue; | ||||||
| @@ -150,3 +136,35 @@ class BackupService { | |||||||
|     return DeviceInfoRemote.fromJson(res.toString()); |     return DeviceInfoRemote.fromJson(res.toString()); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | class MultipartRequest extends http.MultipartRequest { | ||||||
|  |   /// Creates a new [MultipartRequest]. | ||||||
|  |   MultipartRequest( | ||||||
|  |     String method, | ||||||
|  |     Uri url, { | ||||||
|  |     required this.onProgress, | ||||||
|  |   }) : super(method, url); | ||||||
|  |  | ||||||
|  |   final void Function(int bytes, int totalBytes) onProgress; | ||||||
|  |  | ||||||
|  |   /// Freezes all mutable fields and returns a | ||||||
|  |   /// single-subscription [http.ByteStream] | ||||||
|  |   /// that will emit the request body. | ||||||
|  |   @override | ||||||
|  |   http.ByteStream finalize() { | ||||||
|  |     final byteStream = super.finalize(); | ||||||
|  |  | ||||||
|  |     final total = contentLength; | ||||||
|  |     var bytes = 0; | ||||||
|  |  | ||||||
|  |     final t = StreamTransformer.fromHandlers( | ||||||
|  |       handleData: (List<int> data, EventSink<List<int>> sink) { | ||||||
|  |         bytes += data.length; | ||||||
|  |         onProgress.call(bytes, total); | ||||||
|  |         sink.add(data); | ||||||
|  |       }, | ||||||
|  |     ); | ||||||
|  |     final stream = byteStream.transform(t); | ||||||
|  |     return http.ByteStream(stream); | ||||||
|  |   } | ||||||
|  | } | ||||||
|   | |||||||
| @@ -141,6 +141,20 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.0.1" |     version: "1.0.1" | ||||||
|  |   cancellation_token: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: cancellation_token | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "1.4.0" | ||||||
|  |   cancellation_token_http: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: cancellation_token_http | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "1.1.0" | ||||||
|   characters: |   characters: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -437,7 +451,7 @@ packages: | |||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.15.0" |     version: "0.15.0" | ||||||
|   http: |   http: | ||||||
|     dependency: transitive |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
|       name: http |       name: http | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|   | |||||||
| @@ -40,6 +40,8 @@ dependencies: | |||||||
|   equatable: ^2.0.3 |   equatable: ^2.0.3 | ||||||
|   image_picker: ^0.8.5+3 |   image_picker: ^0.8.5+3 | ||||||
|   url_launcher: ^6.1.3 |   url_launcher: ^6.1.3 | ||||||
|  |   http: 0.13.4 | ||||||
|  |   cancellation_token_http: ^1.1.0 | ||||||
|  |  | ||||||
| dev_dependencies: | dev_dependencies: | ||||||
|   flutter_test: |   flutter_test: | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user