import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:cancellation_token_http/http.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:logging/logging.dart'; import 'package:immich_mobile/utils/debug_print.dart'; class UploadTaskWithFile { final File file; final UploadTask task; const UploadTaskWithFile({required this.file, required this.task}); } final uploadRepositoryProvider = Provider((ref) => UploadRepository()); class UploadRepository { final Logger logger = Logger('UploadRepository'); void Function(TaskStatusUpdate)? onUploadStatus; void Function(TaskProgressUpdate)? onTaskProgress; UploadRepository() { FileDownloader().registerCallbacks( group: kBackupGroup, taskStatusCallback: (update) => onUploadStatus?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update), ); FileDownloader().registerCallbacks( group: kBackupLivePhotoGroup, taskStatusCallback: (update) => onUploadStatus?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update), ); FileDownloader().registerCallbacks( group: kManualUploadGroup, taskStatusCallback: (update) => onUploadStatus?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update), ); } Future enqueueBackground(UploadTask task) { return FileDownloader().enqueue(task); } Future> enqueueBackgroundAll(List tasks) { return FileDownloader().enqueueAll(tasks); } Future deleteDatabaseRecords(String group) { return FileDownloader().database.deleteAllRecords(group: group); } Future cancelAll(String group) { return FileDownloader().cancelAll(group: group); } Future reset(String group) { return FileDownloader().reset(group: group); } /// Get a list of tasks that are ENQUEUED or RUNNING Future> getActiveTasks(String group) { return FileDownloader().allTasks(group: group); } Future start() { return FileDownloader().start(); } Future getUploadInfo() async { final [enqueuedTasks, runningTasks, canceledTasks, waitingTasks, pausedTasks] = await Future.wait([ FileDownloader().database.allRecordsWithStatus(TaskStatus.enqueued, group: kBackupGroup), FileDownloader().database.allRecordsWithStatus(TaskStatus.running, group: kBackupGroup), FileDownloader().database.allRecordsWithStatus(TaskStatus.canceled, group: kBackupGroup), FileDownloader().database.allRecordsWithStatus(TaskStatus.waitingToRetry, group: kBackupGroup), FileDownloader().database.allRecordsWithStatus(TaskStatus.paused, group: kBackupGroup), ]); dPrint( () => """ Upload Info: Enqueued: ${enqueuedTasks.length} Running: ${runningTasks.length} Canceled: ${canceledTasks.length} Waiting: ${waitingTasks.length} Paused: ${pausedTasks.length} """, ); } Future uploadFile({ required File file, required String originalFileName, required Map headers, required Map fields, required Client httpClient, required CancellationToken cancelToken, required void Function(int bytes, int totalBytes) onProgress, required String logContext, }) async { final String savedEndpoint = Store.get(StoreKey.serverEndpoint); try { final fileStream = file.openRead(); final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName); final baseRequest = _CustomMultipartRequest('POST', Uri.parse('$savedEndpoint/assets'), onProgress: onProgress); baseRequest.headers.addAll(headers); baseRequest.fields.addAll(fields); baseRequest.files.add(assetRawUploadData); final response = await httpClient.send(baseRequest, cancellationToken: cancelToken); final responseBodyString = await response.stream.bytesToString(); if (![200, 201].contains(response.statusCode)) { String? errorMessage; if (response.statusCode == 413) { errorMessage = 'Error(413) File is too large to upload'; return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage); } try { final error = jsonDecode(responseBodyString); errorMessage = error['message'] ?? error['error']; } catch (_) { errorMessage = responseBodyString.isNotEmpty ? responseBodyString : 'Upload failed with status ${response.statusCode}'; } return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage); } try { final responseBody = jsonDecode(responseBodyString); return UploadResult.success(remoteAssetId: responseBody['id'] as String); } catch (e) { return UploadResult.error(errorMessage: 'Failed to parse server response'); } } on CancelledException { logger.warning("Upload $logContext was cancelled"); return UploadResult.cancelled(); } catch (error, stackTrace) { logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace"); return UploadResult.error(errorMessage: error.toString()); } } } class UploadResult { final bool isSuccess; final bool isCancelled; final String? remoteAssetId; final String? errorMessage; final int? statusCode; const UploadResult({ required this.isSuccess, required this.isCancelled, this.remoteAssetId, this.errorMessage, this.statusCode, }); factory UploadResult.success({required String remoteAssetId}) { return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId); } factory UploadResult.error({String? errorMessage, int? statusCode}) { return UploadResult(isSuccess: false, isCancelled: false, errorMessage: errorMessage, statusCode: statusCode); } factory UploadResult.cancelled() { return const UploadResult(isSuccess: false, isCancelled: true); } } class _CustomMultipartRequest extends MultipartRequest { _CustomMultipartRequest(super.method, super.url, {required this.onProgress}); final void Function(int bytes, int totalBytes) onProgress; @override ByteStream finalize() { final byteStream = super.finalize(); final total = contentLength; var bytes = 0; final t = StreamTransformer.fromHandlers( handleData: (List data, EventSink> sink) { bytes += data.length; onProgress.call(bytes, total); sink.add(data); }, ); final stream = byteStream.transform(t); return ByteStream(stream); } }