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 | ||||
|     - FMDB (>= 2.7.5) | ||||
|   - Toast (4.0.0) | ||||
|   - url_launcher_ios (0.0.1): | ||||
|     - Flutter | ||||
|   - video_player_avfoundation (0.0.1): | ||||
|     - Flutter | ||||
|   - wakelock (0.0.1): | ||||
| @@ -37,6 +39,7 @@ DEPENDENCIES: | ||||
|   - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) | ||||
|   - photo_manager (from `.symlinks/plugins/photo_manager/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`) | ||||
|   - wakelock (from `.symlinks/plugins/wakelock/ios`) | ||||
|  | ||||
| @@ -63,6 +66,8 @@ EXTERNAL SOURCES: | ||||
|     :path: ".symlinks/plugins/photo_manager/ios" | ||||
|   sqflite: | ||||
|     :path: ".symlinks/plugins/sqflite/ios" | ||||
|   url_launcher_ios: | ||||
|     :path: ".symlinks/plugins/url_launcher_ios/ios" | ||||
|   video_player_avfoundation: | ||||
|     :path: ".symlinks/plugins/video_player_avfoundation/ios" | ||||
|   wakelock: | ||||
| @@ -80,6 +85,7 @@ SPEC CHECKSUMS: | ||||
|   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c | ||||
|   sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 | ||||
|   Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 | ||||
|   url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de | ||||
|   video_player_avfoundation: e489aac24ef5cf7af82702979ed16f2a5ef84cff | ||||
|   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:photo_manager/photo_manager.dart'; | ||||
|  | ||||
| @@ -12,7 +12,7 @@ class BackUpState extends Equatable { | ||||
|   final BackUpProgressEnum backupProgress; | ||||
|   final List<String> allAssetOnDatabase; | ||||
|   final double progressInPercentage; | ||||
|   final CancelToken cancelToken; | ||||
|   final CancellationToken cancelToken; | ||||
|   final ServerInfo serverInfo; | ||||
|  | ||||
|   /// All available albums on the device | ||||
| @@ -43,7 +43,7 @@ class BackUpState extends Equatable { | ||||
|     BackUpProgressEnum? backupProgress, | ||||
|     List<String>? allAssetOnDatabase, | ||||
|     double? progressInPercentage, | ||||
|     CancelToken? cancelToken, | ||||
|     CancellationToken? cancelToken, | ||||
|     ServerInfo? serverInfo, | ||||
|     List<AvailableAlbum>? availableAlbums, | ||||
|     Set<AssetPathEntity>? selectedBackupAlbums, | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import 'package:cancellation_token_http/http.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| @@ -19,7 +20,7 @@ class BackupNotifier extends StateNotifier<BackUpState> { | ||||
|             backupProgress: BackUpProgressEnum.idle, | ||||
|             allAssetOnDatabase: const [], | ||||
|             progressInPercentage: 0, | ||||
|             cancelToken: CancelToken(), | ||||
|             cancelToken: CancellationToken(), | ||||
|             serverInfo: ServerInfo( | ||||
|               diskAvailable: "0", | ||||
|               diskAvailableRaw: 0, | ||||
| @@ -266,7 +267,7 @@ class BackupNotifier extends StateNotifier<BackUpState> { | ||||
|       } | ||||
|  | ||||
|       // Perform Backup | ||||
|       state = state.copyWith(cancelToken: CancelToken()); | ||||
|       state = state.copyWith(cancelToken: CancellationToken()); | ||||
|       _backupService.backupAsset(assetsWillBeBackup, state.cancelToken, _onAssetUploaded, _onUploadProgress); | ||||
|     } else { | ||||
|       PhotoManager.openSetting(); | ||||
| @@ -274,7 +275,7 @@ class BackupNotifier extends StateNotifier<BackUpState> { | ||||
|   } | ||||
|  | ||||
|   void cancelBackup() { | ||||
|     state.cancelToken.cancel('Cancel Backup'); | ||||
|     state.cancelToken.cancel(); | ||||
|     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/shared/services/network.service.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:photo_manager/photo_manager.dart'; | ||||
| import 'package:http_parser/http_parser.dart'; | ||||
| import 'package:path/path.dart' as p; | ||||
| import 'package:cancellation_token_http/http.dart' as http; | ||||
|  | ||||
| class BackupService { | ||||
|   final NetworkService _networkService = NetworkService(); | ||||
| @@ -26,17 +26,13 @@ class BackupService { | ||||
|     return result.cast<String>(); | ||||
|   } | ||||
|  | ||||
|   backupAsset(Set<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb, | ||||
|       Function(int, int) uploadProgress) async { | ||||
|     var dio = Dio(); | ||||
|     dio.interceptors.add(AuthenticatedRequestInterceptor()); | ||||
|  | ||||
|   backupAsset(Set<AssetEntity> assetList, http.CancellationToken cancelToken, | ||||
|       Function(String, String) singleAssetDoneCb, Function(int, int) uploadProgress) async { | ||||
|     String deviceId = Hive.box(userInfoBox).get(deviceIdKey); | ||||
|     String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); | ||||
|     File? file; | ||||
|  | ||||
|     MultipartFile assetRawUploadData; | ||||
|     MultipartFile thumbnailUploadData; | ||||
|     http.MultipartFile? thumbnailUploadData; | ||||
|  | ||||
|     for (var entity in assetList) { | ||||
|       try { | ||||
| @@ -47,35 +43,27 @@ class BackupService { | ||||
|         } | ||||
|  | ||||
|         if (file != null) { | ||||
|           FormData formData; | ||||
|           String originalFileName = await entity.titleAsync; | ||||
|           String fileNameWithoutPath = originalFileName.toString().split(".")[0]; | ||||
|           var fileExtension = p.extension(file.path); | ||||
|           var mimeType = FileHelper.getMimeType(file.path); | ||||
|           assetRawUploadData = await MultipartFile.fromFile( | ||||
|             file.path, | ||||
|           var fileStream = file.openRead(); | ||||
|           var assetRawUploadData = http.MultipartFile( | ||||
|             "assetData", | ||||
|             fileStream, | ||||
|             file.lengthSync(), | ||||
|             filename: fileNameWithoutPath, | ||||
|             contentType: MediaType( | ||||
|               mimeType["type"], | ||||
|               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 | ||||
|           var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(1440, 2560)); | ||||
|           if (thumbnailData != null) { | ||||
|             thumbnailUploadData = MultipartFile.fromBytes( | ||||
|             thumbnailUploadData = http.MultipartFile.fromBytes( | ||||
|               "thumbnailData", | ||||
|               List.from(thumbnailData), | ||||
|               filename: fileNameWithoutPath, | ||||
|               contentType: MediaType( | ||||
| @@ -83,39 +71,37 @@ class BackupService { | ||||
|                 "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( | ||||
|             '$savedEndpoint/asset/upload', | ||||
|             data: formData, | ||||
|             cancelToken: cancelToken, | ||||
|             onSendProgress: (sent, total) => uploadProgress(sent, total), | ||||
|           ); | ||||
|           var box = Hive.box(userInfoBox); | ||||
|  | ||||
|           var req = MultipartRequest('POST', Uri.parse('$savedEndpoint/asset/upload'), | ||||
|               onProgress: ((bytes, totalBytes) => uploadProgress(bytes, totalBytes))); | ||||
|           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) { | ||||
|             singleAssetDoneCb(entity.id, deviceId); | ||||
|           } | ||||
|         } | ||||
|       } on DioError catch (e) { | ||||
|         debugPrint("DioError backupAsset: ${e.response}"); | ||||
|         if (e.type == DioErrorType.cancel || e.type == DioErrorType.other) { | ||||
|       } on http.CancelledException { | ||||
|         debugPrint("Backup was cancelled by the user"); | ||||
|         return; | ||||
|         } | ||||
|         continue; | ||||
|       } catch (e) { | ||||
|         debugPrint("ERROR backupAsset: ${e.toString()}"); | ||||
|         continue; | ||||
| @@ -150,3 +136,35 @@ class BackupService { | ||||
|     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" | ||||
|     source: hosted | ||||
|     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: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -437,7 +451,7 @@ packages: | ||||
|     source: hosted | ||||
|     version: "0.15.0" | ||||
|   http: | ||||
|     dependency: transitive | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: http | ||||
|       url: "https://pub.dartlang.org" | ||||
|   | ||||
| @@ -40,6 +40,8 @@ dependencies: | ||||
|   equatable: ^2.0.3 | ||||
|   image_picker: ^0.8.5+3 | ||||
|   url_launcher: ^6.1.3 | ||||
|   http: 0.13.4 | ||||
|   cancellation_token_http: ^1.1.0 | ||||
|  | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user