downloadMaterial method
- MaterialRefDto material,
- String directory, {
- CancelToken? cancelToken,
- ProgressCallback? onReceiveProgress,
override
Resolves fresh access information and downloads a file into directory.
Keeps course selection locked until the transfer and partial-file cleanup finish. Preserves the required Referer and authentication cookies. Throws UnsupportedError for iStream resources. The caller owns cleanup of successfully downloaded files.
Implementation
@override
Future<MaterialFileDto> downloadMaterial(
MaterialRefDto material,
String directory, {
CancelToken? cancelToken,
ProgressCallback? onReceiveProgress,
}) => _withSelectedCourse(material.course, () async {
if (cancelToken?.cancelError case final error?) throw error;
final access = await _getMaterial(material, cancelToken: cancelToken);
if (access.streamable || _isStream(access.downloadUrl)) {
throw UnsupportedError('iStream downloads are not supported');
}
if (!['http', 'https'].contains(access.downloadUrl.scheme) ||
access.downloadUrl.host.isEmpty) {
throw StateError('Material download URL must be HTTP(S)');
}
var uri = access.downloadUrl;
for (var redirects = 0; redirects <= 10; redirects++) {
if (_isStream(uri)) {
throw UnsupportedError('iStream downloads are not supported');
}
if (!['http', 'https'].contains(uri.scheme) || uri.host.isEmpty) {
throw StateError('Material download URL must be HTTP(S)');
}
final response = await _iSchoolPlusDio.getUri<ResponseBody>(
uri,
cancelToken: cancelToken,
options: Options(
responseType: .stream,
validateStatus: (status) =>
status != null &&
((status >= 200 && status < 300) ||
[301, 302, 303, 307, 308].contains(status)),
followRedirects: false,
extra: {RedirectInterceptor.followRedirects: false},
receiveTimeout: const Duration(minutes: 2),
headers: {HttpHeaders.refererHeader: ?access.referer},
),
);
final body = response.data!;
if ([301, 302, 303, 307, 308].contains(response.statusCode)) {
await body.stream.listen((_) {}).cancel();
final location = response.headers.value(HttpHeaders.locationHeader);
if (location == null || redirects == 10) {
throw StateError('Invalid material redirect');
}
uri = response.realUri.resolve(location);
continue;
}
var fileName = materialFileName(
disposition: response.headers.value('content-disposition'),
title: material.title,
contentType: response.headers.value(HttpHeaders.contentTypeHeader),
);
var file = File(p.join(directory, fileName));
final iterator = StreamIterator(body.stream);
cancelToken?.whenCancel.then((_) => iterator.cancel());
RandomAccessFile? output;
var completed = false;
try {
output = await file.open(mode: .write);
var received = 0;
// Keep only the signature, even when it spans multiple stream chunks.
final leadingBytes = <int>[];
final total =
int.tryParse(
response.headers.value(HttpHeaders.contentLengthHeader) ?? '',
) ??
-1;
while (await iterator.moveNext()) {
if (cancelToken?.cancelError case final error?) throw error;
final bytes = iterator.current;
if (leadingBytes.length < 8) {
leadingBytes.addAll(bytes.take(8 - leadingBytes.length));
}
await output.writeFrom(bytes);
received += bytes.length;
onReceiveProgress?.call(received, total);
}
if (cancelToken?.cancelError case final error?) throw error;
await output.close();
output = null;
final resolvedName = materialFileName(
disposition: response.headers.value('content-disposition'),
title: material.title,
contentType: response.headers.value(HttpHeaders.contentTypeHeader),
leadingBytes: leadingBytes,
);
if (resolvedName != fileName) {
file = await file.rename(p.join(directory, resolvedName));
fileName = resolvedName;
}
if (cancelToken?.cancelError case final error?) throw error;
completed = true;
return (path: file.path, fileName: fileName);
} finally {
await iterator.cancel();
await output?.close();
if (!completed && await file.exists()) await file.delete();
}
}
throw StateError('Too many material redirects');
}, cancelToken: cancelToken);