mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00

* [Android] Use SAF to change local path * Use IOOverrides to replace openDirectoryPlatform and openFilePlatform * fix io
825 lines
18 KiB
Dart
825 lines
18 KiB
Dart
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<String, dynamic> toJson();
|
|
|
|
LocalComic toLocalComic();
|
|
|
|
String get id;
|
|
|
|
ComicType get comicType;
|
|
|
|
static DownloadTask? fromJson(Map<String, dynamic> 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<String>? 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 = <int>[];
|
|
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<String, List<String>>? _images;
|
|
|
|
int _downloadedCount = 0;
|
|
|
|
int _totalCount = 0;
|
|
|
|
int _index = 0;
|
|
|
|
int _chapter = 0;
|
|
|
|
var tasks = <int, _ImageDownloadWrapper>{};
|
|
|
|
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<String, dynamic> 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<String, dynamic> json) {
|
|
if (json["type"] != "ImagesDownloadTask") {
|
|
return null;
|
|
}
|
|
|
|
Map<String, List<String>>? images;
|
|
if (json["images"] != null) {
|
|
images = {};
|
|
for (var entry in json["images"].entries) {
|
|
images[entry.key] = List<String>.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<Res<T>> runWithRetry<T>(Future<T> 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 = <Completer<_ImageDownloadWrapper>>[];
|
|
|
|
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<void> 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<String, dynamic> toJson() {
|
|
return {
|
|
"type": "ArchiveDownloadTask",
|
|
"archiveUrl": archiveUrl,
|
|
"comic": comic.toJson(),
|
|
"path": path,
|
|
};
|
|
}
|
|
|
|
static ArchiveDownloadTask? fromJson(Map<String, dynamic> 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(),
|
|
);
|
|
}
|
|
}
|