import 'dart:async'; import 'dart:isolate'; import 'package:flutter/widgets.dart' show ChangeNotifier; import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/network/images.dart'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/file_type.dart'; import 'package:venera/utils/io.dart'; import 'package:zip_flutter/zip_flutter.dart'; import 'file_downloader.dart'; abstract class DownloadTask with ChangeNotifier { /// 0-1 double get progress; bool get isError; bool get isPaused; /// bytes per second int get speed; void cancel(); void pause(); void resume(); String get title; String? get cover; String get message; /// root path for the comic. If null, the task is not scheduled. String? path; /// convert current state to json, which can be used to restore the task Map toJson(); LocalComic toLocalComic(); String get id; ComicType get comicType; static DownloadTask? fromJson(Map json) { switch (json["type"]) { case "ImagesDownloadTask": return ImagesDownloadTask.fromJson(json); default: return null; } } } class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin { final ComicSource source; final String comicId; /// comic details. If null, the comic details will be fetched from the source. ComicDetails? comic; /// chapters to download. If null, all chapters will be downloaded. final List? chapters; @override String get id => comicId; @override ComicType get comicType => ComicType(source.key.hashCode); String? comicTitle; ImagesDownloadTask({ required this.source, required this.comicId, this.comic, this.chapters, this.comicTitle, }); @override void cancel() { _isRunning = false; LocalManager().removeTask(this); var local = LocalManager().find(id, comicType); if (path != null) { if (local == null) { Directory(path!).deleteIgnoreError(recursive: true); } else if (chapters != null) { for (var c in chapters!) { var dir = Directory(FilePath.join(path!, c)); if (dir.existsSync()) { dir.deleteSync(recursive: true); } } } } } @override String? get cover => _cover ?? comic?.cover; @override String get message => _message; @override void pause() { if (isPaused) { return; } _isRunning = false; _message = "Paused"; _currentSpeed = 0; var shouldMove = []; for (var entry in tasks.entries) { if (!entry.value.isComplete) { entry.value.cancel(); shouldMove.add(entry.key); } } for (var i in shouldMove) { tasks.remove(i); } stopRecorder(); notifyListeners(); } @override double get progress => _totalCount == 0 ? 0 : _downloadedCount / _totalCount; bool _isRunning = false; bool _isError = false; String _message = "Fetching comic info..."; String? _cover; Map>? _images; int _downloadedCount = 0; int _totalCount = 0; int _index = 0; int _chapter = 0; var tasks = {}; int get _maxConcurrentTasks => (appdata.settings["downloadThreads"] as num).toInt(); void _scheduleTasks() { var images = _images![_images!.keys.elementAt(_chapter)]!; var downloading = 0; for (var i = _index; i < images.length; i++) { if (downloading >= _maxConcurrentTasks) { return; } if (tasks[i] != null) { if (!tasks[i]!.isComplete) { downloading++; } if (tasks[i]!.error == null) { continue; } } Directory saveTo; if (comic!.chapters != null) { saveTo = Directory(FilePath.join( path!, comic!.chapters!.keys.elementAt(_chapter), )); if (!saveTo.existsSync()) { saveTo.createSync(); } } else { saveTo = Directory(path!); } var task = _ImageDownloadWrapper( this, _images!.keys.elementAt(_chapter), images[i], saveTo, i, ); tasks[i] = task; task.wait().then((task) { if (task.isComplete) { _scheduleTasks(); } }); downloading++; } } @override void resume() async { if (_isRunning) return; _isError = false; _message = "Resuming..."; _isRunning = true; notifyListeners(); runRecorder(); if (comic == null) { var res = await runWithRetry(() async { var r = await source.loadComicInfo!(comicId); if (r.error) { throw r.errorMessage!; } else { return r.data; } }); if (!_isRunning) { return; } if (res.error) { _setError("Error: ${res.errorMessage}"); return; } else { comic = res.data; } } if (path == null) { try { var dir = await LocalManager().findValidDirectory( comicId, comicType, comic!.title, ); if (!(await dir.exists())) { await dir.create(); } path = dir.path; } catch (e, s) { Log.error("Download", e.toString(), s); _setError("Error: $e"); return; } } await LocalManager().saveCurrentDownloadingTasks(); if (_cover == null) { var res = await runWithRetry(() async { Uint8List? data; await for (var progress in ImageDownloader.loadThumbnail(comic!.cover, source.key)) { if (progress.imageBytes != null) { data = progress.imageBytes; } } if (data == null) { throw "Failed to download cover"; } var fileType = detectFileType(data); var file = File(FilePath.join(path!, "cover${fileType.ext}")); file.writeAsBytesSync(data); return "file://${file.path}"; }); if (res.error) { Log.error("Download", res.errorMessage!); _setError("Error: ${res.errorMessage}"); return; } else { _cover = res.data; notifyListeners(); } await LocalManager().saveCurrentDownloadingTasks(); } if (_images == null) { if (comic!.chapters == null) { var res = await runWithRetry(() async { var r = await source.loadComicPages!(comicId, null); if (r.error) { throw r.errorMessage!; } else { return r.data; } }); if (!_isRunning) { return; } if (res.error) { Log.error("Download", res.errorMessage!); _setError("Error: ${res.errorMessage}"); return; } else { _images = {'': res.data}; _totalCount = _images!['']!.length; } } else { _images = {}; _totalCount = 0; for (var i in comic!.chapters!.keys) { if (chapters != null && !chapters!.contains(i)) { continue; } if (_images![i] != null) { _totalCount += _images![i]!.length; continue; } var res = await runWithRetry(() async { var r = await source.loadComicPages!(comicId, i); if (r.error) { throw r.errorMessage!; } else { return r.data; } }); if (!_isRunning) { return; } if (res.error) { Log.error("Download", res.errorMessage!); _setError("Error: ${res.errorMessage}"); return; } else { _images![i] = res.data; _totalCount += _images![i]!.length; } } } _message = "$_downloadedCount/$_totalCount"; notifyListeners(); await LocalManager().saveCurrentDownloadingTasks(); } while (_chapter < _images!.length) { var images = _images![_images!.keys.elementAt(_chapter)]!; tasks.clear(); while (_index < images.length) { _scheduleTasks(); var task = tasks[_index]!; await task.wait(); if (isPaused) { return; } if (task.error != null) { Log.error("Download", task.error.toString()); _setError("Error: ${task.error}"); return; } _index++; _downloadedCount++; _message = "$_downloadedCount/$_totalCount"; await LocalManager().saveCurrentDownloadingTasks(); } _index = 0; _chapter++; } LocalManager().completeTask(this); stopRecorder(); } @override void onNextSecond(Timer t) { notifyListeners(); super.onNextSecond(t); } void _setError(String message) { _isRunning = false; _isError = true; _message = message; notifyListeners(); stopRecorder(); } @override int get speed => currentSpeed; @override String get title => comic?.title ?? comicTitle ?? "Loading..."; @override Map toJson() { return { "type": "ImagesDownloadTask", "source": source.key, "comicId": comicId, "comic": comic?.toJson(), "chapters": chapters, "path": path, "cover": cover, "images": _images, "downloadedCount": _downloadedCount, "totalCount": _totalCount, "index": _index, "chapter": _chapter, }; } static ImagesDownloadTask? fromJson(Map json) { if (json["type"] != "ImagesDownloadTask") { return null; } Map>? images; if (json["images"] != null) { images = {}; for (var entry in json["images"].entries) { images[entry.key] = List.from(entry.value); } } return ImagesDownloadTask( source: ComicSource.find(json["source"])!, comicId: json["comicId"], comic: json["comic"] == null ? null : ComicDetails.fromJson(json["comic"]), chapters: ListOrNull.from(json["chapters"]), ) ..path = json["path"] .._cover = json["cover"] .._images = images .._downloadedCount = json["downloadedCount"] .._totalCount = json["totalCount"] .._index = json["index"] .._chapter = json["chapter"]; } @override bool get isError => _isError; @override bool get isPaused => !_isRunning; @override LocalComic toLocalComic() { return LocalComic( id: comic!.id, title: title, subtitle: comic!.subTitle ?? '', tags: comic!.tags.entries.expand((e) { return e.value.map((v) => "${e.key}:$v"); }).toList(), directory: Directory(path!).name, chapters: comic!.chapters, cover: File(_cover!.split("file://").last).name, comicType: ComicType(source.key.hashCode), downloadedChapters: chapters ?? [], createdAt: DateTime.now(), ); } @override bool operator ==(Object other) { if (other is ImagesDownloadTask) { return other.comicId == comicId && other.source.key == source.key; } return false; } @override int get hashCode => Object.hash(comicId, source.key); } Future> runWithRetry(Future Function() task, {int retry = 3}) async { for (var i = 0; i < retry; i++) { try { return Res(await task()); } catch (e) { if (i == retry - 1) { return Res.error(e.toString()); } } } throw UnimplementedError(); } class _ImageDownloadWrapper { final ImagesDownloadTask task; final String chapter; final int index; final String image; final Directory saveTo; _ImageDownloadWrapper( this.task, this.chapter, this.image, this.saveTo, this.index, ) { start(); } bool isComplete = false; String? error; bool isCancelled = false; void cancel() { isCancelled = true; } var completers = >[]; var retry = 3; void start() async { int lastBytes = 0; try { await for (var p in ImageDownloader.loadComicImage( image, task.source.key, task.comicId, chapter)) { if (isCancelled) { return; } task.onData(p.currentBytes - lastBytes); lastBytes = p.currentBytes; if (p.imageBytes != null) { var fileType = detectFileType(p.imageBytes!); var file = saveTo.joinFile("$index${fileType.ext}"); await file.writeAsBytes(p.imageBytes!); isComplete = true; for (var c in completers) { c.complete(this); } completers.clear(); } } } catch (e, s) { if (isCancelled) { return; } Log.error("Download", e.toString(), s); retry--; if (retry > 0) { start(); return; } error = e.toString(); for (var c in completers) { if (!c.isCompleted) { c.complete(this); } } } } Future<_ImageDownloadWrapper> wait() { if (isComplete) { return Future.value(this); } var c = Completer<_ImageDownloadWrapper>(); completers.add(c); return c.future; } } abstract mixin class _TransferSpeedMixin { int _bytesSinceLastSecond = 0; int _currentSpeed = 0; int get currentSpeed => _currentSpeed; Timer? timer; void onData(int length) { if (timer == null) return; if (length < 0) { return; } _bytesSinceLastSecond += length; } void onNextSecond(Timer t) { _currentSpeed = _bytesSinceLastSecond; _bytesSinceLastSecond = 0; } void runRecorder() { if (timer != null) { timer!.cancel(); } _bytesSinceLastSecond = 0; timer = Timer.periodic(const Duration(seconds: 1), onNextSecond); } void stopRecorder() { timer?.cancel(); timer = null; _currentSpeed = 0; _bytesSinceLastSecond = 0; } } class ArchiveDownloadTask extends DownloadTask { final String archiveUrl; final ComicDetails comic; late ComicSource source; /// Download comic by archive url /// /// Currently only support zip file and comics without chapters ArchiveDownloadTask(this.archiveUrl, this.comic) { source = ComicSource.find(comic.sourceKey)!; } FileDownloader? _downloader; String _message = "Fetching comic info..."; bool _isRunning = false; bool _isError = false; void _setError(String message) { _isRunning = false; _isError = true; _message = message; notifyListeners(); Log.error("Download", message); } @override void cancel() async { _isRunning = false; await _downloader?.stop(); if (path != null) { Directory(path!).deleteIgnoreError(recursive: true); } path = null; LocalManager().removeTask(this); } @override ComicType get comicType => ComicType(source.key.hashCode); @override String? get cover => comic.cover; @override String get id => comic.id; @override bool get isError => _isError; @override bool get isPaused => !_isRunning; @override String get message => _message; int _currentBytes = 0; int _expectedBytes = 0; int _speed = 0; @override void pause() { _isRunning = false; _message = "Paused"; _downloader?.stop(); notifyListeners(); } @override double get progress => _expectedBytes == 0 ? 0 : _currentBytes / _expectedBytes; @override void resume() async { if (_isRunning) { return; } _isError = false; _isRunning = true; notifyListeners(); _message = "Downloading..."; if (path == null) { var dir = await LocalManager().findValidDirectory( comic.id, comicType, comic.title, ); if (!(await dir.exists())) { try { await dir.create(); } catch (e) { _setError("Error: $e"); return; } } path = dir.path; } var resultFile = File(FilePath.join(path!, "archive.zip")); Log.info("Download", "Downloading $archiveUrl"); _downloader = FileDownloader(archiveUrl, resultFile.path); bool isDownloaded = false; try { await for (var status in _downloader!.start()) { _currentBytes = status.downloadedBytes; _expectedBytes = status.totalBytes; _message = "${bytesToReadableString(_currentBytes)}/${bytesToReadableString(_expectedBytes)}"; _speed = status.bytesPerSecond; isDownloaded = status.isFinished; notifyListeners(); } } catch (e) { _setError("Error: $e"); return; } if (!_isRunning) { return; } if (!isDownloaded) { _setError("Error: Download failed"); return; } try { await extractArchive(path!); } catch (e) { _setError("Failed to extract archive: $e"); return; } await resultFile.deleteIgnoreError(); LocalManager().completeTask(this); } static Future extractArchive(String path) async { var resultFile = FilePath.join(path, "archive.zip"); await Isolate.run(() { ZipFile.openAndExtract(resultFile, path); }); } @override int get speed => _speed; @override String get title => comic.title; @override Map toJson() { return { "type": "ArchiveDownloadTask", "archiveUrl": archiveUrl, "comic": comic.toJson(), "path": path, }; } static ArchiveDownloadTask? fromJson(Map json) { if (json["type"] != "ArchiveDownloadTask") { return null; } return ArchiveDownloadTask( json["archiveUrl"], ComicDetails.fromJson(json["comic"]), )..path = json["path"]; } String _findCover() { var files = Directory(path!).listSync(); for (var f in files) { if (f.name.startsWith('cover')) { return f.name; } } files.sort((a, b) { return a.name.compareTo(b.name); }); return files.first.name; } @override LocalComic toLocalComic() { return LocalComic( id: comic.id, title: title, subtitle: comic.subTitle ?? '', tags: comic.tags.entries.expand((e) { return e.value.map((v) => "${e.key}:$v"); }).toList(), directory: Directory(path!).name, chapters: null, cover: _findCover(), comicType: ComicType(source.key.hashCode), downloadedChapters: [], createdAt: DateTime.now(), ); } }