diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index 6a3dc4e7..a6d2dccd 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'package:catcher/catcher.dart'; @@ -31,6 +30,7 @@ class DownloadManagerProvider extends ChangeNotifier { if (track == null) return; final savePath = getTrackFileUrl(track); + // related to onFileExists final oldFile = File("$savePath.old"); if ((status == DownloadStatus.failed || @@ -40,12 +40,7 @@ class DownloadManagerProvider extends ChangeNotifier { } if (status != DownloadStatus.completed) return; - var file = File(request.path); - - file.copySync(savePath); - file.deleteSync(); - - file = File(savePath); + final file = File(request.path); if (await oldFile.exists()) { await oldFile.delete(); @@ -62,13 +57,13 @@ class DownloadManagerProvider extends ChangeNotifier { album: track.album?.name, albumArtist: track.artists?.map((a) => a.name).join(", "), year: track.album?.releaseDate != null - ? int.tryParse(track.album!.releaseDate!) - : null, + ? int.tryParse(track.album!.releaseDate!) ?? 1969 + : 1969, trackNumber: track.trackNumber, discNumber: track.discNumber, - durationMs: track.durationMs?.toDouble(), - fileSize: file.lengthSync(), - trackTotal: track.album?.tracks?.length, + durationMs: track.durationMs?.toDouble() ?? 0.0, + fileSize: await file.length(), + trackTotal: track.album?.tracks?.length ?? 0, picture: imageBytes != null ? Picture( data: imageBytes, diff --git a/lib/services/download_manager/chunked_download.dart b/lib/services/download_manager/chunked_download.dart new file mode 100644 index 00000000..672acfb3 --- /dev/null +++ b/lib/services/download_manager/chunked_download.dart @@ -0,0 +1,150 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +/// Downloading by spiting as file in chunks +extension ChunkDownload on Dio { + Future chunkedDownload( + url, { + Map? queryParameters, + required String savePath, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + bool deleteOnError = true, + int chunkSize = 102400, // 100KB + int maxConcurrentChunk = 3, + String tempExtension = ".temp", + }) async { + int total = 0; + var progress = []; + + ProgressCallback createCallback(int chunkIndex) { + return (int received, _) { + progress[chunkIndex] = received; + if (onReceiveProgress != null && total != 0) { + onReceiveProgress(progress.reduce((a, b) => a + b), total); + } + }; + } + + // this is the last response + // status & headers will the last chunk's status & headers + final completer = Completer(); + + Future downloadChunk( + String url, { + required int start, + required int end, + required int chunkIndex, + }) async { + progress.add(0); + --end; + final res = await download( + url, + savePath + tempExtension + chunkIndex.toString(), + onReceiveProgress: createCallback(chunkIndex), + cancelToken: cancelToken, + queryParameters: queryParameters, + deleteOnError: deleteOnError, + options: Options( + responseType: ResponseType.bytes, + headers: {"range": "bytes=$start-$end"}, + ), + ); + + return res; + } + + Future mergeTempFiles(int chunk) async { + File headFile = File("$savePath${tempExtension}0"); + var raf = await headFile.open(mode: FileMode.writeOnlyAppend); + for (int i = 1; i < chunk; ++i) { + File chunkFile = File(savePath + tempExtension + i.toString()); + raf = await raf.writeFrom(await chunkFile.readAsBytes()); + await chunkFile.delete(); + } + await raf.close(); + + debugPrint("Downloaded file path: ${headFile.path}"); + + headFile = await headFile.rename(savePath); + + debugPrint("Renamed file path: ${headFile.path}"); + } + + final firstResponse = await downloadChunk( + url, + start: 0, + end: chunkSize, + chunkIndex: 0, + ); + + final responses = [firstResponse]; + + if (firstResponse.statusCode == HttpStatus.partialContent) { + total = int.parse( + firstResponse.headers + .value(HttpHeaders.contentRangeHeader) + ?.split("/") + .lastOrNull ?? + '0', + ); + + final reserved = total - + int.parse( + firstResponse.headers.value(HttpHeaders.contentLengthHeader) ?? + // since its a partial content, the content length will be the chunk size + chunkSize.toString(), + ); + + int chunk = (reserved / chunkSize).ceil() + 1; + + if (chunk > 1) { + int currentChunkSize = chunkSize; + if (chunk > maxConcurrentChunk + 1) { + chunk = maxConcurrentChunk + 1; + currentChunkSize = (reserved / maxConcurrentChunk).ceil(); + } + + responses.addAll( + await Future.wait( + List.generate(maxConcurrentChunk, (i) { + int start = chunkSize + i * currentChunkSize; + return downloadChunk( + url, + start: start, + end: start + currentChunkSize, + chunkIndex: i + 1, + ); + }), + ), + ); + } + + await mergeTempFiles(chunk).then((_) { + final response = responses.last; + final isPartialStatus = + response.statusCode == HttpStatus.partialContent; + + completer.complete( + Response( + data: response.data, + headers: response.headers, + requestOptions: response.requestOptions, + statusCode: isPartialStatus ? HttpStatus.ok : response.statusCode, + statusMessage: isPartialStatus ? 'Ok' : response.statusMessage, + extra: response.extra, + isRedirect: response.isRedirect, + redirects: response.redirects, + ), + ); + }).catchError((e) { + completer.completeError(e); + }); + } + + return completer.future; + } +} diff --git a/lib/services/download_manager/download_manager.dart b/lib/services/download_manager/download_manager.dart index ddfce8a0..0f2f798d 100644 --- a/lib/services/download_manager/download_manager.dart +++ b/lib/services/download_manager/download_manager.dart @@ -1,10 +1,12 @@ import 'dart:async'; import 'dart:collection'; import 'dart:io'; +import 'package:catcher/core/catcher.dart'; import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import 'package:spotube/services/download_manager/chunked_download.dart'; import 'package:spotube/services/download_manager/download_request.dart'; import 'package:spotube/services/download_manager/download_status.dart'; import 'package:spotube/services/download_manager/download_task.dart'; @@ -54,35 +56,39 @@ class DownloadManager { if (total == -1) {} }; - Future download(String url, String savePath, cancelToken, - {forceDownload = false}) async { + Future download( + String url, + String savePath, + CancelToken cancelToken, { + forceDownload = false, + }) async { late String partialFilePath; late File partialFile; try { - var task = getDownload(url); + final task = getDownload(url); if (task == null || task.status.value == DownloadStatus.canceled) { return; } setStatus(task, DownloadStatus.downloading); - debugPrint(url); - var file = File(savePath.toString()); + debugPrint("[DownloadManager] $url"); + final file = File(savePath.toString()); partialFilePath = savePath + partialExtension; partialFile = File(partialFilePath); - var fileExist = await file.exists(); - var partialFileExist = await partialFile.exists(); + final fileExist = await file.exists(); + final partialFileExist = await partialFile.exists(); if (fileExist) { - debugPrint("File Exists"); + debugPrint("[DownloadManager] File Exists"); setStatus(task, DownloadStatus.completed); } else if (partialFileExist) { - debugPrint("Partial File Exists"); + debugPrint("[DownloadManager] Partial File Exists"); - var partialFileLength = await partialFile.length(); + final partialFileLength = await partialFile.length(); - var response = await dio.download( + final response = await dio.download( url, partialFilePath + tempExtension, onReceiveProgress: createCallback(url, partialFileLength), @@ -97,23 +103,20 @@ class DownloadManager { ); if (response.statusCode == HttpStatus.partialContent) { - var ioSink = partialFile.openWrite(mode: FileMode.writeOnlyAppend); - var _f = File(partialFilePath + tempExtension); - await ioSink.addStream(_f.openRead()); - await _f.delete(); + final ioSink = partialFile.openWrite(mode: FileMode.writeOnlyAppend); + final partialChunkFile = File(partialFilePath + tempExtension); + await ioSink.addStream(partialChunkFile.openRead()); + await partialChunkFile.delete(); await ioSink.close(); await partialFile.rename(savePath); setStatus(task, DownloadStatus.completed); } } else { - var response = await dio.download( + final response = await dio.chunkedDownload( url, - partialFilePath, + savePath: partialFilePath, onReceiveProgress: createCallback(url, 0), - options: Options(headers: { - HttpHeaders.connectionHeader: "close", - }), cancelToken: cancelToken, deleteOnError: false, ); @@ -123,7 +126,9 @@ class DownloadManager { setStatus(task, DownloadStatus.completed); } } - } catch (e) { + } catch (e, stackTrace) { + Catcher.reportCheckedError(e, stackTrace); + var task = getDownload(url)!; if (task.status.value != DownloadStatus.canceled && task.status.value != DownloadStatus.paused) { @@ -169,21 +174,9 @@ class DownloadManager { } } - Future addDownload(String url, String savedDir) async { - if (url.isNotEmpty) { - if (savedDir.isEmpty) { - savedDir = "."; - } - - var isDirectory = await Directory(savedDir).exists(); - var downloadFilename = isDirectory - ? savedDir + Platform.pathSeparator + getFileNameFromUrl(url) - : savedDir; - - return _addDownloadRequest(DownloadRequest(url, downloadFilename)); - } - - return null; + Future addDownload(String url, String savedPath) async { + if (url.isEmpty) throw Exception("Invalid Url. Url is empty: $url"); + return _addDownloadRequest(DownloadRequest(url, savedPath)); } Future _addDownloadRequest( @@ -200,7 +193,8 @@ class DownloadManager { } _queue.add(DownloadRequest(downloadRequest.url, downloadRequest.path)); - var task = DownloadTask(_queue.last); + + final task = DownloadTask(_queue.last); _cache[downloadRequest.url] = task; @@ -210,7 +204,7 @@ class DownloadManager { } Future pauseDownload(String url) async { - debugPrint("Pause Download"); + debugPrint("[DownloadManager] Pause Download"); var task = getDownload(url)!; setStatus(task, DownloadStatus.paused); task.request.cancelToken.cancel(); @@ -219,7 +213,7 @@ class DownloadManager { } Future cancelDownload(String url) async { - debugPrint("Cancel Download"); + debugPrint("[DownloadManager] Cancel Download"); var task = getDownload(url)!; setStatus(task, DownloadStatus.canceled); _queue.remove(task.request); @@ -227,7 +221,7 @@ class DownloadManager { } Future resumeDownload(String url) async { - debugPrint("Resume Download"); + debugPrint("[DownloadManager] Resume Download"); var task = getDownload(url)!; setStatus(task, DownloadStatus.downloading); task.request.cancelToken = CancelToken(); @@ -262,10 +256,10 @@ class DownloadManager { } // Batch Download Mechanism - Future addBatchDownloads(List urls, String savedDir) async { - urls.forEach((url) { - addDownload(url, savedDir); - }); + Future addBatchDownloads(List urls, String savePath) async { + for (final url in urls) { + addDownload(url, savePath); + } } List getBatchDownloads(List urls) { @@ -349,7 +343,7 @@ class DownloadManager { var completed = 0; var total = urls.length; - urls.forEach((url) { + for (final url in urls) { DownloadTask? task = getDownload(url); if (task != null) { @@ -381,7 +375,7 @@ class DownloadManager { completer.complete(null); } } - }); + } return completer.future.timeout(timeout); }