Multipart upload

This commit is contained in:
LittleSheep 2024-08-21 01:53:16 +08:00
parent 65d9253876
commit aa94dfcfe0
7 changed files with 233 additions and 137 deletions

View File

@ -1,5 +1,30 @@
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
class AttachmentPlaceholder {
int chunkCount;
int chunkSize;
Attachment meta;
AttachmentPlaceholder({
required this.chunkCount,
required this.chunkSize,
required this.meta,
});
factory AttachmentPlaceholder.fromJson(Map<String, dynamic> json) =>
AttachmentPlaceholder(
chunkCount: json['chunk_count'],
chunkSize: json['chunk_size'],
meta: Attachment.fromJson(json['meta']),
);
Map<String, dynamic> toJson() => {
'chunk_count': chunkCount,
'chunk_size': chunkSize,
'meta': meta.toJson(),
};
}
class Attachment { class Attachment {
int id; int id;
DateTime createdAt; DateTime createdAt;
@ -14,7 +39,9 @@ class Attachment {
String hash; String hash;
int destination; int destination;
bool isAnalyzed; bool isAnalyzed;
bool isUploaded;
Map<String, dynamic>? metadata; Map<String, dynamic>? metadata;
Map<String, dynamic>? fileChunks;
bool isMature; bool isMature;
Account? account; Account? account;
int? accountId; int? accountId;
@ -33,7 +60,9 @@ class Attachment {
required this.hash, required this.hash,
required this.destination, required this.destination,
required this.isAnalyzed, required this.isAnalyzed,
required this.isUploaded,
required this.metadata, required this.metadata,
required this.fileChunks,
required this.isMature, required this.isMature,
required this.account, required this.account,
required this.accountId, required this.accountId,
@ -55,7 +84,9 @@ class Attachment {
hash: json['hash'], hash: json['hash'],
destination: json['destination'], destination: json['destination'],
isAnalyzed: json['is_analyzed'], isAnalyzed: json['is_analyzed'],
isUploaded: json['is_uploaded'],
metadata: json['metadata'], metadata: json['metadata'],
fileChunks: json['file_chunks'],
isMature: json['is_mature'], isMature: json['is_mature'],
account: account:
json['account'] != null ? Account.fromJson(json['account']) : null, json['account'] != null ? Account.fromJson(json['account']) : null,
@ -76,7 +107,9 @@ class Attachment {
'hash': hash, 'hash': hash,
'destination': destination, 'destination': destination,
'is_analyzed': isAnalyzed, 'is_analyzed': isAnalyzed,
'is_uploaded': isUploaded,
'metadata': metadata, 'metadata': metadata,
'file_chunks': fileChunks,
'is_mature': isMature, 'is_mature': isMature,
'account': account?.toJson(), 'account': account?.toJson(),
'account_id': accountId, 'account_id': accountId,

View File

@ -1,15 +1,17 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:cross_file/cross_file.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:path/path.dart' show basename;
import 'package:solian/models/attachment.dart'; import 'package:solian/models/attachment.dart';
import 'package:solian/providers/content/attachment.dart'; import 'package:solian/providers/content/attachment.dart';
class AttachmentUploadTask { class AttachmentUploadTask {
File file; XFile file;
String usage; String pool;
Map<String, dynamic>? metadata; Map<String, dynamic>? metadata;
Map<String, int>? chunkFiles;
double progress = 0; double progress = 0;
bool isUploading = false; bool isUploading = false;
@ -18,7 +20,7 @@ class AttachmentUploadTask {
AttachmentUploadTask({ AttachmentUploadTask({
required this.file, required this.file,
required this.usage, required this.pool,
this.metadata, this.metadata,
}); });
} }
@ -75,21 +77,23 @@ class AttachmentUploaderController extends GetxController {
queueOfUpload[queueIndex].isUploading = true; queueOfUpload[queueIndex].isUploading = true;
final task = queueOfUpload[queueIndex]; final task = queueOfUpload[queueIndex];
final result = await _rawUploadAttachment( try {
await task.file.readAsBytes(), final result = await _chunkedUploadAttachment(
task.file.path, task.file,
task.usage, task.pool,
null, null,
onProgress: (value) { onData: (_) {},
queueOfUpload[queueIndex].progress = value; onProgress: (progress) {
_progressOfUpload = value; queueOfUpload[queueIndex].progress = progress;
}, _progressOfUpload = progress;
onError: (err) {
queueOfUpload[queueIndex].error = err;
queueOfUpload[queueIndex].isUploading = false;
}, },
); );
return result;
} catch (err) {
queueOfUpload[queueIndex].error = err;
queueOfUpload[queueIndex].isUploading = false;
} finally {
_progressOfUpload = 1;
if (queueOfUpload[queueIndex].error == null) { if (queueOfUpload[queueIndex].error == null) {
queueOfUpload.removeAt(queueIndex); queueOfUpload.removeAt(queueIndex);
} }
@ -97,8 +101,9 @@ class AttachmentUploaderController extends GetxController {
_syncProgress(); _syncProgress();
isUploading.value = false; isUploading.value = false;
}
return result; return null;
} }
Future<void> performUploadQueue({ Future<void> performUploadQueue({
@ -117,22 +122,23 @@ class AttachmentUploaderController extends GetxController {
queueOfUpload[idx].isUploading = true; queueOfUpload[idx].isUploading = true;
final task = queueOfUpload[idx]; final task = queueOfUpload[idx];
final result = await _rawUploadAttachment( try {
await task.file.readAsBytes(), final result = await _chunkedUploadAttachment(
task.file.path, task.file,
task.usage, task.pool,
null, null,
onProgress: (value) { onData: (_) {},
queueOfUpload[idx].progress = value; onProgress: (progress) {
_progressOfUpload = (idx + value) / queueOfUpload.length; queueOfUpload[idx].progress = progress;
},
onError: (err) {
queueOfUpload[idx].error = err;
queueOfUpload[idx].isUploading = false;
}, },
); );
_progressOfUpload = (idx + 1) / queueOfUpload.length;
if (result != null) onData(result); if (result != null) onData(result);
} catch (err) {
queueOfUpload[idx].error = err;
queueOfUpload[idx].isUploading = false;
} finally {
_progressOfUpload = (idx + 1) / queueOfUpload.length;
}
queueOfUpload[idx].isUploading = false; queueOfUpload[idx].isUploading = false;
queueOfUpload[idx].isCompleted = true; queueOfUpload[idx].isCompleted = true;
@ -145,69 +151,75 @@ class AttachmentUploaderController extends GetxController {
isUploading.value = false; isUploading.value = false;
} }
Future<void> uploadAttachmentWithCallback( Future<Attachment?> uploadAttachmentFromData(
Uint8List data,
String path,
String pool,
Map<String, dynamic>? metadata,
Function(Attachment?) callback,
) async {
if (isUploading.value) throw Exception('uploading blocked');
isUploading.value = true;
final result = await _rawUploadAttachment(
data,
path,
pool,
metadata,
onProgress: (progress) {
progressOfUpload.value = progress;
},
);
isUploading.value = false;
callback(result);
}
Future<Attachment?> uploadAttachment(
Uint8List data, Uint8List data,
String path, String path,
String pool, String pool,
Map<String, dynamic>? metadata, Map<String, dynamic>? metadata,
) async { ) async {
if (isUploading.value) throw Exception('uploading blocked'); if (isUploading.value) throw Exception('uploading blocked');
isUploading.value = true; isUploading.value = true;
final result = await _rawUploadAttachment(
data,
path,
pool,
metadata,
onProgress: (progress) {
progressOfUpload.value = progress;
},
);
isUploading.value = false;
return result;
}
Future<Attachment?> _rawUploadAttachment( final AttachmentProvider attach = Get.find();
Uint8List data, String path, String pool, Map<String, dynamic>? metadata,
{Function(double)? onProgress, Function(dynamic err)? onError}) async {
final AttachmentProvider provider = Get.find();
try { try {
final result = await provider.createAttachment( final result = await attach.createAttachmentDirectly(
data, data,
path, path,
pool, pool,
metadata, metadata,
onProgress: onProgress,
); );
return result; return result;
} catch (err) { } catch (_) {
if (onError != null) {
onError(err);
}
return null; return null;
} finally {
isUploading.value = false;
} }
} }
Future<Attachment?> _chunkedUploadAttachment(
XFile file,
String pool,
Map<String, dynamic>? metadata, {
required Function(AttachmentPlaceholder) onData,
required Function(double) onProgress,
}) async {
final AttachmentProvider attach = Get.find();
final holder = await attach.createAttachmentMultipartPlaceholder(
await file.length(),
file.path,
pool,
metadata,
);
onData(holder);
onProgress(0);
final filename = basename(file.path);
final chunks = holder.meta.fileChunks ?? {};
var currentTask = 0;
for (final entry in chunks.entries) {
final beginCursor = entry.value * holder.chunkSize;
final endCursor = (entry.value + 1) * holder.chunkSize;
final data = Uint8List.fromList(await file
.openRead(beginCursor, endCursor)
.expand((chunk) => chunk)
.toList());
final out = await attach.uploadAttachmentMultipartChunk(
data,
filename,
holder.meta.rid,
entry.key,
);
holder.meta = out;
currentTask++;
onProgress(currentTask / chunks.length);
onData(holder);
}
return holder.meta;
}
} }

View File

@ -7,7 +7,6 @@ import 'package:solian/models/attachment.dart';
import 'package:solian/models/pagination.dart'; import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:dio/dio.dart' as dio;
class AttachmentProvider extends GetConnect { class AttachmentProvider extends GetConnect {
static Map<String, String> mimetypeOverrides = { static Map<String, String> mimetypeOverrides = {
@ -83,16 +82,21 @@ class AttachmentProvider extends GetConnect {
return null; return null;
} }
Future<Attachment> createAttachment( Future<Attachment> createAttachmentDirectly(
Uint8List data, String path, String pool, Map<String, dynamic>? metadata, Uint8List data,
{Function(double)? onProgress}) async { String path,
String pool,
Map<String, dynamic>? metadata,
) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw Exception('unauthorized');
await auth.ensureCredentials(); final client = auth.configureClient(
'uc',
timeout: const Duration(minutes: 3),
);
final filePayload = final filePayload = MultipartFile(data, filename: basename(path));
dio.MultipartFile.fromBytes(data, filename: basename(path));
final fileAlt = basename(path).contains('.') final fileAlt = basename(path).contains('.')
? basename(path).substring(0, basename(path).lastIndexOf('.')) ? basename(path).substring(0, basename(path).lastIndexOf('.'))
: basename(path); : basename(path);
@ -105,30 +109,82 @@ class AttachmentProvider extends GetConnect {
if (mimetypeOverrides.keys.contains(fileExt)) { if (mimetypeOverrides.keys.contains(fileExt)) {
mimetypeOverride = mimetypeOverrides[fileExt]; mimetypeOverride = mimetypeOverrides[fileExt];
} }
final payload = dio.FormData.fromMap({ final payload = FormData({
'alt': fileAlt, 'alt': fileAlt,
'file': filePayload, 'file': filePayload,
'pool': pool, 'pool': pool,
if (mimetypeOverride != null) 'mimetype': mimetypeOverride, if (mimetypeOverride != null) 'mimetype': mimetypeOverride,
'metadata': jsonEncode(metadata), 'metadata': jsonEncode(metadata),
}); });
final resp = await dio.Dio( final resp = await client.post('/attachments', payload);
dio.BaseOptions(
baseUrl: ServiceFinder.buildUrl('files', null),
headers: {'Authorization': 'Bearer ${auth.credentials!.accessToken}'},
),
).post(
'/attachments',
data: payload,
onSendProgress: (count, total) {
if (onProgress != null) onProgress(count / total);
},
);
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.data); throw Exception(resp.bodyString);
} }
return Attachment.fromJson(resp.data); return Attachment.fromJson(resp.body);
}
Future<AttachmentPlaceholder> createAttachmentMultipartPlaceholder(
int size,
String path,
String pool,
Map<String, dynamic>? metadata,
) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized');
final client = auth.configureClient('uc');
final fileAlt = basename(path).contains('.')
? basename(path).substring(0, basename(path).lastIndexOf('.'))
: basename(path);
final fileExt = basename(path)
.substring(basename(path).lastIndexOf('.') + 1)
.toLowerCase();
// Override for some files cannot be detected mimetype by server-side
String? mimetypeOverride;
if (mimetypeOverrides.keys.contains(fileExt)) {
mimetypeOverride = mimetypeOverrides[fileExt];
}
final resp = await client.post('/attachments/multipart', {
'alt': fileAlt,
'name': basename(path),
'size': size,
'pool': pool,
if (mimetypeOverride != null) 'mimetype': mimetypeOverride,
'metadata': metadata,
});
if (resp.statusCode != 200) {
throw Exception(resp.bodyString);
}
return AttachmentPlaceholder.fromJson(resp.body);
}
Future<Attachment> uploadAttachmentMultipartChunk(
Uint8List data,
String name,
String rid,
String cid,
) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized');
final client = auth.configureClient(
'uc',
timeout: const Duration(minutes: 3),
);
final payload = FormData({
'file': MultipartFile(data, filename: name),
});
final resp = await client.post('/attachments/multipart/$rid/$cid', payload);
if (resp.statusCode != 200) {
throw Exception(resp.bodyString);
}
return Attachment.fromJson(resp.body);
} }
Future<Response> updateAttachment( Future<Response> updateAttachment(

View File

@ -113,7 +113,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
Attachment? attachResult; Attachment? attachResult;
try { try {
attachResult = await provider.createAttachment( attachResult = await provider.createAttachmentDirectly(
await file.readAsBytes(), await file.readAsBytes(),
file.path, file.path,
'avatar', 'avatar',

View File

@ -72,8 +72,8 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
if (medias.isEmpty) return; if (medias.isEmpty) return;
_enqueueTaskBatch(medias.map((x) { _enqueueTaskBatch(medias.map((x) {
final file = File(x.path); final file = XFile(x.path);
return AttachmentUploadTask(file: file, usage: widget.pool); return AttachmentUploadTask(file: file, pool: widget.pool);
})); }));
} else { } else {
final media = await _imagePicker.pickMedia( final media = await _imagePicker.pickMedia(
@ -83,7 +83,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
if (media == null) return; if (media == null) return;
_enqueueTask( _enqueueTask(
AttachmentUploadTask(file: File(media.path), usage: widget.pool), AttachmentUploadTask(file: XFile(media.path), pool: widget.pool),
); );
} }
} }
@ -95,9 +95,8 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
final media = await _imagePicker.pickVideo(source: ImageSource.gallery); final media = await _imagePicker.pickVideo(source: ImageSource.gallery);
if (media == null) return; if (media == null) return;
final file = File(media.path);
_enqueueTask( _enqueueTask(
AttachmentUploadTask(file: file, usage: widget.pool), AttachmentUploadTask(file: XFile(media.path), pool: widget.pool),
); );
} }
@ -113,7 +112,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
List<File> files = result.paths.map((path) => File(path!)).toList(); List<File> files = result.paths.map((path) => File(path!)).toList();
_enqueueTaskBatch(files.map((x) { _enqueueTaskBatch(files.map((x) {
return AttachmentUploadTask(file: x, usage: widget.pool); return AttachmentUploadTask(file: XFile(x.path), pool: widget.pool);
})); }));
} }
@ -129,9 +128,8 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
} }
if (media == null) return; if (media == null) return;
final file = File(media.path);
_enqueueTask( _enqueueTask(
AttachmentUploadTask(file: file, usage: widget.pool), AttachmentUploadTask(file: XFile(media.path), pool: widget.pool),
); );
} }
@ -197,20 +195,16 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
if (_uploadController.isUploading.value) return; if (_uploadController.isUploading.value) return;
_uploadController.uploadAttachmentWithCallback( _uploadController
data, .uploadAttachmentFromData(data, 'Pasted Image', widget.pool, null)
'Pasted Image', .then((item) {
widget.pool,
null,
(item) {
if (item == null) return; if (item == null) return;
widget.onAdd(item.rid); widget.onAdd(item.rid);
if (mounted) { if (mounted) {
setState(() => _attachments.add(item)); setState(() => _attachments.add(item));
if (widget.singleMode) Navigator.pop(context); if (widget.singleMode) Navigator.pop(context);
} }
}, });
);
} }
String _formatBytes(int bytes, {int decimals = 2}) { String _formatBytes(int bytes, {int decimals = 2}) {
@ -304,7 +298,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
], ],
); );
if (croppedFile == null) return; if (croppedFile == null) return;
_uploadController.queueOfUpload[queueIndex].file = File(croppedFile.path); _uploadController.queueOfUpload[queueIndex].file = XFile(croppedFile.path);
_uploadController.queueOfUpload.refresh(); _uploadController.queueOfUpload.refresh();
} }
@ -581,8 +575,8 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
onDragDone: (detail) async { onDragDone: (detail) async {
if (_uploadController.isUploading.value) return; if (_uploadController.isUploading.value) return;
_enqueueTaskBatch(detail.files.map((x) { _enqueueTaskBatch(detail.files.map((x) {
final file = File(x.path); final file = XFile(x.path);
return AttachmentUploadTask(file: file, usage: widget.pool); return AttachmentUploadTask(file: file, pool: widget.pool);
})); }));
}, },
child: Column( child: Column(

View File

@ -263,7 +263,7 @@ packages:
source: hosted source: hosted
version: "3.1.1" version: "3.1.1"
cross_file: cross_file:
dependency: transitive dependency: "direct main"
description: description:
name: cross_file name: cross_file
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"

View File

@ -72,6 +72,7 @@ dependencies:
media_kit_video: ^1.2.4 media_kit_video: ^1.2.4
media_kit_libs_video: ^1.0.4 media_kit_libs_video: ^1.0.4
flutter_svg: ^2.0.10+1 flutter_svg: ^2.0.10+1
cross_file: ^0.3.4+2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: